From 01529aaa82b9f5c58d2bb87264b7b3f7b823678f Mon Sep 17 00:00:00 2001 From: Niko1221 Date: Thu, 24 Sep 2026 18:41:57 +0200 Subject: [PATCH] Strata: Qwen3.8-Flash-Next on a 12-24 GB NVIDIA GPU + 64 GB RAM, one-click install Inference engine, OpenAI/Anthropic API on localhost, optional image input, one-file setup (START-HERE.bat / setup.sh). Model files are downloaded from Hugging Face by the setup. --- .gitattributes | 6 + .gitignore | 48 + CMakeLists.txt | 536 ++++ README.md | 313 ++ START-HERE.bat | 52 + bench/results/2026-09-24-final/estimates.json | 461 +++ bench/results/2026-09-24-final/estimates.md | 27 + bench/results/2026-09-24-final/matrix.json | 240 ++ bench/results/2026-09-24-final/matrix.md | 15 + bench/results/2026-09-24-vision/vision.json | 53 + bench/results/2026-09-24-vision/vision.md | 5 + chat.py | 116 + data/draft_vocab.bin | Bin 0 -> 162100 bytes data/expert-profile.bin | Bin 0 -> 130328 bytes docs/paper/Strata-Paper.pdf | Bin 0 -> 341672 bytes docs/paper/tiers.svg | 18 + include/strata/artifact/dequant.hpp | 292 ++ include/strata/artifact/gguf_reader.hpp | 486 +++ include/strata/core/device.hpp | 71 + include/strata/core/expert_cache.hpp | 155 + include/strata/core/expert_source.hpp | 354 +++ include/strata/core/graph.hpp | 117 + include/strata/core/hit_hook.hpp | 41 + include/strata/core/layer.hpp | 566 ++++ include/strata/core/layout.hpp | 97 + include/strata/core/mtp.hpp | 135 + include/strata/core/native_dense.hpp | 36 + include/strata/core/native_head.hpp | 65 + include/strata/core/pinned.hpp | 95 + include/strata/core/session.hpp | 411 +++ include/strata/core/verify.hpp | 160 + include/strata/core/weights.hpp | 135 + include/strata/kernels/bf16_bits.hpp | 46 + include/strata/kernels/bf16_gemv.hpp | 48 + include/strata/kernels/cpu/expert.hpp | 179 ++ include/strata/kernels/cpu/expert_layout.hpp | 51 + include/strata/kernels/cpu/iq_avx512.hpp | 18 + include/strata/kernels/cpu/native_expert.hpp | 49 + include/strata/kernels/cpu/pool.hpp | 195 ++ include/strata/kernels/dequant_bf16.hpp | 27 + include/strata/kernels/dequant_s2.hpp | 18 + include/strata/kernels/elementwise.hpp | 116 + include/strata/kernels/f16_bits.hpp | 96 + include/strata/kernels/fused_gdn.hpp | 35 + include/strata/kernels/fused_gr.hpp | 51 + include/strata/kernels/gdn.hpp | 97 + include/strata/kernels/gr.hpp | 122 + include/strata/kernels/iq_kernels.hpp | 57 + include/strata/kernels/kv_q8.hpp | 38 + include/strata/kernels/mrope.hpp | 32 + include/strata/kernels/native_flash_attn.hpp | 36 + include/strata/kernels/native_gdn.hpp | 22 + .../strata/kernels/native_gdn_preprocess.hpp | 37 + include/strata/kernels/native_gr_norm.hpp | 18 + include/strata/kernels/native_gr_postops.hpp | 28 + include/strata/kernels/native_mmvq.hpp | 112 + include/strata/kernels/native_moe.hpp | 18 + include/strata/kernels/native_ple_postops.hpp | 30 + include/strata/kernels/native_qsa.hpp | 30 + include/strata/kernels/native_qsa_indexer.hpp | 35 + include/strata/kernels/native_qsa_score.hpp | 31 + include/strata/kernels/native_rope.hpp | 14 + include/strata/kernels/native_router.hpp | 17 + include/strata/kernels/ngram.hpp | 194 ++ include/strata/kernels/ple.hpp | 119 + include/strata/kernels/qsa.hpp | 360 +++ include/strata/kernels/qsa_decode_attn.hpp | 44 + include/strata/kernels/qsa_select.hpp | 33 + include/strata/kernels/quantize_act.hpp | 58 + include/strata/kernels/rope.hpp | 50 + include/strata/kernels/router_top10.hpp | 20 + include/strata/kernels/s2_expert_grouped.hpp | 118 + include/strata/kernels/s2_gemv.hpp | 25 + include/strata/kernels/s2_gemv_q8.hpp | 22 + include/strata/kernels/s_gemv.hpp | 133 + include/strata/kernels/sampler.hpp | 43 + include/strata/kernels/shared_expert.hpp | 101 + include/strata/kernels/verify_kernels.hpp | 80 + include/strata/ngram/ple_reader.hpp | 86 + include/strata/plan/plan.hpp | 168 + include/strata/platform/direct_file.hpp | 59 + include/strata/platform/memory.hpp | 26 + include/strata/prefill/gemm.hpp | 54 + include/strata/prefill/kernels.hpp | 74 + include/strata/prefill/prefill.hpp | 75 + include/strata/program/logits_selection.hpp | 30 + include/strata/spec/controller.hpp | 73 + include/strata/spec/suffix_drafter.hpp | 53 + ref/load.py | 212 ++ serve/__init__.py | 1 + serve/chat_golden.json | 197 ++ serve/chat_template.jinja | 184 ++ serve/frontend.py | 344 ++ serve/index.html | 152 + serve/server.py | 606 ++++ setup.py | 806 +++++ setup.sh | 30 + src/artifact/dequant.cpp | 123 + src/artifact/gguf_reader.cpp | 67 + src/core/concurrent_main.cpp | 197 ++ src/core/device.cu | 117 + src/core/device_main.cpp | 78 + src/core/expert_cache.cpp | 297 ++ src/core/expert_source.cpp | 709 +++++ src/core/graph.cpp | 146 + src/core/layer.cpp | 1063 +++++++ src/core/layout.cpp | 173 + src/core/load_main.cpp | 119 + src/core/mtp.cpp | 588 ++++ src/core/native_dense.cpp | 179 ++ src/core/native_head.cpp | 151 + src/core/overlap_main.cpp | 169 + src/core/pinned.cu | 291 ++ src/core/session.cpp | 930 ++++++ src/core/verify.cpp | 877 +++++ src/core/weights.cpp | 456 +++ src/kernels/bf16_gemv_parity.cpp | 148 + src/kernels/cpu/expert.cpp | 683 ++++ src/kernels/cpu/expert_layout.cpp | 136 + src/kernels/cpu/expert_multi_test.cpp | 87 + src/kernels/cpu/expert_parity.cpp | 184 ++ src/kernels/cpu/iq_avx512.cpp | 246 ++ src/kernels/cpu/native_expert.cpp | 114 + src/kernels/cpu/pool.cpp | 397 +++ src/kernels/cpu/pool_test.cpp | 224 ++ src/kernels/cpu/q2_avx2.cpp | 130 + src/kernels/cuda/bf16_gemv.cu | 141 + src/kernels/cuda/dequant_bf16.cu | 245 ++ src/kernels/cuda/dequant_s2.cu | 51 + src/kernels/cuda/elementwise.cu | 289 ++ src/kernels/cuda/fused_gdn.cu | 166 + src/kernels/cuda/fused_gr.cu | 354 +++ src/kernels/cuda/gdn.cu | 248 ++ src/kernels/cuda/gr.cu | 432 +++ src/kernels/cuda/iq_kernels.cu | 720 +++++ src/kernels/cuda/kv_q8.cu | 118 + src/kernels/cuda/native_bf16.cu | 118 + src/kernels/cuda/native_flash_attn.cu | 212 ++ src/kernels/cuda/native_gdn.cu | 122 + src/kernels/cuda/native_gdn_preprocess.cu | 199 ++ src/kernels/cuda/native_gr_norm.cu | 102 + src/kernels/cuda/native_gr_postops.cu | 115 + src/kernels/cuda/native_mmvq.cu | 1492 +++++++++ src/kernels/cuda/native_moe.cu | 74 + src/kernels/cuda/native_ple_postops.cu | 134 + src/kernels/cuda/native_qsa.cu | 127 + src/kernels/cuda/native_qsa_indexer.cu | 129 + src/kernels/cuda/native_qsa_score.cu | 151 + src/kernels/cuda/native_rope.cu | 88 + src/kernels/cuda/native_router.cu | 117 + src/kernels/cuda/ple.cu | 366 +++ src/kernels/cuda/qsa.cu | 797 +++++ src/kernels/cuda/qsa_decode_attn.cu | 242 ++ src/kernels/cuda/qsa_select.cu | 179 ++ src/kernels/cuda/quantize_act.cu | 313 ++ src/kernels/cuda/rope.cu | 95 + src/kernels/cuda/router_top10.cu | 226 ++ src/kernels/cuda/s2_expert_grouped.cu | 785 +++++ src/kernels/cuda/s2_gemv.cu | 63 + src/kernels/cuda/s2_gemv_fast.cu | 150 + src/kernels/cuda/s2_gemv_q8.cu | 96 + src/kernels/cuda/s2_gemv_quads.cu | 93 + src/kernels/cuda/s_gemv.cu | 686 ++++ src/kernels/cuda/sampler.cu | 320 ++ src/kernels/cuda/shared_expert.cu | 338 ++ src/kernels/cuda/verify_kernels.cu | 450 +++ src/kernels/dequant_bf16_test.cpp | 108 + src/kernels/dequant_s2_parity.cpp | 143 + src/kernels/elementwise_parity.cpp | 416 +++ src/kernels/gdn_parity.cpp | 397 +++ src/kernels/gr_parity.cpp | 615 ++++ src/kernels/iq_parity.cpp | 87 + src/kernels/kv_q8_parity.cpp | 141 + src/kernels/native_expert_parity.cpp | 204 ++ src/kernels/ngram.cpp | 348 ++ src/kernels/ple_oracle_vectors.inc | 105 + src/kernels/ple_parity.cpp | 1184 +++++++ src/kernels/qsa_oracle_vectors.inc | 134 + src/kernels/qsa_parity.cpp | 1383 ++++++++ src/kernels/quantize_act_parity.cpp | 407 +++ src/kernels/rope_parity.cpp | 171 + src/kernels/router_top10_parity.cpp | 167 + src/kernels/s2_gemv_parity.cpp | 173 + src/kernels/s2_gemv_q8_parity.cpp | 181 ++ src/kernels/s_gemv_parity.cpp | 634 ++++ src/kernels/s_gemv_q8k_parity.cpp | 236 ++ src/kernels/sampler_parity.cpp | 362 +++ src/kernels/shared_expert_parity.cpp | 483 +++ src/ngram/ple_reader.cpp | 396 +++ src/ngram/ple_reader_test.cpp | 237 ++ src/plan/plan_main.cpp | 73 + src/platform/direct_file.cpp | 241 ++ src/platform/memory.cpp | 76 + src/platform/memory_test.cpp | 35 + src/prefill/gemm.cu | 107 + src/prefill/kernels.cu | 482 +++ src/prefill/prefill.cpp | 537 ++++ src/program/generate.cpp | 2818 +++++++++++++++++ src/spec/controller.cpp | 73 + src/spec/controller_test.cpp | 74 + src/spec/suffix_drafter.cpp | 88 + src/spec/suffix_drafter_test.cpp | 141 + third_party/ggml/LICENSE | 21 + third_party/ggml/VERSION.txt | 1 + third_party/ggml/ggml-common.h | 1911 +++++++++++ tools/_paths.py | 28 + tools/canonical_xcheck.py | 799 +++++ tools/gguf_reader.py | 142 + tools/gguf_writer.py | 234 ++ tools/iq_pack.py | 221 ++ tools/mtp_fetch.py | 121 + tools/mtp_pack.py | 193 ++ tools/mtp_rt.py | 112 + tools/pack_index.py | 260 ++ tools/pack_layer.py | 123 + tools/strata_pack.py | 413 +++ tools/strata_tokenizer.py | 266 ++ tools/vision/CMakeLists.txt | 44 + tools/vision/strata_vision.cpp | 158 + 219 files changed, 50165 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 START-HERE.bat create mode 100644 bench/results/2026-09-24-final/estimates.json create mode 100644 bench/results/2026-09-24-final/estimates.md create mode 100644 bench/results/2026-09-24-final/matrix.json create mode 100644 bench/results/2026-09-24-final/matrix.md create mode 100644 bench/results/2026-09-24-vision/vision.json create mode 100644 bench/results/2026-09-24-vision/vision.md create mode 100644 chat.py create mode 100644 data/draft_vocab.bin create mode 100644 data/expert-profile.bin create mode 100644 docs/paper/Strata-Paper.pdf create mode 100644 docs/paper/tiers.svg create mode 100644 include/strata/artifact/dequant.hpp create mode 100644 include/strata/artifact/gguf_reader.hpp create mode 100644 include/strata/core/device.hpp create mode 100644 include/strata/core/expert_cache.hpp create mode 100644 include/strata/core/expert_source.hpp create mode 100644 include/strata/core/graph.hpp create mode 100644 include/strata/core/hit_hook.hpp create mode 100644 include/strata/core/layer.hpp create mode 100644 include/strata/core/layout.hpp create mode 100644 include/strata/core/mtp.hpp create mode 100644 include/strata/core/native_dense.hpp create mode 100644 include/strata/core/native_head.hpp create mode 100644 include/strata/core/pinned.hpp create mode 100644 include/strata/core/session.hpp create mode 100644 include/strata/core/verify.hpp create mode 100644 include/strata/core/weights.hpp create mode 100644 include/strata/kernels/bf16_bits.hpp create mode 100644 include/strata/kernels/bf16_gemv.hpp create mode 100644 include/strata/kernels/cpu/expert.hpp create mode 100644 include/strata/kernels/cpu/expert_layout.hpp create mode 100644 include/strata/kernels/cpu/iq_avx512.hpp create mode 100644 include/strata/kernels/cpu/native_expert.hpp create mode 100644 include/strata/kernels/cpu/pool.hpp create mode 100644 include/strata/kernels/dequant_bf16.hpp create mode 100644 include/strata/kernels/dequant_s2.hpp create mode 100644 include/strata/kernels/elementwise.hpp create mode 100644 include/strata/kernels/f16_bits.hpp create mode 100644 include/strata/kernels/fused_gdn.hpp create mode 100644 include/strata/kernels/fused_gr.hpp create mode 100644 include/strata/kernels/gdn.hpp create mode 100644 include/strata/kernels/gr.hpp create mode 100644 include/strata/kernels/iq_kernels.hpp create mode 100644 include/strata/kernels/kv_q8.hpp create mode 100644 include/strata/kernels/mrope.hpp create mode 100644 include/strata/kernels/native_flash_attn.hpp create mode 100644 include/strata/kernels/native_gdn.hpp create mode 100644 include/strata/kernels/native_gdn_preprocess.hpp create mode 100644 include/strata/kernels/native_gr_norm.hpp create mode 100644 include/strata/kernels/native_gr_postops.hpp create mode 100644 include/strata/kernels/native_mmvq.hpp create mode 100644 include/strata/kernels/native_moe.hpp create mode 100644 include/strata/kernels/native_ple_postops.hpp create mode 100644 include/strata/kernels/native_qsa.hpp create mode 100644 include/strata/kernels/native_qsa_indexer.hpp create mode 100644 include/strata/kernels/native_qsa_score.hpp create mode 100644 include/strata/kernels/native_rope.hpp create mode 100644 include/strata/kernels/native_router.hpp create mode 100644 include/strata/kernels/ngram.hpp create mode 100644 include/strata/kernels/ple.hpp create mode 100644 include/strata/kernels/qsa.hpp create mode 100644 include/strata/kernels/qsa_decode_attn.hpp create mode 100644 include/strata/kernels/qsa_select.hpp create mode 100644 include/strata/kernels/quantize_act.hpp create mode 100644 include/strata/kernels/rope.hpp create mode 100644 include/strata/kernels/router_top10.hpp create mode 100644 include/strata/kernels/s2_expert_grouped.hpp create mode 100644 include/strata/kernels/s2_gemv.hpp create mode 100644 include/strata/kernels/s2_gemv_q8.hpp create mode 100644 include/strata/kernels/s_gemv.hpp create mode 100644 include/strata/kernels/sampler.hpp create mode 100644 include/strata/kernels/shared_expert.hpp create mode 100644 include/strata/kernels/verify_kernels.hpp create mode 100644 include/strata/ngram/ple_reader.hpp create mode 100644 include/strata/plan/plan.hpp create mode 100644 include/strata/platform/direct_file.hpp create mode 100644 include/strata/platform/memory.hpp create mode 100644 include/strata/prefill/gemm.hpp create mode 100644 include/strata/prefill/kernels.hpp create mode 100644 include/strata/prefill/prefill.hpp create mode 100644 include/strata/program/logits_selection.hpp create mode 100644 include/strata/spec/controller.hpp create mode 100644 include/strata/spec/suffix_drafter.hpp create mode 100644 ref/load.py create mode 100644 serve/__init__.py create mode 100644 serve/chat_golden.json create mode 100644 serve/chat_template.jinja create mode 100644 serve/frontend.py create mode 100644 serve/index.html create mode 100644 serve/server.py create mode 100644 setup.py create mode 100644 setup.sh create mode 100644 src/artifact/dequant.cpp create mode 100644 src/artifact/gguf_reader.cpp create mode 100644 src/core/concurrent_main.cpp create mode 100644 src/core/device.cu create mode 100644 src/core/device_main.cpp create mode 100644 src/core/expert_cache.cpp create mode 100644 src/core/expert_source.cpp create mode 100644 src/core/graph.cpp create mode 100644 src/core/layer.cpp create mode 100644 src/core/layout.cpp create mode 100644 src/core/load_main.cpp create mode 100644 src/core/mtp.cpp create mode 100644 src/core/native_dense.cpp create mode 100644 src/core/native_head.cpp create mode 100644 src/core/overlap_main.cpp create mode 100644 src/core/pinned.cu create mode 100644 src/core/session.cpp create mode 100644 src/core/verify.cpp create mode 100644 src/core/weights.cpp create mode 100644 src/kernels/bf16_gemv_parity.cpp create mode 100644 src/kernels/cpu/expert.cpp create mode 100644 src/kernels/cpu/expert_layout.cpp create mode 100644 src/kernels/cpu/expert_multi_test.cpp create mode 100644 src/kernels/cpu/expert_parity.cpp create mode 100644 src/kernels/cpu/iq_avx512.cpp create mode 100644 src/kernels/cpu/native_expert.cpp create mode 100644 src/kernels/cpu/pool.cpp create mode 100644 src/kernels/cpu/pool_test.cpp create mode 100644 src/kernels/cpu/q2_avx2.cpp create mode 100644 src/kernels/cuda/bf16_gemv.cu create mode 100644 src/kernels/cuda/dequant_bf16.cu create mode 100644 src/kernels/cuda/dequant_s2.cu create mode 100644 src/kernels/cuda/elementwise.cu create mode 100644 src/kernels/cuda/fused_gdn.cu create mode 100644 src/kernels/cuda/fused_gr.cu create mode 100644 src/kernels/cuda/gdn.cu create mode 100644 src/kernels/cuda/gr.cu create mode 100644 src/kernels/cuda/iq_kernels.cu create mode 100644 src/kernels/cuda/kv_q8.cu create mode 100644 src/kernels/cuda/native_bf16.cu create mode 100644 src/kernels/cuda/native_flash_attn.cu create mode 100644 src/kernels/cuda/native_gdn.cu create mode 100644 src/kernels/cuda/native_gdn_preprocess.cu create mode 100644 src/kernels/cuda/native_gr_norm.cu create mode 100644 src/kernels/cuda/native_gr_postops.cu create mode 100644 src/kernels/cuda/native_mmvq.cu create mode 100644 src/kernels/cuda/native_moe.cu create mode 100644 src/kernels/cuda/native_ple_postops.cu create mode 100644 src/kernels/cuda/native_qsa.cu create mode 100644 src/kernels/cuda/native_qsa_indexer.cu create mode 100644 src/kernels/cuda/native_qsa_score.cu create mode 100644 src/kernels/cuda/native_rope.cu create mode 100644 src/kernels/cuda/native_router.cu create mode 100644 src/kernels/cuda/ple.cu create mode 100644 src/kernels/cuda/qsa.cu create mode 100644 src/kernels/cuda/qsa_decode_attn.cu create mode 100644 src/kernels/cuda/qsa_select.cu create mode 100644 src/kernels/cuda/quantize_act.cu create mode 100644 src/kernels/cuda/rope.cu create mode 100644 src/kernels/cuda/router_top10.cu create mode 100644 src/kernels/cuda/s2_expert_grouped.cu create mode 100644 src/kernels/cuda/s2_gemv.cu create mode 100644 src/kernels/cuda/s2_gemv_fast.cu create mode 100644 src/kernels/cuda/s2_gemv_q8.cu create mode 100644 src/kernels/cuda/s2_gemv_quads.cu create mode 100644 src/kernels/cuda/s_gemv.cu create mode 100644 src/kernels/cuda/sampler.cu create mode 100644 src/kernels/cuda/shared_expert.cu create mode 100644 src/kernels/cuda/verify_kernels.cu create mode 100644 src/kernels/dequant_bf16_test.cpp create mode 100644 src/kernels/dequant_s2_parity.cpp create mode 100644 src/kernels/elementwise_parity.cpp create mode 100644 src/kernels/gdn_parity.cpp create mode 100644 src/kernels/gr_parity.cpp create mode 100644 src/kernels/iq_parity.cpp create mode 100644 src/kernels/kv_q8_parity.cpp create mode 100644 src/kernels/native_expert_parity.cpp create mode 100644 src/kernels/ngram.cpp create mode 100644 src/kernels/ple_oracle_vectors.inc create mode 100644 src/kernels/ple_parity.cpp create mode 100644 src/kernels/qsa_oracle_vectors.inc create mode 100644 src/kernels/qsa_parity.cpp create mode 100644 src/kernels/quantize_act_parity.cpp create mode 100644 src/kernels/rope_parity.cpp create mode 100644 src/kernels/router_top10_parity.cpp create mode 100644 src/kernels/s2_gemv_parity.cpp create mode 100644 src/kernels/s2_gemv_q8_parity.cpp create mode 100644 src/kernels/s_gemv_parity.cpp create mode 100644 src/kernels/s_gemv_q8k_parity.cpp create mode 100644 src/kernels/sampler_parity.cpp create mode 100644 src/kernels/shared_expert_parity.cpp create mode 100644 src/ngram/ple_reader.cpp create mode 100644 src/ngram/ple_reader_test.cpp create mode 100644 src/plan/plan_main.cpp create mode 100644 src/platform/direct_file.cpp create mode 100644 src/platform/memory.cpp create mode 100644 src/platform/memory_test.cpp create mode 100644 src/prefill/gemm.cu create mode 100644 src/prefill/kernels.cu create mode 100644 src/prefill/prefill.cpp create mode 100644 src/program/generate.cpp create mode 100644 src/spec/controller.cpp create mode 100644 src/spec/controller_test.cpp create mode 100644 src/spec/suffix_drafter.cpp create mode 100644 src/spec/suffix_drafter_test.cpp create mode 100644 third_party/ggml/LICENSE create mode 100644 third_party/ggml/VERSION.txt create mode 100644 third_party/ggml/ggml-common.h create mode 100644 tools/_paths.py create mode 100644 tools/canonical_xcheck.py create mode 100644 tools/gguf_reader.py create mode 100644 tools/gguf_writer.py create mode 100644 tools/iq_pack.py create mode 100644 tools/mtp_fetch.py create mode 100644 tools/mtp_pack.py create mode 100644 tools/mtp_rt.py create mode 100644 tools/pack_index.py create mode 100644 tools/pack_layer.py create mode 100644 tools/strata_pack.py create mode 100644 tools/strata_tokenizer.py create mode 100644 tools/vision/CMakeLists.txt create mode 100644 tools/vision/strata_vision.cpp diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dc1b7ba2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Frozen prompts are hashed byte for byte (manifest sha256): never convert their line endings. +bench/prompts/** -text + +# launchers keep their platform line endings wherever the repository is cloned +*.sh text eol=lf +*.bat text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e8501e9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Generated, not source: deterministic build products of tools/ (see docs/). +# tiny-qwen4exp.gguf is ~113 MB, over GitHub's 100 MB file limit -- regenerate with +# python tools/make_tiny_model.py +bench/tiny-*.gguf +bench/wide-*.gguf +bench/micro/*.exe +bench/micro/*.obj +bench/micro/*.bin +bench/micro/*.dll +bench/micro/*.txt +*.log +build*/ +pack/ +logs/ +__pycache__/ +.pytest_cache/ +*.pyc + +# plan v0.3 P1 (repository hygiene, review E1): profiler captures and large diagnostic dumps stay out of git. +*.nsys-rep +*.sqlite +*.npz +*.occ1 +td.log + +# created by setup.py on the user's machine +/.venv/ +/build/ +/third_party/llama.cpp/ +/models/ +/packs/ +/mtp/ +/run-*.bat +/run-*.sh +/strata-*.json +/strata-*.log +/build-strata.bat + +# image encoder (setup.py --vision): build output and the generated build script +build-vision*/ +build-vision.bat + +# setup.py outputs (engine, downloads) and release builds +/engine/ +/build-release*/ +/dist/ +*.done +/third_party/*.zip diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000..0b174608 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,536 @@ +# Strata Engine - top-level build (P1.S1). +# +# Targets (phase-1 §P1.S1): `strata_artifact` (no CUDA dependency), `tests`. `strata_plan` and the C++ +# `strata-pack` are added when their sources exist - a target with no sources would make the build graph +# claim something the tree does not have, which is worse than an absent target. +# +# CUDA targets arrive in Phase 2 and this build REJECTS any architecture other than 120: the engine is +# written against sm_120 (RTX 5000 / Blackwell) and silently building for an older arch would produce a +# binary that runs and is wrong about its own performance, which is the worst of both outcomes. +cmake_minimum_required(VERSION 3.24) +project(strata VERSION 0.1.0 LANGUAGES CXX) + +# The prebuilt release (setup.py downloads it, so users need no compiler): ggml's CPU backend for any AVX2 CPU +# instead of this machine's (Strata's own AVX-512 kernels are per-file and chosen at run time either way), and +# the MSVC runtime linked in, so no Visual C++ redistributable is needed. +option(STRATA_PORTABLE "build for other PCs: AVX2 baseline, static MSVC runtime" OFF) +if(STRATA_PORTABLE AND MSVC) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded") +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +endif() + +option(STRATA_ENABLE_CUDA "Build the CUDA targets (Phase 2+)" OFF) +# The test suite and the micro benchmarks live in tests/ and bench/micro/, which the published source leaves out. +set(_strata_tests_default OFF) +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/bench/micro) + set(_strata_tests_default ON) +endif() +option(STRATA_BUILD_TESTS "Build the test suite" ${_strata_tests_default}) +option(STRATA_WERROR "Treat compiler warnings as errors" OFF) + +if(STRATA_ENABLE_CUDA) + enable_language(CUDA) + # `enable_language(CUDA)` gives the .cu compile rules but NOT the imported targets, so `CUDA::cudart` has to + # be brought in explicitly for the host-side tests that call cudaMalloc themselves. + find_package(CUDAToolkit REQUIRED) + if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES) + set(CMAKE_CUDA_ARCHITECTURES 120 CACHE STRING "CUDA architectures" FORCE) + endif() + foreach(_a IN LISTS CMAKE_CUDA_ARCHITECTURES) + string(REGEX REPLACE "-.*$" "" _base "${_a}") # 120-real / 120-virtual are still 120 + # Plan v0.3 P6: developed and measured on sm_120 (RTX 50); the kernels need sm_80 or newer (tf32 mma in + # the attention scorer, bf16 math), so RTX 30 (86) and RTX 40 (89) build too. Older cards are refused. + if(_base STREQUAL "native" OR _base STREQUAL "all" OR _base STREQUAL "all-major") + continue() + endif() + if(_base LESS 80) + message(FATAL_ERROR + "Strata needs an NVIDIA GPU of compute capability 8.0 or newer (RTX 30 / 40 / 50 series), but " + "CMAKE_CUDA_ARCHITECTURES is '${CMAKE_CUDA_ARCHITECTURES}'.") + endif() + endforeach() + message(STATUS "Strata: CUDA enabled, arch ${CMAKE_CUDA_ARCHITECTURES}") +endif() + +# Warnings live on an INTERFACE target the project's own code links, NOT in add_compile_options: applied +# globally they would also hit FetchContent'd Catch2, whose -W4 warnings are not this project's to fix and +# whose /WX failures would be indistinguishable from our own. +# +# Every flag is guarded by COMPILE_LANGUAGE:CXX. Without that guard nvcc receives bare `/W4 /utf-8` and dies +# with "A single input file is required for a non-link phase" - it parses them as inputs rather than host +# flags. Host flags for CUDA must go through -Xcompiler, which is what COMPILE_LANGUAGE:CUDA does below. +add_library(strata_warnings INTERFACE) +target_compile_options(strata_warnings INTERFACE + $<$,$>:/W4> + $<$,$>:/utf-8> + $<$,$>>:-Wall> + $<$,$>>:-Wextra> + $<$,$>:-Xcompiler=/W4>) +if(STRATA_WERROR) + target_compile_options(strata_warnings INTERFACE + $<$,$>:/WX> + $<$,$>>:-Werror>) +endif() + +# ---- strata_artifact ------------------------------------------------------- +# Header-only, and that is a deliberate property rather than an unfinished split: the GGUF reader and the +# scalar dequantizers hold no state worth hiding, and a header-only boundary cannot introduce a +# duplicate-symbol or missing-declaration bug into code that is already validated against the real artifact. +add_library(strata_artifact INTERFACE) +target_include_directories(strata_artifact INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(strata_artifact INTERFACE strata_warnings) + +add_executable(strata-gguf src/artifact/gguf_reader.cpp) +target_link_libraries(strata-gguf PRIVATE strata_artifact) + +add_executable(strata-dequant src/artifact/dequant.cpp) +target_link_libraries(strata-dequant PRIVATE strata_artifact) +# this file is now nothing but the CLI entry point, and its inherited guard spells that as a "selftest" +target_compile_definitions(strata-dequant PRIVATE STRATA_DEQUANT_SELFTEST) + +# ---- strata_plan ---------------------------------------------------------- +# Also header-only. A "planner" that hides state would be one whose decision is not inspectable, and the +# whole point of P1.S9 is that the decision is arithmetic you can check by hand. +add_library(strata_plan INTERFACE) +target_include_directories(strata_plan INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(strata_plan INTERFACE strata_warnings) + +add_executable(strata-plan src/plan/plan_main.cpp) +target_link_libraries(strata-plan PRIVATE strata_plan) + +# ---- strata_core (CUDA, Phase 2) ------------------------------------------ +# Only built when CUDA is on, so the Phase 1 targets keep working on a machine with no toolkit. The arch +# guard above has already refused anything but sm_120 by the time this runs. +if(STRATA_ENABLE_CUDA) + add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/core/graph.cpp src/core/weights.cpp + src/core/layout.cpp) + target_include_directories(strata_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) + # CUDA::cudart is PUBLIC because graph.cpp is a HOST translation unit that calls cudaGraphLaunch and + # cudaEventQuery: the .cu sources get cudart through the CUDA language rules, a .cpp does not, and the + # linker error ("unresolved external symbol cudaGraphInstantiate") names the function rather than the cause. + target_link_libraries(strata_core PUBLIC strata_plan strata_warnings CUDA::cudart) + + add_executable(strata-device src/core/device_main.cpp) + target_link_libraries(strata-device PRIVATE strata_core) + + add_executable(strata-load src/core/load_main.cpp) + target_link_libraries(strata-load PRIVATE strata_core strata_kernels) + + add_executable(strata-concurrent src/core/concurrent_main.cpp) + target_include_directories(strata-concurrent PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(strata-concurrent PRIVATE strata_core strata_kernels CUDA::cudart) + + add_executable(strata-overlap src/core/overlap_main.cpp) + # a .cpp that calls cudaMemcpyAsync itself needs the toolkit headers and cudart explicitly + target_include_directories(strata-overlap PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(strata-overlap PRIVATE strata_core strata_kernels CUDA::cudart) + + # The load test needs the 34 GB artifact, so it is registered ONLY where that artifact exists. A test that + # silently skips is a test that stops running; an absent registration is visible in the configure output. + if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/pack/full/experts.bin) + add_test(NAME expert_arena_load + COMMAND strata-load --file ${CMAKE_CURRENT_SOURCE_DIR}/pack/full/experts.bin --layers 1) + set_tests_properties(expert_arena_load PROPERTIES PASS_REGULAR_EXPRESSION "per-layer checksums") + else() + message(STATUS "strata: pack/full/experts.bin absent - the expert arena load test is NOT registered") + endif() + + # ---- strata_kernels (P2.S2) ---- + # Naive first, each with a parity test against a reference that was itself validated against ggml. The + # kernels link `strata_artifact` for the scalar dequantizers, because using the VALIDATED scalar path as the + # reference is the whole point - a kernel checked against another copy of itself proves nothing. + add_library(strata_kernels STATIC src/kernels/cuda/dequant_s2.cu src/kernels/cuda/s2_gemv.cu + src/kernels/cuda/s_gemv.cu + src/kernels/cuda/s2_gemv_quads.cu + src/kernels/cuda/s2_gemv_fast.cu + src/kernels/cuda/router_top10.cu + src/kernels/cuda/quantize_act.cu + src/kernels/cuda/rope.cu + src/kernels/cuda/sampler.cu + src/kernels/cuda/s2_gemv_q8.cu + src/kernels/cuda/s2_expert_grouped.cu + src/kernels/cuda/shared_expert.cu + src/kernels/cuda/gr.cu + src/kernels/cuda/gdn.cu + src/kernels/cuda/qsa.cu + src/kernels/cuda/elementwise.cu + src/kernels/cuda/bf16_gemv.cu + src/kernels/cuda/native_mmvq.cu + src/kernels/cuda/native_bf16.cu + src/kernels/cuda/native_gr_norm.cu + src/kernels/cuda/native_moe.cu + src/kernels/cuda/native_gdn.cu + src/kernels/cuda/native_gr_postops.cu + src/kernels/cuda/native_router.cu + src/kernels/cuda/native_gdn_preprocess.cu + src/kernels/cuda/native_qsa.cu + src/kernels/cuda/native_rope.cu + src/kernels/cuda/native_ple_postops.cu + src/kernels/cuda/native_flash_attn.cu + src/kernels/cuda/native_qsa_indexer.cu + src/kernels/cuda/native_qsa_score.cu + src/kernels/cuda/ple.cu + src/kernels/cuda/kv_q8.cu + src/kernels/cuda/fused_gr.cu + src/kernels/cuda/qsa_decode_attn.cu + src/kernels/cuda/fused_gdn.cu + src/kernels/cuda/dequant_bf16.cu + src/kernels/cuda/qsa_select.cu + src/kernels/cuda/verify_kernels.cu + src/kernels/cuda/iq_kernels.cu + src/kernels/ngram.cpp + src/ngram/ple_reader.cpp + src/platform/direct_file.cpp) + target_link_libraries(strata_kernels PUBLIC strata_core strata_artifact) + # plan v0.3 P6: llama.cpp's block layouts and codebook grids for the i-quant formats (MIT, third_party/ggml) + target_include_directories(strata_kernels PRIVATE third_party/ggml) + # Match the pinned llama.cpp CUDA arithmetic contract for this adapter only. + set_source_files_properties(src/kernels/cuda/native_mmvq.cu src/kernels/cuda/native_bf16.cu + src/kernels/cuda/native_gr_norm.cu + src/kernels/cuda/native_moe.cu + src/kernels/cuda/native_gdn.cu + src/kernels/cuda/native_gr_postops.cu + src/kernels/cuda/native_router.cu + src/kernels/cuda/native_gdn_preprocess.cu + src/kernels/cuda/native_qsa.cu + src/kernels/cuda/native_rope.cu + src/kernels/cuda/native_ple_postops.cu + src/kernels/cuda/native_flash_attn.cu + src/kernels/cuda/native_qsa_indexer.cu + src/kernels/cuda/native_qsa_score.cu + PROPERTIES COMPILE_OPTIONS "--use_fast_math") + + set(STRATA_ORACLE_SOURCE_DIR "" CACHE PATH "Pinned llama.cpp source for independent CUDA parity tests") + set(STRATA_ORACLE_BUILD_DIR "" CACHE PATH "Matching existing shared llama.cpp build") + if(STRATA_ORACLE_SOURCE_DIR AND STRATA_ORACLE_BUILD_DIR) + foreach(parity IN ITEMS native_mmvq native_bf16 native_shared native_gr_norm native_moe native_gdn native_gr_postops native_router native_gdn_preprocess native_gdn_pipeline native_qsa native_rope native_ple_postops native_flash_attn native_qsa_indexer native_qsa_score) + add_executable(${parity}_parity bench/micro/${parity}_parity.cpp) + target_include_directories(${parity}_parity PRIVATE + "${STRATA_ORACLE_SOURCE_DIR}/ggml/include" ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_compile_definitions(${parity}_parity PRIVATE GGML_SHARED GGML_BACKEND_SHARED) + target_link_libraries(${parity}_parity PRIVATE strata_kernels CUDA::cudart) + foreach(component IN ITEMS ggml ggml-base ggml-cpu ggml-cuda) + string(REPLACE "-" "_" variable "STRATA_ORACLE_${component}") + find_library(${variable} NAMES ${component} PATHS + "${STRATA_ORACLE_BUILD_DIR}/ggml/src" "${STRATA_ORACLE_BUILD_DIR}/ggml/src/${component}" + "${STRATA_ORACLE_BUILD_DIR}/bin" NO_DEFAULT_PATH REQUIRED) + target_link_libraries(${parity}_parity PRIVATE "${${variable}}") + endforeach() + add_test(NAME ${parity}_parity COMMAND ${parity}_parity --selftest) + if(WIN32) + set_tests_properties(${parity}_parity PROPERTIES + ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:${STRATA_ORACLE_BUILD_DIR}/bin") + endif() + endforeach() + endif() + + # ---- strata_engine (P2.S5) ----------------------------------------------------- + # The COMPOSED layer lives here and not in `strata_core`, because it calls kernels and `strata_kernels` + # already links `strata_core` - putting `gdn_layer` in core would make the two mutually dependent. A third + # library is the honest shape of the dependency graph: core knows about devices and weights, kernels know + # about arithmetic, and this one knows the ORDER the arithmetic happens in. + add_library(strata_engine STATIC src/core/layer.cpp src/core/session.cpp src/core/expert_source.cpp + src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp) + target_include_directories(strata_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) + target_link_libraries(strata_engine PUBLIC strata_core strata_kernels strata_kernels_cpu CUDA::cudart) + + # `strata generate` (P2.S6). The FIRST user-facing program: everything under it is a component, and this is + # what composes them into a token. No tokenizer - the prompt arrives as ids - because Gate C1 compares logits + # against llama.cpp on the SAME ids and a tokenizer on one side of that comparison is a second variable. + # plan v0.3 P5: batched prompt processing (tensor-core GEMMs through cuBLAS). + add_library(strata_prefill STATIC src/prefill/gemm.cu src/prefill/kernels.cu src/prefill/prefill.cpp) + target_include_directories(strata_prefill PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(strata_prefill PUBLIC strata_engine CUDA::cublas CUDA::cudart) + + add_executable(strata src/program/generate.cpp) + target_include_directories(strata PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(strata PRIVATE strata_engine strata_prefill CUDA::cudart) + + add_executable(dequant_s2_parity src/kernels/dequant_s2_parity.cpp) + # The tests are HOST C++ files that call cudaMalloc/cudaMemcpy themselves, so they need the toolkit headers + # and cudart explicitly: enabling the CUDA language gives those to .cu sources automatically, but a .cpp + # target is just a C++ target and gets neither. + target_include_directories(dequant_s2_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(dequant_s2_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME dequant_s2_parity COMMAND dequant_s2_parity --selftest) + + add_executable(s2_gemv_parity src/kernels/s2_gemv_parity.cpp) + target_include_directories(s2_gemv_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(s2_gemv_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME s2_gemv_parity COMMAND s2_gemv_parity --selftest) + + add_executable(iq_parity src/kernels/iq_parity.cpp) + target_include_directories(iq_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(iq_parity PRIVATE strata_kernels CUDA::cudart) + add_executable(shared_expert_parity src/kernels/shared_expert_parity.cpp) + target_include_directories(shared_expert_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(shared_expert_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME shared_expert_parity COMMAND shared_expert_parity --selftest) + + add_executable(gr_parity src/kernels/gr_parity.cpp) + target_include_directories(gr_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(gr_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME gr_parity COMMAND gr_parity --selftest) + + add_executable(gdn_parity src/kernels/gdn_parity.cpp) + target_include_directories(gdn_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(gdn_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME gdn_parity COMMAND gdn_parity --selftest) + + add_executable(s2_gemv_q8_parity src/kernels/s2_gemv_q8_parity.cpp) + target_include_directories(s2_gemv_q8_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(s2_gemv_q8_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME s2_gemv_q8_parity COMMAND s2_gemv_q8_parity --selftest) + + add_executable(sampler_parity src/kernels/sampler_parity.cpp) + target_include_directories(sampler_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(sampler_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME sampler_parity COMMAND sampler_parity --selftest) + + add_executable(rope_parity src/kernels/rope_parity.cpp) + target_include_directories(rope_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(rope_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME rope_parity COMMAND rope_parity --selftest) + + add_executable(quantize_act_parity src/kernels/quantize_act_parity.cpp) + target_include_directories(quantize_act_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(quantize_act_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME quantize_act_parity COMMAND quantize_act_parity --selftest) + + add_executable(router_top10_parity src/kernels/router_top10_parity.cpp) + target_include_directories(router_top10_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(router_top10_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME router_top10_parity COMMAND router_top10_parity --selftest) + + add_executable(s_gemv_parity src/kernels/s_gemv_parity.cpp) + target_include_directories(s_gemv_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(s_gemv_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME s_gemv_parity COMMAND s_gemv_parity --selftest) + + add_executable(elementwise_parity src/kernels/elementwise_parity.cpp) + target_include_directories(elementwise_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(elementwise_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME elementwise_parity COMMAND elementwise_parity --selftest) + + add_executable(bf16_gemv_parity src/kernels/bf16_gemv_parity.cpp) + target_include_directories(bf16_gemv_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(bf16_gemv_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME bf16_gemv_parity COMMAND bf16_gemv_parity --selftest) + + add_executable(s_gemv_q8k_parity src/kernels/s_gemv_q8k_parity.cpp) + target_include_directories(s_gemv_q8k_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(s_gemv_q8k_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME s_gemv_q8k_parity COMMAND s_gemv_q8k_parity --selftest) + + add_executable(qsa_parity src/kernels/qsa_parity.cpp) + target_include_directories(qsa_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(qsa_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME qsa_parity COMMAND qsa_parity --selftest) + + # ---- P2.S4: the PLE n-gram hash, the IQ4_NL table read, and the PLE block ---- + # `ngram.cpp` is host-only but lives in `strata_kernels` because `ple.cu` shares its geometry constants, and + # splitting them across two libraries would only add a layer. + # + # The pack, the original GGUF and the ggml capture are all REQUIRED inputs: this test's oracles are real + # artifacts, so the working directory is pinned to the source tree and a missing input is a hard error + # INSIDE the test. A test that passes by finding nothing to compare is the failure mode this project keeps + # re-learning. + add_executable(ple_parity src/kernels/ple_parity.cpp) + target_include_directories(ple_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(ple_parity PRIVATE strata_kernels CUDA::cudart) + set(STRATA_PLE_FIXTURE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/bench/micro" CACHE PATH + "Directory containing required ple_in.bin and ple_out.bin parity fixtures") + add_test(NAME ple_parity COMMAND ple_parity --selftest + --in "${STRATA_PLE_FIXTURE_DIR}/ple_in.bin" --out "${STRATA_PLE_FIXTURE_DIR}/ple_out.bin" + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) + + # Plan v0.3 P7: INT8 KV storage vs a host reference and vs the FP16 path (GPU, synthetic, no model). + add_executable(kv_q8_parity src/kernels/kv_q8_parity.cpp) + target_include_directories(kv_q8_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(kv_q8_parity PRIVATE strata_kernels CUDA::cudart) + add_test(NAME kv_q8_parity COMMAND kv_q8_parity) + + # Plan v0.3 P3: native MMVQ for 1..8 tokens (bitwise selftest + --bench timing); synthetic data, no model. + if(STRATA_BUILD_TESTS) + add_executable(native_mmvq_multi bench/micro/native_mmvq_multi.cpp) + target_include_directories(native_mmvq_multi PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(native_mmvq_multi PRIVATE strata_kernels CUDA::cudart) + add_test(NAME native_mmvq_multi COMMAND native_mmvq_multi --selftest) + endif() + + # Plan v0.3 P0.1: working-set lock of a host region (CPU only). + add_executable(platform_memory_test src/platform/memory_test.cpp) + target_link_libraries(platform_memory_test PRIVATE strata_core CUDA::cudart) + add_test(NAME platform_memory_test COMMAND platform_memory_test) + + # Plan v0.3 P2: the n-gram table read straight from the SSD. The selftest needs only the CPU and a temp file. + add_executable(ple_reader_test src/ngram/ple_reader_test.cpp) + target_include_directories(ple_reader_test PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(ple_reader_test PRIVATE strata_kernels CUDA::cudart) + add_test(NAME ple_reader_selftest COMMAND ple_reader_test --selftest) + + # The GPU test is registered only when CUDA is on, and it runs ON THE TARGET PC. `--selftest` allocates + # and writes real VRAM, so a machine without a device fails this test rather than skipping it silently - + # which is correct: an engine that targets one GPU should not report a pass for having no GPU. + add_test(NAME cuda_device_selftest COMMAND strata-device --selftest) + + # ---- P2.S5: the GraphRegistry -------------------------------------------------- + # A .cu because the test needs real kernels to capture; it links `strata_core` and not `strata_kernels`, + # because what it exercises is the capture machinery and not any kernel in it. + if(STRATA_BUILD_TESTS) + add_executable(graph_registry_test bench/micro/graph_registry_test.cu) + target_include_directories(graph_registry_test PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(graph_registry_test PRIVATE strata_core CUDA::cudart) + add_test(NAME graph_registry_test COMMAND graph_registry_test) + + # ---- P2.S5's GATE: is a device<->pinned-memory handoff graph-capturable? -------------------- + # The doorbell protocol puts the D2H of `x_f` and the miss list INSIDE G[l] - that is the whole overlap. The + # phase doc says to verify this "before building on it", and this project has already lost two rounds to + # building first (a memory-only spin that DEADLOCKS on Windows, and a `cudaEventRecord` inside a capture that + # is SILENTLY DROPPED). It links only cudart: it is about the capture machinery, not about any kernel. + add_executable(pinned_capture bench/micro/pinned_capture.cu) + target_include_directories(pinned_capture PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(pinned_capture PRIVATE CUDA::cudart) + add_test(NAME pinned_capture COMMAND pinned_capture) + endif() + + # Tells tests/CMakeLists.txt to add the CUDA-dependent Catch2 target. It cannot be declared there + # unconditionally: tests/ is added at the end of this file, but the flag has to be known before it runs. + set(STRATA_CUDA_TESTS ON) +endif() + +# ---- strata_kernels_cpu (P2.S3) ---- +# +# A SEPARATE LIBRARY from `strata_kernels`, and `/arch:AVX512` is set on the SOURCE rather than on the target +# so that the flag applies only to the TU that needs it. Both parts matter: +# +# * the CPU expert kernel uses `_mm256_dpbusd_epi32` (AVX512-VNNI) and `_mm256_multishift_epi64_epi8` +# (AVX512-VBMI); without the flag MSVC reports undeclared intrinsics and never mentions AVX-512; +# * a TU compiled WITH `/arch:AVX512` may use AVX-512 in ANY of its code, so the scalar fallback and the +# test drivers must NOT be in it - otherwise the fallback is not a fallback, it is the same bet with +# worse opt-out. `cpu_require_expert_support()` therefore refuses at startup on a CPU without the +# features, and the fallback exists for tests, exactly as P2.S3 specifies. +# Plan v0.3 P6: speculation components that need no GPU. +add_library(strata_spec STATIC src/spec/suffix_drafter.cpp src/spec/controller.cpp) +target_include_directories(strata_spec PUBLIC include) +if(STRATA_BUILD_TESTS) + add_executable(suffix_drafter_test src/spec/suffix_drafter_test.cpp) + target_link_libraries(suffix_drafter_test PRIVATE strata_spec) + add_test(NAME suffix_drafter_test COMMAND suffix_drafter_test) + add_executable(controller_test src/spec/controller_test.cpp) + target_link_libraries(controller_test PRIVATE strata_spec) + add_test(NAME controller_test COMMAND controller_test) +endif() + +add_library(strata_kernels_cpu STATIC src/kernels/cpu/expert.cpp src/kernels/cpu/pool.cpp + src/kernels/cpu/expert_layout.cpp src/kernels/cpu/q2_avx2.cpp) +target_include_directories(strata_kernels_cpu PUBLIC include) +# Plan v0.3 P6: only the kernel files get the AVX-512 / AVX2 flags; the pool and the probe run on any x86-64, +# and the engine picks the kernels at run time (cpu_avx512_ok). +if(MSVC) + set_source_files_properties(src/kernels/cpu/expert.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX512") + set_source_files_properties(src/kernels/cpu/q2_avx2.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX2") +else() + # GCC/Clang spell it differently, and `-mavx512vnni` alone is not enough: VBMI is a separate feature bit. + set_source_files_properties(src/kernels/cpu/expert.cpp + PROPERTIES COMPILE_OPTIONS "-mavx512f;-mavx512bw;-mavx512vl;-mavx512dq;-mavx512vnni;-mavx512vbmi;-mf16c;-mfma") + set_source_files_properties(src/kernels/cpu/q2_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c") +endif() +find_package(Threads REQUIRED) +target_link_libraries(strata_kernels_cpu PUBLIC Threads::Threads) + +# ---- plan v0.3 P6: the i-quant models' CPU experts (IQ2_XS / IQ3_XXS files) ---- +# +# The CPU computes a native expert with ggml-cpu's own dot products (`ggml_get_type_traits_cpu`), so an IQ +# weight means exactly what it means in llama.cpp. ggml is built static from a llama.cpp checkout: a local one +# with -DSTRATA_GGML_DIR=..., otherwise the pinned upstream commit (third_party/ggml/VERSION.txt). +option(STRATA_NATIVE_EXPERTS "Native (IQ) expert support through ggml-cpu" ON) +set(STRATA_GGML_DIR "" CACHE PATH "llama.cpp checkout to take ggml from (empty: fetch the pinned commit)") +if(STRATA_NATIVE_EXPERTS) + enable_language(C) + set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + set(GGML_STATIC ON CACHE BOOL "" FORCE) + set(GGML_CUDA OFF CACHE BOOL "" FORCE) + set(GGML_OPENMP OFF CACHE BOOL "" FORCE) + set(GGML_BACKEND_DL OFF CACHE BOOL "" FORCE) + set(GGML_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(GGML_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + if(STRATA_PORTABLE) + set(GGML_NATIVE OFF CACHE BOOL "" FORCE) + foreach(_f GGML_AVX GGML_AVX2 GGML_FMA GGML_F16C GGML_BMI2) + set(${_f} ON CACHE BOOL "" FORCE) + endforeach() + else() + set(GGML_NATIVE ON CACHE BOOL "" FORCE) + endif() + if(STRATA_GGML_DIR) + add_subdirectory(${STRATA_GGML_DIR}/ggml ${CMAKE_BINARY_DIR}/ggml EXCLUDE_FROM_ALL) + else() + include(FetchContent) + FetchContent_Declare(strata_llamacpp + GIT_REPOSITORY https://github.com/ggml-org/llama.cpp.git + GIT_TAG 3cf03257f219afbe7334045ff7c6a06ac68c627d + GIT_SHALLOW FALSE) + FetchContent_GetProperties(strata_llamacpp) + if(NOT strata_llamacpp_POPULATED) + FetchContent_Populate(strata_llamacpp) + endif() + add_subdirectory(${strata_llamacpp_SOURCE_DIR}/ggml ${CMAKE_BINARY_DIR}/ggml EXCLUDE_FROM_ALL) + endif() + # part of strata_kernels_cpu (the pool calls these and they call its CPU probe: one library, so GNU ld's + # one-pass static linking has no cycle to resolve) + target_sources(strata_kernels_cpu PRIVATE src/kernels/cpu/native_expert.cpp src/kernels/cpu/iq_avx512.cpp) + target_include_directories(strata_kernels_cpu PRIVATE third_party/ggml) + if(MSVC) + set_source_files_properties(src/kernels/cpu/iq_avx512.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX512") + else() + set_source_files_properties(src/kernels/cpu/iq_avx512.cpp PROPERTIES COMPILE_OPTIONS + "-mavx512f;-mavx512bw;-mavx512vl;-mavx512dq;-mavx512vnni;-mavx512vbmi;-mf16c;-mfma") + endif() + target_link_libraries(strata_kernels_cpu PUBLIC ggml-cpu ggml-base) + target_compile_definitions(strata_kernels_cpu PUBLIC STRATA_NATIVE_EXPERTS=1) + if(STRATA_ENABLE_CUDA) + add_executable(native_expert_parity src/kernels/native_expert_parity.cpp) + target_include_directories(native_expert_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES}) + target_link_libraries(native_expert_parity PRIVATE strata_kernels strata_kernels_cpu strata_artifact CUDA::cudart) + endif() +endif() + +if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS) + add_executable(hit_cpu_order_parity bench/micro/hit_cpu_order_parity.cu) + target_link_libraries(hit_cpu_order_parity PRIVATE strata_kernels strata_kernels_cpu CUDA::cudart) +endif() + +if(STRATA_BUILD_TESTS) + # Plan v0.3 P6: one expert applied to several tokens, bitwise vs the single-token kernel (synthetic, CPU only). + # plan v0.3 P5: the device dequantizers vs the artifact's CPU dequantizers on the real shards (not a ctest: needs + # the 38 GB model; run it by hand or from tools/gpu_session.py) + add_executable(dequant_bf16_test src/kernels/dequant_bf16_test.cpp) + target_link_libraries(dequant_bf16_test PRIVATE strata_kernels strata_artifact CUDA::cudart) + + add_executable(expert_multi_test src/kernels/cpu/expert_multi_test.cpp) + target_link_libraries(expert_multi_test PRIVATE strata_kernels_cpu) + add_test(NAME expert_multi_test COMMAND expert_multi_test) + + add_executable(expert_parity src/kernels/cpu/expert_parity.cpp) + target_link_libraries(expert_parity PRIVATE strata_kernels_cpu) + # The pack is a REQUIRED input, not an optional one: this test compares against a real expert blob, and a + # missing file would otherwise be reported as a pass by a test that skipped everything. + add_test(NAME expert_parity COMMAND expert_parity --selftest + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) + + add_executable(pool_test src/kernels/cpu/pool_test.cpp) + target_link_libraries(pool_test PRIVATE strata_kernels_cpu) + add_test(NAME pool_test COMMAND pool_test --selftest WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) +endif() + +if(STRATA_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/README.md b/README.md new file mode 100644 index 00000000..03db4da8 --- /dev/null +++ b/README.md @@ -0,0 +1,313 @@ +

Qwen3.8-Flash-Next (125B MoE) on a 12-24 GB NVIDIA GPU + 64 GB RAM: one-click install for Windows / Linux

+ +

Strata: an inference engine built for exactly one model and exactly this kind of PC

+ +A 125-billion-parameter mixture-of-experts model (6B active per token) on a **normal gaming PC**: one NVIDIA card with +12-24 GB of VRAM, 64 GB of RAM and a desktop CPU. Strata keeps the always-used part of the model on the GPU, all 24,576 +experts in RAM, computes the experts the GPU does not hold on the CPU at the same time, and checks several tokens per +pass with the model's own multi-token-prediction (MTP) layer. The text it produces is identical to plain greedy decoding. + +It serves an **OpenAI-** and **Anthropic-compatible API on localhost**, so any chat app, coding agent or script that +talks to those APIs can use it. Optionally it also **reads images** (screenshots, photos, scanned pages) with the +model's own vision encoder. + +> **Jump to:** [Speed](#speed-measured) · [Other GPUs](#other-gpus-estimated) · [Which model?](#which-model) · +> [Windows](#windows) · [Linux](#linux) · [Using it](#using-it) · [Images](#images-vision) · +> [Troubleshooting](#troubleshooting) · [How it works](#how-it-works) + +--- + +## Speed (measured) + +RTX 5070 **12 GB**, Ryzen 5 7600 (6 cores), 64 GB DDR5-5200, Windows. One code-agent prompt per length, 256 generated +tokens, MTP speculative decoding on. "262K" is the model's full context window (a 259,943-token prompt). + +### Prompt processing (tokens/s) + +| Model | 1K | 4K | 32K | 64K | 128K | 262K | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| **Q2_0** | 389 | 539 | 571 | 561 | 543 | 496 | +| **IQ2_XS** | 332 | 463 | 495 | 486 | 472 | 437 | +| **IQ3_XXS** | 285 | 410 | 435 | 427 | 414 | - | + +### Output (tokens/s) + +| Model | 1K | 4K | 32K | 64K | 128K | 262K | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| **Q2_0** | 88.7 | 94.6 | 87.5 | 76.4 | 65.1 | 56.3 | +| **IQ2_XS** | 82.0 | 78.0 | 65.3 | 63.7 | 52.0 | 48.0 | +| **IQ3_XXS** | 64.6 | 65.6 | 57.3 | 54.4 | 44.8 | - | + +IQ3_XXS at 262K is not measured: with its 43 GB of experts, a 260K-token context brings a 64 GB PC to its memory +limit. Use up to 128K with IQ3_XXS on 64 GB. + +Time to first token is prompt length / prompt speed: about 7 s at 4K, 55 s at 32K, 4 minutes at 128K and 9 minutes at +262K. The raw numbers: `bench/results/`. The [paper](docs/paper/Strata-Paper.pdf) explains every number. + +## Other GPUs (estimated) + +Not measured - estimated from the runs above (same CPU and 64 GB RAM): the GPU part scaled by memory bandwidth, the CPU +part by how many more experts the card's VRAM holds. Treat as **±20%**. Numbers are *prompt / output* tokens/s. + +| GPU | Model | 1K | 4K | 32K | 64K | 128K | 262K | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| RTX 5060 Ti 16GB | Q2_0 | ~341 / ~80 | ~472 / ~87 | ~501 / ~81 | ~492 / ~72 | ~476 / ~62 | ~435 / ~53 | +| | IQ2_XS | ~291 / ~80 | ~406 / ~77 | ~434 / ~63 | ~426 / ~62 | ~413 / ~51 | ~383 / ~47 | +| | IQ3_XXS | ~249 / ~66 | ~359 / ~65 | ~381 / ~56 | ~374 / ~54 | ~363 / ~45 | - | +| RTX 3090 24GB | Q2_0 | ~355 / ~128 | ~491 / ~140 | ~521 / ~130 | ~512 / ~115 | ~495 / ~100 | ~453 / ~85 | +| | IQ2_XS | ~303 / ~131 | ~422 / ~128 | ~451 / ~103 | ~444 / ~102 | ~430 / ~85 | ~398 / ~78 | +| | IQ3_XXS | ~260 / ~106 | ~374 / ~103 | ~396 / ~89 | ~390 / ~85 | ~378 / ~71 | - | + +More VRAM matters more than a faster GPU: every extra GB holds ~700 more experts, and every expert on the GPU is one the +CPU does not have to compute. A 3090's 24 GB takes most of the CPU work away. + +## Which model? + +All three are [ISTA-DASLab's GSQ-RCO quantizations](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF) +of [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next). + +| Model | Download | RAM it uses | Speed | Quality | +| --- | ---: | ---: | --- | --- | +| **Q2_0** | 66 GB | ~34 GB experts + ~6 GB | fastest | good | +| **IQ2_XS** | 68 GB | ~36 GB experts + ~6 GB | close to Q2_0 | a bit better | +| **IQ3_XXS** | 76 GB | ~43 GB experts + ~6 GB | slower (more CPU work) | best | + +With 64 GB of RAM all three fit (close the browser for IQ3_XXS, and keep its context at 128K or less). With 48 GB only Q2_0 / IQ2_XS may fit. 32 GB is not enough. + +## Before you start + +You need **only an NVIDIA driver** (version 580 or newer; update it with the NVIDIA App or from +[nvidia.com/drivers](https://www.nvidia.com/drivers)). Everything else is installed for you the first time. + +| | | +| --- | --- | +| GPU | NVIDIA **RTX 30, 40 or 50 series**, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070; RTX 30/40 are untested. | +| RAM | **64 GB** recommended (see the table above). | +| CPU | x86-64 with AVX2 (any Intel/AMD desktop CPU from the last ~8 years). AVX-512 (Ryzen 7000/9000) is a bit faster. | +| Disk | ~70-80 GB free for the model, ~6 GB for the MTP layer (+1 GB with images). **Q2_0 on an AVX-512 CPU** also writes a one-time ~40 GB copy of its experts for the fast CPU kernel. An NVMe SSD is strongly recommended. | +| OS | Windows 10/11, or Linux (Ubuntu 22.04/24.04 get everything installed automatically). | + +What the first start installs, all inside this folder (`.venv/`, `engine/`, `third_party/`, `models/`, `packs/`, `mtp/`): +Python 3.12 if you have none (for your user account, no admin), a private Python environment, NVIDIA's CUDA libraries +(from pip, ~0.4 GB), the ready-made Strata engine for RTX 30/40/50, the model and the MTP draft layer. If no +ready-made engine fits your PC, it offers to install the build tools (Visual Studio Build Tools + CUDA Toolkit on +Windows, `build-essential` + CUDA on Ubuntu) and compiles the engine for your GPU (asks first; 20-40 minutes once). + +--- + +## Windows + +### Double-click `START-HERE.bat` + +**The first time** it asks three questions and does the rest: + +1. **Which model?** Q2_0, IQ2_XS or IQ3_XXS (it recommends one for your RAM). +2. **How much context?** 8K to 256K tokens (it recommends one for your VRAM). +3. **Images?** yes / no (see [Images](#images-vision)). + +Then it downloads and prepares everything (the model is 66-76 GB, so the first start takes a while; an interrupted +download continues where it stopped) and **starts the model**: your browser opens `http://127.0.0.1:8080`, a small +page that shows it is running and lets you chat. The API is at `http://127.0.0.1:8080/v1` for your apps. + +**Every time after that**, `START-HERE.bat` just starts the model (30-90 s to load 34-43 GB into RAM). Nothing is +downloaded again. Closing the window stops the model. + +``` +START-HERE.bat --setup install another model, or change context / images +START-HERE.bat --model IQ2_XS --context 32768 --vision yes --yes no questions +START-HERE.bat --gguf-dir D:\models\IQ2_XS use GGUF files you already have +START-HERE.bat --port 8081 another port +``` + +With more than one model installed, it asks which one to start. `run-.bat` starts a model directly. + +### Chat in the terminal (optional) + +``` +.venv\Scripts\python chat.py +``` + +--- + +## Linux + +```bash +./setup.sh +``` + +The same three questions, the same automatic install (it uses `sudo apt` for Python and, only if it has to compile, +for the build tools), and the same start: `http://127.0.0.1:8080`. Later runs of `./setup.sh` (or `./run-.sh`) +start the model directly. Options as on Windows (`./setup.sh --setup`, `--model Q2_0 --yes`, `--gguf-dir /data/Q2_0`). +Terminal chat: `.venv/bin/python chat.py`. + +--- + +## Using it + +The server listens on `http://127.0.0.1:8080` (change with `--port` in setup, or edit the run script). + +| API | Endpoint | +| --- | --- | +| OpenAI Chat Completions (stream and non-stream, tools) | `POST /v1/chat/completions` | +| Anthropic Messages (stream and non-stream, tools) | `POST /v1/messages` | +| Model list / health | `GET /v1/models`, `GET /health` | + +```bash +curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{ + "model": "strata", "messages": [{"role": "user", "content": "Write a haiku about GPUs."}], "max_tokens": 512 }' +``` + +```python +from openai import OpenAI +client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="none") +r = client.chat.completions.create(model="strata", messages=[{"role": "user", "content": "Hello!"}]) +print(r.choices[0].message.content) +``` + +- **Thinking levels: none, low, medium, high.** The model thinks before it answers (streamed as + `reasoning_content`, Anthropic: `thinking` blocks). Choose how much per request - in the chat page (the "Thinking" + menu), in `chat.py` (`/think low`), or over the API: + + | API | how | + | --- | --- | + | OpenAI | `"reasoning_effort": "none" \| "low" \| "medium" \| "high"` (also `"reasoning": {"effort": ...}`, or `"chat_template_kwargs": {"enable_thinking": false}`) | + | Anthropic | `"output_config": {"effort": "low" \| "medium" \| "high"}`, `"thinking": {"type": "disabled"}`, or `"thinking": {"type": "enabled", "budget_tokens": N}` (under 2K = low, under 8K = medium, more = high) | + + Without a setting the model uses its own default, **high**. `none` answers at once (fastest); `low` keeps the thinking + short. The levels are instructions the model was trained with, not a hard token limit: on easy questions all three + think briefly, on hard ones `high` thinks longest and is most accurate. +- **Chat apps.** Any app with an "OpenAI-compatible" provider works: base URL `http://127.0.0.1:8080/v1`, any API key. +- **Context.** Chosen in setup (8K-262K). Requests longer than that are refused, never silently cut. +- **From other devices / the internet.** The server listens on your PC only (`127.0.0.1`). To reach it from elsewhere, + put a tunnel in front of it, for example [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/): + `cloudflared tunnel --url http://127.0.0.1:8080`. **Set a key first**, or anyone with the link can use your PC: + add `"api_key": "some-long-secret"` to `strata-.json` (or set the `STRATA_API_KEY` environment variable); + clients then send it as their API key. + +**Current limits (v1):** one request at a time; greedy decoding (temperature is ignored); every request processes its +whole prompt again (no conversation cache yet, so long chats have a long time-to-first-token); images only when set up +with them (below); no video. + +--- + +## Images (vision) + +The model has a vision encoder: [`mmproj-Qwen3.8-Flash-Next-BF16.gguf`](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF) +(0.9 GB, a 27-layer ViT plus the projector into the language model). It is **optional**: say yes when the setup asks +"Images?", or run it again with `--vision gpu` (or `--vision cpu`). The setup downloads the encoder, builds a small +helper (`strata-vision`, from llama.cpp's `mtmd` library) and adds it to your start script. Nothing else changes. + +| Encoder on | Time per picture | Cost | +| --- | --- | --- | +| **GPU** (recommended) | **0.1-0.5 s** (up to 1,024 image tokens) | ~1.4 GB of VRAM is kept free for it, so the expert cache is smaller: text output is a few % slower (table below) | +| CPU | 10-30 s (pictures are scaled down to ~300 image tokens) | nothing on the GPU | + +A picture becomes up to 1,024 tokens of the context (a 640x480 photo: 300). The same picture sent again, as chat apps +do on every turn, is encoded only once. + +### Sending a picture + +**Terminal chat:** type `/image `, press Enter, then type your question. + +``` +you> /image C:\Users\me\Pictures\receipt.jpg +(picture attached: receipt.jpg - now type your question) +you> What is the total on this receipt? +``` + +**OpenAI API** (an `image_url` part: a `data:` URL, an `http(s)://` URL or a local file path): + +```python +import base64 +from openai import OpenAI +client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="none") +img = base64.b64encode(open("photo.jpg", "rb").read()).decode() +r = client.chat.completions.create(model="strata", messages=[{"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}}, + {"type": "text", "text": "What is in this picture?"}]}]) +print(r.choices[0].message.content) +``` + +**Anthropic API:** an `image` block with a `base64` (or `url`) source, as usual. + +JPEG, PNG, BMP and GIF work (WebP does not). Chat apps with image upload work the same way. + +### Speed with images on (4K context, measured) + +| Model | Prompt tok/s, images off | Prompt tok/s, images on | Output tok/s, images off | Output tok/s, images on | +| --- | ---: | ---: | ---: | ---: | +| **Q2_0** | 541 | 531 | 90.7 | 86.9 | +| **IQ2_XS** | 468 | 458 | 77.1 | 74.0 | +| **IQ3_XXS** | 411 | 401 | 64.7 | 59.5 | + +"Off" is the published setup; "on" runs with the encoder loaded on the GPU and its VRAM kept free, so ~1,000 fewer +experts fit in VRAM and the CPU computes a few more per token: 2-8% slower. For text, turning images on changes +nothing else (the output is bit-identical when the VRAM is the same). + +A question about a picture (a 640x480 newspaper page = 300 image tokens, a 328-token prompt; the whole request, encoder +on the GPU, measured through the API): + +| Model | Answer ("MEN WALK ON MOON") | Picture encoded | Prompt | Output | +| --- | ---: | ---: | ---: | ---: | +| **Q2_0** | 1.9 s | ~0.1 s | 207 tok/s | 65-73 tok/s | +| **IQ2_XS** | 2.3 s | ~0.1 s | 173 tok/s | 50-60 tok/s | +| **IQ3_XXS** | 2.7 s | ~0.1 s | 144 tok/s | 41-50 tok/s | + +(Short prompts run below the 4K prompt speed: a 2,048-token chunk is where the prompt path is efficient. Short answers +run below the long-output speed: the first rounds have no draft yet.) + +**How it works inside:** the encoder turns the picture into rows of the same width as the model's word embeddings; +Strata puts them where the prompt has `<|image_pad|>` tokens and gives each one its 2-D position (row and column in the +picture; the model uses interleaved M-RoPE). Answers match llama.cpp's multimodal implementation token for token on our +test images. + +--- + +## Troubleshooting + +| Symptom | What to do | +| --- | --- | +| `the NVIDIA driver is too old` | Update the driver (NVIDIA App or nvidia.com/drivers), restart, run `START-HERE.bat` again. | +| Python or the build tools could not be installed | Install what it names (links are printed), then run it again. Everything already done is kept. | +| `port 8080 is already in use` | Strata is already running (look for its window), or another program uses the port: `START-HERE.bat --port 8081`. | +| `cudaHostRegister ... out of memory` in the log | Normal on Windows: the engine pins the experts in per-layer slices instead. Only a problem if the load then fails. | +| The first start takes minutes | It is reading 34-43 GB into RAM; the second start is faster while the files are in the OS cache. | +| Slow output, disk light busy | Not enough free RAM: close other programs, or choose Q2_0 / IQ2_XS. | +| `prompt ... exceeds the context` | The request is longer than the context you chose: run setup again with a bigger `--context`. | +| Slower than the tables | The monitor plugged into the GPU and other GPU programs take VRAM from the expert cache; RAM running below its rated speed (enable EXPO/XMP in the BIOS) slows the CPU half. | +| `this server was started without the vision encoder` | The model was set up for text only: run setup again with `--vision gpu`. | +| A picture is refused or `cannot read the image` | Use JPEG, PNG, BMP or GIF (not WebP). | +| Pictures are slow (10-30 s) | The encoder runs on the CPU: run setup again with `--vision gpu` (needs ~1.4 GB of VRAM). | +| Anything else | The engine log is `strata-.log` in this folder. | + +--- + +## How it works + +

memory tiers

+ +- **GPU (VRAM):** attention and DeltaNet mixers, the gated-residual weights, routers, shared experts, output head, the MTP + draft layer, the KV cache, and an **expert cache** that fills the rest of VRAM with the most-used experts (it adapts to + the conversation while you chat). +- **RAM:** all 24,576 experts, pinned. The CPU computes the experts that are not on the GPU **in place**, at the same time + as the GPU works on the cached ones (AVX-512 / AVX2 kernels, ggml's for the i-quants). +- **SSD:** the 28.8 GB n-gram table, read a few rows per token through the OS cache. +- **Speculation:** the model's own MTP layer drafts up to 3 tokens; one pass over all 48 layers checks them. 2.4-3.2 + tokens per pass on average. +- **Prompts** are processed in 2,048-token chunks with the experts streamed to the GPU over PCIe. + +The full story, with measurements, bottlenecks and what comes next: **[docs/paper/Strata-Paper.pdf](docs/paper/Strata-Paper.pdf)**. + +--- + +## Credits and licenses + +- Model: [Qwen/Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team; quantizations: + [ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF). + Their licenses apply to the weights. +- [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp) (MIT): the i-quant formats, the GPU dot products and + dequantizers transcribed in `src/kernels/cuda/iq_kernels.cu`, the CPU backend linked for the i-quant experts, the + `mtmd` library behind the image encoder (`tools/vision/`), and `gguf-py` used by the tools. See + `third_party/ggml/LICENSE`. +- Ideas from [Splash](https://github.com/incoai/splash), [ninfer](https://github.com/Neroued/ninfer) and + [HyperQwen](https://github.com/syv-ai/HyperQwen); references in the paper. diff --git a/START-HERE.bat b/START-HERE.bat new file mode 100644 index 00000000..2b80315d --- /dev/null +++ b/START-HERE.bat @@ -0,0 +1,52 @@ +@echo off +rem Strata for Windows: the first run installs everything and starts the model; later runs just start it. +rem Needs only an NVIDIA driver. Python is installed for your user account if it is missing (no admin needed). +setlocal +title Strata +cd /d "%~dp0" +if exist ".venv\Scripts\python.exe" goto run + +call :findpy +if defined PY goto venv +echo. +echo Python 3.10 or newer is not installed. Installing Python 3.12 for your user account ... +where winget >nul 2>nul +if errorlevel 1 goto pyorg +winget install -e --id Python.Python.3.12 --scope user --silent --source winget --accept-package-agreements --accept-source-agreements --disable-interactivity +call :findpy +if defined PY goto venv +:pyorg +echo Downloading the Python installer from python.org ... +powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol='Tls12'; Invoke-WebRequest -UseBasicParsing https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe -OutFile \"$env:TEMP\strata-python-setup.exe\"" +if exist "%TEMP%\strata-python-setup.exe" "%TEMP%\strata-python-setup.exe" /quiet InstallAllUsers=0 PrependPath=1 Include_launcher=1 Include_test=0 +call :findpy +if defined PY goto venv +echo. +echo Python could not be installed automatically. +echo Install 64-bit Python 3.12 from https://www.python.org/downloads/ ("Add python.exe to PATH"), +echo then double-click START-HERE.bat again. +pause +exit /b 1 + +:venv +rem a private environment inside this folder, so nothing is installed into the system Python +%PY% -m venv .venv +if exist ".venv\Scripts\python.exe" goto run +echo Could not create the Python environment in .venv +pause +exit /b 1 + +:run +".venv\Scripts\python.exe" setup.py %* +if errorlevel 1 pause +exit /b + +:findpy +rem the py launcher first, then python on PATH (not the Microsoft Store stub), then the usual per-user folders +set "PY=" +py -3 -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) and sys.maxsize > 2**32 else 1)" >nul 2>nul +if not errorlevel 1 set "PY=py -3" & goto :eof +python -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) and sys.maxsize > 2**32 else 1)" >nul 2>nul +if not errorlevel 1 set "PY=python" & goto :eof +for %%V in (313 312 311 310) do if exist "%LOCALAPPDATA%\Programs\Python\Python%%V\python.exe" set "PY="%LOCALAPPDATA%\Programs\Python\Python%%V\python.exe"" & goto :eof +goto :eof diff --git a/bench/results/2026-09-24-final/estimates.json b/bench/results/2026-09-24-final/estimates.json new file mode 100644 index 00000000..0479ec41 --- /dev/null +++ b/bench/results/2026-09-24-final/estimates.json @@ -0,0 +1,461 @@ +[ + { + "card": "RTX 5070 12GB (measured)", + "model": "Q2_0", + "tier": "1k", + "slots": 4517, + "h": 0.747, + "decode": 88.7, + "prefill": 389.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "Q2_0", + "tier": "1k", + "slots": 7179, + "h": 0.826, + "decode": 80.5, + "prefill": 341.0 + }, + { + "card": "RTX 3090 24GB", + "model": "Q2_0", + "tier": "1k", + "slots": 12503, + "h": 0.93, + "decode": 127.5, + "prefill": 355.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "Q2_0", + "tier": "4k", + "slots": 4464, + "h": 0.722, + "decode": 94.6, + "prefill": 539.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "Q2_0", + "tier": "4k", + "slots": 7126, + "h": 0.809, + "decode": 87.4, + "prefill": 472.0 + }, + { + "card": "RTX 3090 24GB", + "model": "Q2_0", + "tier": "4k", + "slots": 12450, + "h": 0.923, + "decode": 140.0, + "prefill": 491.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "Q2_0", + "tier": "32k", + "slots": 4174, + "h": 0.674, + "decode": 87.5, + "prefill": 571.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "Q2_0", + "tier": "32k", + "slots": 6836, + "h": 0.774, + "decode": 81.3, + "prefill": 501.0 + }, + { + "card": "RTX 3090 24GB", + "model": "Q2_0", + "tier": "32k", + "slots": 12160, + "h": 0.907, + "decode": 130.0, + "prefill": 521.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "Q2_0", + "tier": "64k", + "slots": 3809, + "h": 0.625, + "decode": 76.4, + "prefill": 561.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "Q2_0", + "tier": "64k", + "slots": 6471, + "h": 0.74, + "decode": 71.6, + "prefill": 492.0 + }, + { + "card": "RTX 3090 24GB", + "model": "Q2_0", + "tier": "64k", + "slots": 11795, + "h": 0.892, + "decode": 115.3, + "prefill": 512.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "Q2_0", + "tier": "128k", + "slots": 3079, + "h": 0.571, + "decode": 65.1, + "prefill": 543.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "Q2_0", + "tier": "128k", + "slots": 5741, + "h": 0.702, + "decode": 61.8, + "prefill": 476.0 + }, + { + "card": "RTX 3090 24GB", + "model": "Q2_0", + "tier": "128k", + "slots": 11065, + "h": 0.873, + "decode": 100.0, + "prefill": 495.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "Q2_0", + "tier": "262k", + "slots": 1588, + "h": 0.526, + "decode": 56.3, + "prefill": 496.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "Q2_0", + "tier": "262k", + "slots": 4250, + "h": 0.668, + "decode": 52.9, + "prefill": 435.0 + }, + { + "card": "RTX 3090 24GB", + "model": "Q2_0", + "tier": "262k", + "slots": 9574, + "h": 0.849, + "decode": 84.6, + "prefill": 453.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ2_XS", + "tier": "1k", + "slots": 4621, + "h": 0.799, + "decode": 82.0, + "prefill": 332.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ2_XS", + "tier": "1k", + "slots": 7176, + "h": 0.86, + "decode": 79.6, + "prefill": 291.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ2_XS", + "tier": "1k", + "slots": 12287, + "h": 0.941, + "decode": 131.1, + "prefill": 303.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ2_XS", + "tier": "4k", + "slots": 4568, + "h": 0.785, + "decode": 78.1, + "prefill": 463.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ2_XS", + "tier": "4k", + "slots": 7123, + "h": 0.85, + "decode": 76.9, + "prefill": 406.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ2_XS", + "tier": "4k", + "slots": 12234, + "h": 0.937, + "decode": 127.7, + "prefill": 422.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ2_XS", + "tier": "32k", + "slots": 4289, + "h": 0.783, + "decode": 65.3, + "prefill": 495.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ2_XS", + "tier": "32k", + "slots": 6844, + "h": 0.848, + "decode": 62.9, + "prefill": 434.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ2_XS", + "tier": "32k", + "slots": 11955, + "h": 0.935, + "decode": 102.9, + "prefill": 451.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ2_XS", + "tier": "64k", + "slots": 3936, + "h": 0.759, + "decode": 63.7, + "prefill": 486.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ2_XS", + "tier": "64k", + "slots": 6491, + "h": 0.83, + "decode": 62.0, + "prefill": 426.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ2_XS", + "tier": "64k", + "slots": 11602, + "h": 0.927, + "decode": 102.2, + "prefill": 444.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ2_XS", + "tier": "128k", + "slots": 3229, + "h": 0.695, + "decode": 52.0, + "prefill": 472.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ2_XS", + "tier": "128k", + "slots": 5784, + "h": 0.784, + "decode": 51.3, + "prefill": 413.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ2_XS", + "tier": "128k", + "slots": 10895, + "h": 0.905, + "decode": 85.0, + "prefill": 430.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ2_XS", + "tier": "262k", + "slots": 1796, + "h": 0.627, + "decode": 48.1, + "prefill": 437.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ2_XS", + "tier": "262k", + "slots": 4351, + "h": 0.735, + "decode": 47.3, + "prefill": 383.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ2_XS", + "tier": "262k", + "slots": 9462, + "h": 0.876, + "decode": 77.5, + "prefill": 398.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ3_XXS", + "tier": "1k", + "slots": 3556, + "h": 0.638, + "decode": 64.6, + "prefill": 285.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ3_XXS", + "tier": "1k", + "slots": 5658, + "h": 0.728, + "decode": 65.8, + "prefill": 249.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ3_XXS", + "tier": "1k", + "slots": 9864, + "h": 0.858, + "decode": 106.5, + "prefill": 260.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ3_XXS", + "tier": "4k", + "slots": 3511, + "h": 0.712, + "decode": 65.6, + "prefill": 410.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ3_XXS", + "tier": "4k", + "slots": 5613, + "h": 0.784, + "decode": 64.7, + "prefill": 359.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ3_XXS", + "tier": "4k", + "slots": 9819, + "h": 0.887, + "decode": 103.0, + "prefill": 374.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ3_XXS", + "tier": "32k", + "slots": 3279, + "h": 0.696, + "decode": 57.3, + "prefill": 435.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ3_XXS", + "tier": "32k", + "slots": 5381, + "h": 0.772, + "decode": 56.3, + "prefill": 381.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ3_XXS", + "tier": "32k", + "slots": 9587, + "h": 0.879, + "decode": 89.0, + "prefill": 396.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ3_XXS", + "tier": "64k", + "slots": 2990, + "h": 0.677, + "decode": 54.4, + "prefill": 427.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ3_XXS", + "tier": "64k", + "slots": 5092, + "h": 0.757, + "decode": 53.7, + "prefill": 374.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ3_XXS", + "tier": "64k", + "slots": 9298, + "h": 0.87, + "decode": 85.1, + "prefill": 390.0 + }, + { + "card": "RTX 5070 12GB (measured)", + "model": "IQ3_XXS", + "tier": "128k", + "slots": 2407, + "h": 0.57, + "decode": 44.8, + "prefill": 414.0 + }, + { + "card": "RTX 5060 Ti 16GB", + "model": "IQ3_XXS", + "tier": "128k", + "slots": 4509, + "h": 0.675, + "decode": 44.6, + "prefill": 363.0 + }, + { + "card": "RTX 3090 24GB", + "model": "IQ3_XXS", + "tier": "128k", + "slots": 8715, + "h": 0.824, + "decode": 70.8, + "prefill": 378.0 + } +] \ No newline at end of file diff --git a/bench/results/2026-09-24-final/estimates.md b/bench/results/2026-09-24-final/estimates.md new file mode 100644 index 00000000..66b88d27 --- /dev/null +++ b/bench/results/2026-09-24-final/estimates.md @@ -0,0 +1,27 @@ +### Output (decode) tok/s - estimated + +| Card | Model | 1k | 4k | 32k | 64k | 128k | 262k | +|---|---|---:|---:|---:|---:|---:|---:| +| RTX 5070 12GB (measured) | Q2_0 | ~89 | ~95 | ~88 | ~76 | ~65 | ~56 | +| RTX 5070 12GB (measured) | IQ2_XS | ~82 | ~78 | ~65 | ~64 | ~52 | ~48 | +| RTX 5070 12GB (measured) | IQ3_XXS | ~65 | ~66 | ~57 | ~54 | ~45 | - | +| RTX 5060 Ti 16GB | Q2_0 | ~80 | ~87 | ~81 | ~72 | ~62 | ~53 | +| RTX 5060 Ti 16GB | IQ2_XS | ~80 | ~77 | ~63 | ~62 | ~51 | ~47 | +| RTX 5060 Ti 16GB | IQ3_XXS | ~66 | ~65 | ~56 | ~54 | ~45 | - | +| RTX 3090 24GB | Q2_0 | ~128 | ~140 | ~130 | ~115 | ~100 | ~85 | +| RTX 3090 24GB | IQ2_XS | ~131 | ~128 | ~103 | ~102 | ~85 | ~78 | +| RTX 3090 24GB | IQ3_XXS | ~106 | ~103 | ~89 | ~85 | ~71 | - | + +### Prompt processing tok/s - estimated + +| Card | Model | 1k | 4k | 32k | 64k | 128k | 262k | +|---|---|---:|---:|---:|---:|---:|---:| +| RTX 5070 12GB (measured) | Q2_0 | ~389 | ~539 | ~571 | ~561 | ~543 | ~496 | +| RTX 5070 12GB (measured) | IQ2_XS | ~332 | ~463 | ~495 | ~486 | ~472 | ~437 | +| RTX 5070 12GB (measured) | IQ3_XXS | ~285 | ~410 | ~435 | ~427 | ~414 | - | +| RTX 5060 Ti 16GB | Q2_0 | ~341 | ~472 | ~501 | ~492 | ~476 | ~435 | +| RTX 5060 Ti 16GB | IQ2_XS | ~291 | ~406 | ~434 | ~426 | ~413 | ~383 | +| RTX 5060 Ti 16GB | IQ3_XXS | ~249 | ~359 | ~381 | ~374 | ~363 | - | +| RTX 3090 24GB | Q2_0 | ~355 | ~491 | ~521 | ~512 | ~495 | ~453 | +| RTX 3090 24GB | IQ2_XS | ~303 | ~422 | ~451 | ~444 | ~430 | ~398 | +| RTX 3090 24GB | IQ3_XXS | ~260 | ~374 | ~396 | ~390 | ~378 | - | diff --git a/bench/results/2026-09-24-final/matrix.json b/bench/results/2026-09-24-final/matrix.json new file mode 100644 index 00000000..260c8330 --- /dev/null +++ b/bench/results/2026-09-24-final/matrix.json @@ -0,0 +1,240 @@ +[ + { + "model": "Q2_0", + "tier": "1k", + "prompt_tokens": 1017, + "exit": 0, + "wall_s": 40.2, + "prefill_tok_s": 388.83, + "ttft_s": 2.68, + "decode_tok_s": 88.67, + "decoded": 256, + "spec_accept": 0.739, + "tokens_per_round": 2.71, + "vram_slots": 4517 + }, + { + "model": "Q2_0", + "tier": "4k", + "prompt_tokens": 3562, + "exit": 0, + "wall_s": 44.6, + "prefill_tok_s": 538.75, + "ttft_s": 6.67, + "decode_tok_s": 94.59, + "decoded": 256, + "spec_accept": 0.846, + "tokens_per_round": 3.23, + "vram_slots": 4464 + }, + { + "model": "Q2_0", + "tier": "32k", + "prompt_tokens": 31566, + "exit": 0, + "wall_s": 91.9, + "prefill_tok_s": 571.03, + "ttft_s": 55.34, + "decode_tok_s": 87.48, + "decoded": 256, + "spec_accept": 0.865, + "tokens_per_round": 3.11, + "vram_slots": 4174 + }, + { + "model": "Q2_0", + "tier": "64k", + "prompt_tokens": 64162, + "exit": 0, + "wall_s": 151.5, + "prefill_tok_s": 561.16, + "ttft_s": 114.4, + "decode_tok_s": 76.38, + "decoded": 256, + "spec_accept": 0.781, + "tokens_per_round": 2.8, + "vram_slots": 3809 + }, + { + "model": "Q2_0", + "tier": "128k", + "prompt_tokens": 128478, + "exit": 0, + "wall_s": 273.5, + "prefill_tok_s": 542.95, + "ttft_s": 236.7, + "decode_tok_s": 65.09, + "decoded": 256, + "spec_accept": 0.687, + "tokens_per_round": 2.65, + "vram_slots": 3079 + }, + { + "model": "Q2_0", + "tier": "262k", + "prompt_tokens": 259943, + "exit": 0, + "wall_s": 562.5, + "prefill_tok_s": 496.33, + "ttft_s": 523.8, + "decode_tok_s": 56.26, + "decoded": 256, + "spec_accept": 0.659, + "tokens_per_round": 2.46, + "vram_slots": 1588 + }, + { + "model": "IQ2_XS", + "tier": "1k", + "prompt_tokens": 1017, + "exit": 0, + "wall_s": 42.3, + "prefill_tok_s": 332.19, + "ttft_s": 3.14, + "decode_tok_s": 81.97, + "decoded": 256, + "spec_accept": 0.84, + "tokens_per_round": 3.25, + "vram_slots": 4621 + }, + { + "model": "IQ2_XS", + "tier": "4k", + "prompt_tokens": 3562, + "exit": 0, + "wall_s": 47.4, + "prefill_tok_s": 462.95, + "ttft_s": 7.78, + "decode_tok_s": 78.05, + "decoded": 256, + "spec_accept": 0.856, + "tokens_per_round": 3.28, + "vram_slots": 4568 + }, + { + "model": "IQ2_XS", + "tier": "32k", + "prompt_tokens": 31566, + "exit": 0, + "wall_s": 102.3, + "prefill_tok_s": 495.14, + "ttft_s": 63.83, + "decode_tok_s": 65.32, + "decoded": 256, + "spec_accept": 0.706, + "tokens_per_round": 2.53, + "vram_slots": 4289 + }, + { + "model": "IQ2_XS", + "tier": "64k", + "prompt_tokens": 64162, + "exit": 0, + "wall_s": 170.8, + "prefill_tok_s": 486.42, + "ttft_s": 131.99, + "decode_tok_s": 63.66, + "decoded": 256, + "spec_accept": 0.701, + "tokens_per_round": 2.72, + "vram_slots": 3936 + }, + { + "model": "IQ2_XS", + "tier": "128k", + "prompt_tokens": 128478, + "exit": 0, + "wall_s": 312.7, + "prefill_tok_s": 471.61, + "ttft_s": 272.52, + "decode_tok_s": 52.04, + "decoded": 256, + "spec_accept": 0.602, + "tokens_per_round": 2.44, + "vram_slots": 3229 + }, + { + "model": "IQ2_XS", + "tier": "262k", + "prompt_tokens": 259943, + "exit": 0, + "wall_s": 637.4, + "prefill_tok_s": 436.86, + "ttft_s": 595.11, + "decode_tok_s": 48.05, + "decoded": 256, + "spec_accept": 0.628, + "tokens_per_round": 2.42, + "vram_slots": 1796 + }, + { + "model": "IQ3_XXS", + "tier": "1k", + "prompt_tokens": 1017, + "exit": 0, + "wall_s": 43.8, + "prefill_tok_s": 284.64, + "ttft_s": 3.67, + "decode_tok_s": 64.58, + "decoded": 256, + "spec_accept": 0.912, + "tokens_per_round": 3.63, + "vram_slots": 3556 + }, + { + "model": "IQ3_XXS", + "tier": "4k", + "prompt_tokens": 3562, + "exit": 0, + "wall_s": 48.5, + "prefill_tok_s": 409.76, + "ttft_s": 8.78, + "decode_tok_s": 65.57, + "decoded": 256, + "spec_accept": 0.863, + "tokens_per_round": 3.24, + "vram_slots": 3511 + }, + { + "model": "IQ3_XXS", + "tier": "32k", + "prompt_tokens": 31566, + "exit": 0, + "wall_s": 112.6, + "prefill_tok_s": 434.75, + "ttft_s": 72.69, + "decode_tok_s": 57.3, + "decoded": 256, + "spec_accept": 0.752, + "tokens_per_round": 2.78, + "vram_slots": 3279 + }, + { + "model": "IQ3_XXS", + "tier": "64k", + "prompt_tokens": 64162, + "exit": 0, + "wall_s": 190.1, + "prefill_tok_s": 427.26, + "ttft_s": 150.25, + "decode_tok_s": 54.4, + "decoded": 256, + "spec_accept": 0.723, + "tokens_per_round": 2.88, + "vram_slots": 2990 + }, + { + "model": "IQ3_XXS", + "tier": "128k", + "prompt_tokens": 128478, + "exit": 0, + "wall_s": 351.1, + "prefill_tok_s": 414.43, + "ttft_s": 310.1, + "decode_tok_s": 44.81, + "decoded": 256, + "spec_accept": 0.648, + "tokens_per_round": 2.42, + "vram_slots": 2407 + } +] \ No newline at end of file diff --git a/bench/results/2026-09-24-final/matrix.md b/bench/results/2026-09-24-final/matrix.md new file mode 100644 index 00000000..b9b2cb9c --- /dev/null +++ b/bench/results/2026-09-24-final/matrix.md @@ -0,0 +1,15 @@ +### Prompt processing (tok/s) + +| Model | 1K | 4K | 32K | 64K | 128K | 262K | +|---|---:|---:|---:|---:|---:|---:| +| Q2_0 | 389 | 539 | 571 | 561 | 543 | 496 | +| IQ2_XS | 332 | 463 | 495 | 486 | 472 | 437 | +| IQ3_XXS | 285 | 410 | 435 | 427 | 414 | - | + +### Output (tok/s) + +| Model | 1K | 4K | 32K | 64K | 128K | 262K | +|---|---:|---:|---:|---:|---:|---:| +| Q2_0 | 88.7 | 94.6 | 87.5 | 76.4 | 65.1 | 56.3 | +| IQ2_XS | 82.0 | 78.0 | 65.3 | 63.7 | 52.0 | 48.0 | +| IQ3_XXS | 64.6 | 65.6 | 57.3 | 54.4 | 44.8 | - | diff --git a/bench/results/2026-09-24-vision/vision.json b/bench/results/2026-09-24-vision/vision.json new file mode 100644 index 00000000..5150dc24 --- /dev/null +++ b/bench/results/2026-09-24-vision/vision.json @@ -0,0 +1,53 @@ +[ + { + "model": "Q2_0", + "off": { + "prefill_tok_s": 541.31, + "decode_tok_s": 90.74, + "vram_slots": 4464, + "spec_accept": 0.846 + }, + "on": { + "prefill_tok_s": 530.64, + "decode_tok_s": 86.92, + "vram_slots": 3402, + "spec_accept": 0.858 + }, + "flag_identical": true, + "on_identical": false + }, + { + "model": "IQ2_XS", + "off": { + "prefill_tok_s": 468.17, + "decode_tok_s": 77.14, + "vram_slots": 4568, + "spec_accept": 0.856 + }, + "on": { + "prefill_tok_s": 457.61, + "decode_tok_s": 73.96, + "vram_slots": 3542, + "spec_accept": 0.866 + }, + "flag_identical": true, + "on_identical": false + }, + { + "model": "IQ3_XXS", + "off": { + "prefill_tok_s": 411.3, + "decode_tok_s": 64.7, + "vram_slots": 3511, + "spec_accept": 0.863 + }, + "on": { + "prefill_tok_s": 401.48, + "decode_tok_s": 59.46, + "vram_slots": 2662, + "spec_accept": 0.868 + }, + "flag_identical": true, + "on_identical": true + } +] \ No newline at end of file diff --git a/bench/results/2026-09-24-vision/vision.md b/bench/results/2026-09-24-vision/vision.md new file mode 100644 index 00000000..c30d2992 --- /dev/null +++ b/bench/results/2026-09-24-vision/vision.md @@ -0,0 +1,5 @@ +| Model | Prompt tok/s (vision off) | Prompt tok/s (vision on) | Output tok/s (vision off) | Output tok/s (vision on) | VRAM expert slots off / on | --vision alone: same tokens | +|---|---:|---:|---:|---:|---:|---| +| Q2_0 | 541 | 531 | 90.7 | 86.9 | 4464 / 3402 | yes | +| IQ2_XS | 468 | 458 | 77.1 | 74.0 | 4568 / 3542 | yes | +| IQ3_XXS | 411 | 401 | 64.7 | 59.5 | 3511 / 2662 | yes | diff --git a/chat.py b/chat.py new file mode 100644 index 00000000..124c7278 --- /dev/null +++ b/chat.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""A tiny terminal chat for a running Strata server (start it with run-.bat / run-.sh first). + + python chat.py [--port 8080] [--think none|low|medium|high] + +Type a message and press Enter. /image attaches a picture to your next message (when the server was set up +with images), /think sets how long the model thinks first, /reset starts a new conversation, +/quit leaves. Standard library only. +""" +from __future__ import annotations + +import argparse +import base64 +import json +import mimetypes +import os +import sys +import time +import urllib.request + + +def stream(url, messages, think, max_tokens): + body = {"model": "strata", "messages": messages, "stream": True, "max_tokens": max_tokens, + "reasoning_effort": think} + req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=3600) as r: + for raw in r: + line = raw.decode("utf-8", "replace").strip() + if not line.startswith("data: ") or line == "data: [DONE]": + continue + d = json.loads(line[6:])["choices"][0]["delta"] + yield d.get("reasoning_content") or "", d.get("content") or "" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--think", default="medium", choices=["none", "low", "medium", "high"], + help="how long the model thinks before answering (none = answer directly)") + ap.add_argument("--no-think", action="store_true", help="same as --think none") + ap.add_argument("--max-tokens", type=int, default=4096) + a = ap.parse_args() + url = f"http://{a.host}:{a.port}/v1/chat/completions" + gray, reset = ("\033[90m", "\033[0m") if sys.stdout.isatty() else ("", "") + messages, pending = [], [] + think = "none" if a.no_think else a.think + print(f"Strata chat ({url}). /image = attach a picture, /think none|low|medium|high (now: {think}), " + f"/reset = new conversation, /quit = leave.") + while True: + try: + user = input("\nyou> ").strip() + except (EOFError, KeyboardInterrupt): + print() + return 0 + if user in ("/quit", "/exit"): + return 0 + if user == "/reset": + messages, pending = [], [] + print("(new conversation)") + continue + if user.startswith("/think"): + level = user[6:].strip().lower() + if level in ("none", "low", "medium", "high"): + think = level + print(f"(thinking: {think})") + else: + print("(usage: /think none|low|medium|high)") + continue + if user.startswith("/image"): + path = user[6:].strip().strip('"').strip("'") + if not os.path.isfile(path): + print(f"(no such file: {path or '?'} - usage: /image )") + continue + mime = mimetypes.guess_type(path)[0] or "image/jpeg" + data = base64.b64encode(open(path, "rb").read()).decode() + pending.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{data}"}}) + print(f"(picture attached: {os.path.basename(path)} - now type your question)") + continue + if not user: + continue + if pending: + messages.append({"role": "user", "content": [*pending, {"type": "text", "text": user}]}) + pending = [] + else: + messages.append({"role": "user", "content": user}) + answer, n, t0 = [], 0, time.time() + print("model> ", end="", flush=True) + try: + in_think = False + for reasoning, content in stream(url, messages, think, a.max_tokens): + if reasoning: + if not in_think: + print(gray, end="") + in_think = True + print(reasoning, end="", flush=True) + if content: + if in_think: + print(reset + "\n", end="") + in_think = False + print(content, end="", flush=True) + answer.append(content) + n += 1 + if in_think: + print(reset, end="") + except OSError as e: + print(f"\n(could not reach the server at {url}: {e})") + messages.pop() + continue + dt = time.time() - t0 + print(f"\n{gray}[{n} chunks in {dt:.1f} s]{reset}") + messages.append({"role": "assistant", "content": "".join(answer)}) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/data/draft_vocab.bin b/data/draft_vocab.bin new file mode 100644 index 0000000000000000000000000000000000000000..c588b567f6e35fac0e9472a0772d2d03898a93ef GIT binary patch literal 162100 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 M`NCJe@tq(1ACYm4TL1t6 literal 0 HcmV?d00001 diff --git a/data/expert-profile.bin b/data/expert-profile.bin new file mode 100644 index 0000000000000000000000000000000000000000..bf4aeed408d518e22d8e9dfd2fa821dcd05f232f GIT binary patch literal 130328 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

|fY_0E%Yw2@0Ums&v6R{fR$Hwrbsd9(2?+N3!!e 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

8Gxo<2NJKUH8XQ>cUp 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 GVQ`|H)O=2yoj|TW42WBOi4)*MV=q@G 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 literal 0 HcmV?d00001 diff --git a/docs/paper/Strata-Paper.pdf b/docs/paper/Strata-Paper.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fcfd2eb5eba749064ea9246df6c07b59abe855d5 GIT binary patch literal 341672 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

    6hwK%XX@^2k*5gLs9%*y)RBaMkk| zY)NO)-c!l+Fc6F-S-I5Jw-4fj>7v;Qdc0ji4PmK4kC58-Xc3b*;>vl-H8DX&Wl)KrKPfy2ED%e4W@^tgUxG;bli_VM)D0n z^GdrukGEYMUC6ex@BIPR%mcNm`FL_eVnlJJZbN?@@N^s!YMSiW39S1Z2@T{Wflm0E z59LJ6tcFmXzufzr<_Jbk8K_mXX%f7&T5!+b!?3W+Xt?{l^6}o|0E)+Vo4lxHN4^e7 z^;QbfsVe^rW{afN(9oZT&;=<8od%DCh7-=ydGZ>Nyx+8E^3c|n;;L_g>YE{@kg1c& zVEs0esBr<#GzvGCmsp5p!#H)_R1G|a){n@oQ_bS>JWfVg$1!-XWfk z>@%ecu*B?N8|{|LSg0E!Av{tWUks#=nFv0Z%-Q+7=@zA@s2CdwDT(GM>VN0rpl>w_ar!=6ln^)n~FKjKs zO-H+)r$FFFzTC936W6(D8}fV2ZxPZPqW?eU-U2SF^!p!I5s*faP{JW36_}wxB$Y1d z5Znquctq74(GitirL-zT66Vw#r}Ci}Z2_Cw(RHVGs8uLj$!5 zAuN+tp3p5>p%?1}Ox@uhIG-p?3kAp|UGq2VeZ{ga-*VyVYT~_Sqy9J))t0TsAjcd2 z{u#<`wd0O213KldHd*vEdz43?n>5y!KF^aop3IFhP8~)f7Y+s)hCYt0?u;AhdWM;l zB78rZMZO<@nXyrYLMv4&Ht4Vm4NrF%-eU`IlJ-PuzCPS{CAJuzJuZc`Si<2Al! z`|#E)anXXf{L+Q0c=^`#XHm}aG7SazTVJ>;uWJXyVn<1u&Aaoe<-HKBcTilGmZW#g zQW)^LDVBk`IGC4@B*V+~P2#exdR}G3>C)HFQ?^^G2BW8DHYM(D^v;*fW`0VeD+*bB zJ{Gjcxxr(sZ)5!UqJN(jZ64odI?sMuR#XbRe_^@#6J zbA!|NVdOm9*P3Ov@|Src6__qXrI`h4^%vPbcvrGUvgUM0%~osqaV+bXIZ^IA^0ML) zZ5{a@t;^BYCyQj=h;LnB;LfD3l6q`(v$jLL@bsi_Ng14Iu#_@z_v+%mJisB8{+QWB>41BkmcgXec-C=9e*Sr2f z2c?Xy9*ItBaI1dT)@RXIrd#~^MR!)Dv`E_lQu*|@5AU1#rRbjXek51<_}*3VrH~p* zZqr6boQq{={aQ_CnNY!6*=j8Iy`c>r-7X7TB|N8q7t~s3!KL$>%;dsB6oN0h*l(-j zS+Bh~iKR+>MwaEwki%|};j&9oZ%srw=Bd*2n^%_&ecxvBzc2K#5QuazYUD4^sklpw z7xT_w*P?fB*|ymr9e3os{-$Xl)lEJxXU)EseN&%%7PGj(_$&C?^%f>2!4uEX*za;% zLw}h6{sTmAfu}KmSPZ+wA!34=m0UU+*nknd z`_)R<0|ZpM>kPbiU3C0&J7q}D6S%zn+`poA@5Wcub>c^(`$^XT#5m`E<{5|ahq z6CRTROhN-4PQe2`L!O^X8!Z~8T1b386*gNm2DFf%Hl8xf+0QCR?WMUza%X{;=$on| z4s|HKgO^R9Cz0;esoV`RN&gH&98(9Ju!rAs0k_~DvxT#9>uCZ z86V6w&Inmf578*6eECf}NDM<1?ad_iJXgNAFU>{&X<4vXFONgVl^2wi74Ig!VjRq# zIq6T$kh0#jQ(n3Lf%-ue{h$-Cck|w&pu%pU=bjGcF7r6Q>dx&Kiu>!IXzHt#yOi#e z7+$6?*L`IeUmnMr-Oso-li<2gaetxlPM&L+L*>F(A34w!Zz_y5(&EKUS5E_Vmoo)! zPVxZ_^=x*lp~;0~!%s;TCbF-+ioADOg5vp*gq6;N=&~5Ff7|YymhU&!btX≧?oT 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
    S<_$YY5{0-4zGqw2S!2D+a%=VdLkVn9GVxJ~t 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≷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 HEtLNsRdXrT literal 0 HcmV?d00001 diff --git a/docs/paper/tiers.svg b/docs/paper/tiers.svg new file mode 100644 index 00000000..867ba8ee --- /dev/null +++ b/docs/paper/tiers.svg @@ -0,0 +1,18 @@ + + +GPU VRAM 12 GB · 672 GB/s +dense weights ~3.3 GBmixers · GR · routers · head +expert cache: everything left (~6 GB)~4,500 of 24,576 experts, adaptive +MTP layer0.8 GB +KV + state0.3-3 GB +PCIe 4.0 x16 · ~26 GB/s · experts by DMA (prompt, 20% of misses, cache swaps) + +System RAM 64 GB DDR5 · ~41-52 GB/s · 6 CPU cores (AVX-512) +pinned expert arena: all 24,576 experts34 GB (Q2_0) · 35.5 GB (IQ2_XS) · 43 GB (IQ3_XXS); the CPU computes misses in place +OS page cache: hot n-gram rows+ OS, embedding table, buffers +NVMe · 16 row reads per token, issued a step ahead + +SSD +GGUF shard 1 (37.6-47 GB): read once at start +GGUF shard 2: n-gram table 28.8 GB, memory-mapped + \ No newline at end of file diff --git a/include/strata/artifact/dequant.hpp b/include/strata/artifact/dequant.hpp new file mode 100644 index 00000000..d5a314ce --- /dev/null +++ b/include/strata/artifact/dequant.hpp @@ -0,0 +1,292 @@ +#pragma once +// src/artifact/dequant.cpp - P1.S3: scalar reference dequantizers, transcribed from ggml. +// +// P1.S3 asks for every encoding present in the Q2_0 file. This starts with the ones on the critical +// path (the routed experts are all Q2_0) plus the trivial element types, and is structured so the +// rest drop in. Source of truth per encoding is quoted in each function. +// +// Q2_0 is the one that matters, and its contract is settled from source in docs/q2_0-contract.md: +// block = { fp16 d; uint8 qs[16] } = 64 elements, 18 bytes +// code {0,1,2,3} -> symbol {-1,0,+1,+2}; v = (code - 1) * d +// 4 codes per byte, LSB-first: byte j/4, bits (j%4)*2 +// QK2_0 = 64 is PROVEN from the artifact's own offset brackets, not assumed (the llama.cpp PR that +// introduced the type used 128; this artifact is 64). +// +// Validation: `--check ` dequantizes real blocks and asserts the STRUCTURAL invariant that +// every output value is one of {-d, 0, +d, +2d} for that block's own scale. That catches bit-order, +// sign and stride bugs, which are the failure modes a dequantizer actually has. It is deliberately +// not a correctness-vs-ggml test - that needs ggml linked, which is P1.S3's next step. + +#include +#include +#include +#include +#include +#include + +#define STRATA_GGUF_MAIN_DISABLED 1 +#include "strata/artifact/gguf_reader.hpp" + +namespace strata { + +// ---- fp16/bf16 -> fp32. Both handle subnormals and infinities; naive shifts do not. +inline float fp16_to_fp32(uint16_t h) { + const uint32_t sign = (uint32_t)(h >> 15) & 1u; + uint32_t exp = (h >> 10) & 0x1Fu, man = h & 0x3FFu, f; + if (exp == 0) { + if (man == 0) + f = sign << 31; + else { + exp = 127 - 15 + 1; + while (!(man & 0x400u)) { + man <<= 1; + --exp; + } + man &= 0x3FFu; + f = (sign << 31) | (exp << 23) | (man << 13); + } + } else if (exp == 31) + f = (sign << 31) | 0x7F800000u | (man << 13); + else + f = (sign << 31) | ((exp - 15 + 127) << 23) | (man << 13); + float out; + std::memcpy(&out, &f, 4); + return out; +} + +inline float bf16_to_fp32(uint16_t h) { + const uint32_t f = (uint32_t)h << 16; + float out; + std::memcpy(&out, &f, 4); + return out; +} + +inline uint16_t read_u16(const uint8_t* p) { + return (uint16_t)(p[0] | (p[1] << 8)); +} + +// ---- Q2_0: 64 elements from an 18-byte block. Mirrors dequantize_row_q2_0 (ggml master 3cf03257). +inline void dequantize_q2_0(const uint8_t* block, float* out) { + const float d = fp16_to_fp32(read_u16(block)); + const uint8_t* qs = block + 2; + for (int j = 0; j < 64; ++j) { + const int byte_index = j / 4; + const int bit_offset = (j % 4) * 2; + const int code = (qs[byte_index] >> bit_offset) & 0x03; + out[j] = (float)(code - 1) * d; // code {0,1,2,3} -> symbol {-1,0,+1,+2} + } +} + +// ---- Q8_0: 32 elements from a 34-byte block. +inline void dequantize_q8_0(const uint8_t* block, float* out) { + const float d = fp16_to_fp32(read_u16(block)); + const int8_t* qs = (const int8_t*)(block + 2); + for (int j = 0; j < 32; ++j) out[j] = (float)qs[j] * d; +} + +// ---- Q4_0: 32 elements from an 18-byte block. Low nibble is element j, high nibble is j+16. +inline void dequantize_q4_0(const uint8_t* block, float* out) { + const float d = fp16_to_fp32(read_u16(block)); + const uint8_t* qs = block + 2; + for (int j = 0; j < 16; ++j) { + out[j] = (float)((int)(qs[j] & 0x0F) - 8) * d; + out[j + 16] = (float)((int)(qs[j] >> 4) - 8) * d; + } +} + +// ---- Q5_0: 32 elements from a 22-byte block. Same nibble layout as Q4_0 plus a 5th bit per element +// packed in qh[4]; the 32 bits of qh are the high bits of elements 0..31 in order. +inline void dequantize_q5_0(const uint8_t* block, float* out) { + const float d = fp16_to_fp32(read_u16(block)); + const uint8_t* qh = block + 2; + const uint8_t* qs = block + 6; + const uint32_t h = + (uint32_t)qh[0] | ((uint32_t)qh[1] << 8) | ((uint32_t)qh[2] << 16) | ((uint32_t)qh[3] << 24); + for (int j = 0; j < 16; ++j) { + const int x0 = (int)(qs[j] & 0x0F) | (int)(((h >> j) & 1u) << 4); + const int x1 = (int)(qs[j] >> 4) | (int)(((h >> (j + 16)) & 1u) << 4); + out[j] = (float)(x0 - 16) * d; + out[j + 16] = (float)(x1 - 16) * d; + } +} +// ---- IQ4_NL: 32 elements from an 18-byte block. Non-linear 4-bit codebook, NOT a linear grid, so +// the table must be exact. Copied verbatim from ggml-common.h `kvalues_iq4nl` at 3cf03257: +// -127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113 +// Layout matches dequantize_row_iq4_nl (ggml-quants.c): low nibble is element j, high nibble j+16. +static const int8_t kvalues_iq4nl[16] = {-127, -104, -83, -65, -49, -35, -22, -10, + 1, 13, 25, 38, 53, 69, 89, 113}; + +inline void dequantize_iq4_nl(const uint8_t* block, float* out) { + const float d = fp16_to_fp32(read_u16(block)); + const uint8_t* qs = block + 2; + for (int j = 0; j < 16; ++j) { + out[j] = d * (float)kvalues_iq4nl[qs[j] & 0x0F]; + out[j + 16] = d * (float)kvalues_iq4nl[qs[j] >> 4]; + } +} +// ---- Q6_K: 256 elements from a 210-byte super-block, transcribed from dequantize_row_q6_K +// (ggml-quants.c at 3cf03257). Layout: ql[128] low nibbles, qh[64] high 2 bits, scales[16] int8, +// fp16 d LAST. Processed in two 128-element halves, each advancing ql by 64, qh by 32, sc by 8. +// The `sc` indices are is+{0,2,4,6} with is = l/16 - so each 32-element group draws from 8 of the 16 +// scales, and the four output positions within a 32-group use different scales. Easy to get subtly +// wrong, which is why it is transcribed rather than recalled. +inline void dequantize_q6_K(const uint8_t* block, float* out) { + const uint8_t* ql = block; // 128 bytes + const uint8_t* qh = block + 128; // 64 + const int8_t* sc = (const int8_t*)(block + 192); // 16 + const float d = fp16_to_fp32(read_u16(block + 208)); + float* y = out; + for (int n = 0; n < 256; n += 128) { + for (int l = 0; l < 32; ++l) { + const int is = l / 16; + const int q1 = (int)((ql[l + 0] & 0x0F) | (((qh[l] >> 0) & 3) << 4)) - 32; + const int q2 = (int)((ql[l + 32] & 0x0F) | (((qh[l] >> 2) & 3) << 4)) - 32; + const int q3 = (int)((ql[l + 0] >> 4) | (((qh[l] >> 4) & 3) << 4)) - 32; + const int q4 = (int)((ql[l + 32] >> 4) | (((qh[l] >> 6) & 3) << 4)) - 32; + y[l + 0] = d * (float)sc[is + 0] * (float)q1; + y[l + 32] = d * (float)sc[is + 2] * (float)q2; + y[l + 64] = d * (float)sc[is + 4] * (float)q3; + y[l + 96] = d * (float)sc[is + 6] * (float)q4; + } + y += 128; + ql += 64; + qh += 32; + sc += 8; + } +} +// ---- Q4_K: 256 elements from a 144-byte super-block. Layout is `fp16 d`, `fp16 dmin` (so the FIRST +// four bytes are the scale pair, unlike Q6_K where d is last), `scales[12]`, `qs[128]`. +// Both functions transcribed from ggml-quants.c at 3cf03257. The 6-bit scale/min unpacking in +// get_scale_min_k4 is the part that is impossible to recall reliably: the low 4 bits of the second +// half come from q[j+4], and the high 2 bits from the low halves' top bits (q[j-4] and q[j]). +static inline void get_scale_min_k4(int j, const uint8_t* q, uint8_t& d, uint8_t& m) { + if (j < 4) { + d = q[j] & 63; + m = q[j + 4] & 63; + } else { + d = (q[j + 4] & 0x0F) | ((q[j - 4] >> 6) << 4); + m = (q[j + 4] >> 4) | ((q[j - 0] >> 6) << 4); + } +} + +inline void dequantize_q4_K(const uint8_t* block, float* out) { + const float d = fp16_to_fp32(read_u16(block)); // d + const float mn = fp16_to_fp32(read_u16(block + 2)); // dmin + const uint8_t* scales = block + 4; // 12 bytes + const uint8_t* q = block + 16; // 128 bytes + float* y = out; + int is = 0; + for (int j = 0; j < 256; j += 64) { + uint8_t sc, m; + get_scale_min_k4(is + 0, scales, sc, m); + const float d1 = d * (float)sc, m1 = mn * (float)m; + get_scale_min_k4(is + 1, scales, sc, m); + const float d2 = d * (float)sc, m2 = mn * (float)m; + for (int l = 0; l < 32; ++l) *y++ = d1 * (float)(q[l] & 0x0F) - m1; + for (int l = 0; l < 32; ++l) *y++ = d2 * (float)(q[l] >> 4) - m2; + q += 32; + is += 2; + } +} +// ---- Q5_K: 256 elements from a 176-byte super-block: `fp16 d`, `fp16 dmin`, `scales[12]`, +// `qh[32]`, `qs[128]`. Same shape as Q4_K plus the 5th bit plane. Transcribed from +// dequantize_row_q5_K (ggml-quants.c, 3cf03257). The 5th bits are selected by a mask that SHIFTS LEFT +// BY 2 each 64-element group (u1 = 1,4,16,64; u2 = 2,8,32,128) - a recalled version would very likely +// have used a fixed mask or a shift by 1. +inline void dequantize_q5_K(const uint8_t* block, float* out) { + const float d = fp16_to_fp32(read_u16(block)); + const float mn = fp16_to_fp32(read_u16(block + 2)); + const uint8_t* scales = block + 4; // 12 + const uint8_t* qh = block + 16; // 32 + const uint8_t* ql = block + 48; // 128 + float* y = out; + int is = 0; + uint8_t u1 = 1, u2 = 2; + for (int j = 0; j < 256; j += 64) { + uint8_t sc, m; + get_scale_min_k4(is + 0, scales, sc, m); + const float d1 = d * (float)sc, m1 = mn * (float)m; + get_scale_min_k4(is + 1, scales, sc, m); + const float d2 = d * (float)sc, m2 = mn * (float)m; + for (int l = 0; l < 32; ++l) *y++ = d1 * (float)((ql[l] & 0x0F) + ((qh[l] & u1) ? 16 : 0)) - m1; + for (int l = 0; l < 32; ++l) *y++ = d2 * (float)((ql[l] >> 4) + ((qh[l] & u2) ? 16 : 0)) - m2; + ql += 32; + is += 2; + u1 = (uint8_t)(u1 << 2); + u2 = (uint8_t)(u2 << 2); + } +} +// ---- Q3_K: 256 elements from a 110-byte super-block: `hmask[32]`, `qs[64]`, `scales[12]`, `fp16 d` +// LAST. Transcribed from dequantize_row_q3_K (ggml-quants.c, 3cf03257). This type has NO `dmin` - its +// scales are SIGNED 6-bit with a `- 32` bias - so it does not follow the Q4_K/Q5_K pattern at all. +// Two details that cannot be recalled: +// * the 12 scale bytes are bit-repacked through three 32-bit masks before use (kmask1/2 below), +// because 16 six-bit scales do not fit in 12 bytes linearly; +// * the low 2 bits are offset by -4 when the high bit is CLEAR, i.e. the correction is NEGATIVE. +inline void dequantize_q3_K(const uint8_t* block, float* out) { + const uint8_t* hm = block; // 32 + const uint8_t* q = block + 32; // 64 + const float d_all = fp16_to_fp32(read_u16(block + 108)); + + const uint32_t kmask1 = 0x03030303u, kmask2 = 0x0f0f0f0fu; + uint32_t aux[4] = {0, 0, 0, 0}; + std::memcpy(aux, block + 96, 12); + const uint32_t tmp = aux[2]; + aux[2] = ((aux[0] >> 4) & kmask2) | (((tmp >> 4) & kmask1) << 4); + aux[3] = ((aux[1] >> 4) & kmask2) | (((tmp >> 6) & kmask1) << 4); + aux[0] = (aux[0] & kmask2) | (((tmp >> 0) & kmask1) << 4); + aux[1] = (aux[1] & kmask2) | (((tmp >> 2) & kmask1) << 4); + const int8_t* scales = (const int8_t*)aux; + + float* y = out; + int is = 0; + uint8_t m = 1; // hoisted: ggml declares this BEFORE the n loop, so it does not reset between halves + for (int n = 0; n < 256; n += 128) { + int shift = 0; + for (int j = 0; j < 4; ++j) { + float dl = d_all * (float)(scales[is++] - 32); + for (int l = 0; l < 16; ++l) + *y++ = dl * (float)((int8_t)((q[l + 0] >> shift) & 3) - ((hm[l + 0] & m) ? 0 : 4)); + dl = d_all * (float)(scales[is++] - 32); + for (int l = 0; l < 16; ++l) + *y++ = dl * (float)((int8_t)((q[l + 16] >> shift) & 3) - ((hm[l + 16] & m) ? 0 : 4)); + shift += 2; + m = (uint8_t)(m << 1); + } + q += 32; + } +} +// ---- IQ4_XS: 256 elements from a 136-byte super-block: `fp16 d`, `uint16 scales_h`, `scales_l[4]`, +// `qs[128]`. Transcribed from dequantize_row_iq4_xs (ggml-quants.c, 3cf03257). Reuses the IQ4_NL +// codebook. Eight 32-element groups; each group's scale is 6 bits assembled from the LOW nibble of +// `scales_l[ib/2]` (selected by ib%2) and TWO bits of `scales_h` at shift 2*ib - so the high bits come +// from a 16-bit field indexed by group, not from a byte array. Then `- 32`, as in Q3_K. +inline void dequantize_iq4_xs(const uint8_t* block, float* out) { + const float d = fp16_to_fp32(read_u16(block)); + const uint16_t scales_h = read_u16(block + 2); + const uint8_t* scales_l = block + 4; // 4 + const uint8_t* qs = block + 8; // 128 + float* y = out; + for (int ib = 0; ib < 8; ++ib) { + const int ls = ((scales_l[ib / 2] >> (4 * (ib % 2))) & 0x0F) | (((scales_h >> (2 * ib)) & 3) << 4); + const float dl = d * (float)(ls - 32); + for (int j = 0; j < 16; ++j) { + y[j + 0] = dl * (float)kvalues_iq4nl[qs[j] & 0x0F]; + y[j + 16] = dl * (float)kvalues_iq4nl[qs[j] >> 4]; + } + y += 32; + qs += 16; + } +} +// ---- element types +inline void dequantize_f32(const uint8_t* p, float* out, int n) { + std::memcpy(out, p, (size_t)n * 4); +} +inline void dequantize_f16(const uint8_t* p, float* out, int n) { + for (int i = 0; i < n; ++i) out[i] = fp16_to_fp32(read_u16(p + 2 * i)); +} +inline void dequantize_bf16(const uint8_t* p, float* out, int n) { + for (int i = 0; i < n; ++i) out[i] = bf16_to_fp32(read_u16(p + 2 * i)); +} + +} // namespace strata diff --git a/include/strata/artifact/gguf_reader.hpp b/include/strata/artifact/gguf_reader.hpp new file mode 100644 index 00000000..eb490b64 --- /dev/null +++ b/include/strata/artifact/gguf_reader.hpp @@ -0,0 +1,486 @@ +// include/strata/artifact/gguf_reader.hpp - generated from src/artifact/gguf_reader.cpp by +// scripts/split_artifact.py. Header-only on purpose: the reader is one translation unit's worth of +// code with no state to hide, and a header-only split cannot introduce a duplicate-symbol or +// missing-declaration bug in code that is already validated. +#pragma once +// src/artifact/gguf_reader.cpp - P1.S2: GGUF v3 reader, mmap, no ggml dependency. +// +// The C++ counterpart of tools/gguf_reader.py, which has been the reference since P0.S6 (written +// because gguf-py cannot represent type 42 / Q2_0). Per P1.S2 it must: +// * mmap the file and parse header, metadata KV (ALL value types incl. arrays), tensor directory +// * be multi-shard aware (split.* keys) +// * carry an architecture guard: general.architecture == "qwen4exp" and the compiled-in constants +// must match, refusing with a precise error otherwise +// * have no ggml dependency +// +// This file is the reader plus a `--check` mode that validates it the way the Python one is +// validated: parse the tiny model AND both real shards, and report counts that other harnesses have +// already established independently (1,223 / 1 tensors; 202 Q2_0 in shard 1). Agreement with those +// numbers is the test. +// +// Build: scripts/build_artifact.bat Run: gguf_reader.exe [--check] + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#include +#endif + +namespace strata { + +// ---- ggml type ids we care about. 42 = Q2_0, the PrismML ternary 2-bit encoding this engine targets. +inline const char* ggml_type_name(uint32_t t) { + switch (t) { + case 0: + return "F32"; + case 1: + return "F16"; + case 2: + return "Q4_0"; + case 3: + return "Q4_1"; + case 6: + return "Q5_0"; + case 7: + return "Q5_1"; + case 8: + return "Q8_0"; + case 9: + return "Q8_1"; + case 10: + return "Q2_K"; + case 11: + return "Q3_K"; + case 12: + return "Q4_K"; + case 13: + return "Q5_K"; + case 14: + return "Q6_K"; + case 15: + return "Q8_K"; + case 16: + return "IQ2_XXS"; + case 17: + return "IQ2_XS"; + case 18: + return "IQ3_XXS"; + case 19: + return "IQ1_S"; + case 20: + return "IQ4_NL"; + case 21: + return "IQ3_S"; + case 22: + return "IQ2_S"; + case 23: + return "IQ4_XS"; + case 30: + return "BF16"; + case 34: + return "TQ1_0"; + case 35: + return "TQ2_0"; + case 39: + return "MXFP4"; + case 40: + return "NVFP4"; + case 41: + return "Q1_0"; + case 42: + return "Q2_0"; + default: + return "?"; + } +} + +// Block geometry: (elements per block, bytes per block). Q2_0 is 64/18 - proven from this artifact's +// own offset brackets in P0.S6 and recorded in docs/q2_0-contract.md. +inline bool block_geometry(uint32_t t, int& elems, int& bytes) { + switch (t) { + case 0: + elems = 1; + bytes = 4; + return true; + case 1: + case 30: + elems = 1; + bytes = 2; + return true; + case 2: + elems = 32; + bytes = 18; + return true; + case 3: + elems = 32; + bytes = 20; + return true; + case 6: + elems = 32; + bytes = 22; + return true; + case 7: + elems = 32; + bytes = 24; + return true; + case 8: + elems = 32; + bytes = 34; + return true; + case 9: + elems = 32; + bytes = 36; + return true; + case 10: + elems = 256; + bytes = 84; + return true; + case 11: + elems = 256; + bytes = 110; + return true; + case 12: + elems = 256; + bytes = 144; + return true; + case 13: + elems = 256; + bytes = 176; + return true; + case 14: + elems = 256; + bytes = 210; + return true; + case 16: + elems = 256; + bytes = 66; + return true; + case 17: + elems = 256; + bytes = 74; + return true; + case 18: + elems = 256; + bytes = 98; + return true; + case 20: + elems = 32; + bytes = 18; + return true; + case 21: // IQ3_S + elems = 256; + bytes = 110; + return true; + case 22: // IQ2_S + elems = 256; + bytes = 82; + return true; + case 23: + elems = 256; + bytes = 136; + return true; + case 29: // IQ1_M + elems = 256; + bytes = 56; + return true; + case 42: + elems = 64; + bytes = 18; + return true; + default: + return false; + } +} + +struct TensorInfo { + std::string name; + std::vector shape; // GGUF order: dim 0 varies fastest + uint32_t type = 0; + uint64_t offset = 0; + uint64_t elements() const { + uint64_t n = 1; + for (auto d : shape) n *= d; + return n; + } + const char* type_name() const { return ggml_type_name(type); } +}; + +// A bounds-checked cursor over the mmapped header region. Every read is checked, so a truncated or +// corrupt file produces a precise error rather than a segfault. +class Cursor { +public: + Cursor(const uint8_t* base, size_t size) : base_(base), size_(size) {} + void need(size_t n) const { + if (pos_ + n > size_) throw std::runtime_error("GGUF: unexpected end of file in header"); + } + template T read() { + need(sizeof(T)); + T v; + std::memcpy(&v, base_ + pos_, sizeof(T)); + pos_ += sizeof(T); + return v; + } + std::string str() { + const uint64_t n = read(); + need((size_t)n); + std::string s(reinterpret_cast(base_ + pos_), (size_t)n); + pos_ += (size_t)n; + return s; + } + size_t pos() const { return pos_; } + +private: + const uint8_t* base_; + size_t size_; + size_t pos_ = 0; +}; + +enum class MetaType : uint32_t { U8 = 0, I8, U16, I16, U32, I32, F32, BOOL, STRING, ARRAY, U64, I64, F64 }; + +struct MetaValue { + MetaType type = MetaType::U32; + uint64_t u = 0; // integer payloads + double f = 0; // float payloads + std::string s; // string payloads + MetaType elem = MetaType::U32; // arrays + uint64_t count = 0; + std::vector items; + bool is_num() const { return type != MetaType::STRING && type != MetaType::ARRAY; } + double num() const { return (type == MetaType::F32 || type == MetaType::F64) ? f : (double)u; } +}; + +inline MetaValue read_value(Cursor& c, MetaType t, int depth = 0) { + if (depth > 2) throw std::runtime_error("GGUF: array nesting too deep"); + MetaValue v; + v.type = t; + switch (t) { + case MetaType::U8: + v.u = c.read(); + break; + case MetaType::I8: + v.u = (uint64_t)(int64_t)c.read(); + break; + case MetaType::U16: + v.u = c.read(); + break; + case MetaType::I16: + v.u = (uint64_t)(int64_t)c.read(); + break; + case MetaType::U32: + v.u = c.read(); + break; + case MetaType::I32: + v.u = (uint64_t)(int64_t)c.read(); + break; + case MetaType::F32: { + float x = c.read(); + v.f = x; + v.u = 0; + break; + } + case MetaType::BOOL: + v.u = c.read() ? 1 : 0; + break; + case MetaType::STRING: + v.s = c.str(); + break; + case MetaType::U64: + v.u = c.read(); + break; + case MetaType::I64: + v.u = (uint64_t)c.read(); + break; + case MetaType::F64: + v.f = c.read(); + break; + case MetaType::ARRAY: { + v.elem = (MetaType)c.read(); + v.count = c.read(); + if (v.count > (1u << 24)) throw std::runtime_error("GGUF: implausible array length"); + v.items.reserve((size_t)std::min(v.count, 64)); + for (uint64_t i = 0; i < v.count; ++i) { + MetaValue e = read_value(c, v.elem, depth + 1); + if (i < 64) v.items.push_back(std::move(e)); // keep a sample; the count is what matters + } + break; + } + default: + throw std::runtime_error("GGUF: unknown metadata value type"); + } + return v; +} + +class GgufFile { +public: + explicit GgufFile(const std::string& path) : path_(path) { + try { open(); } + catch (...) { close(); throw; } + } + ~GgufFile() { close(); } + GgufFile(const GgufFile&) = delete; + GgufFile& operator=(const GgufFile&) = delete; + + const std::vector& tensors() const { return tensors_; } + const std::map& metadata() const { return meta_; } + uint32_t version() const { return version_; } + uint64_t data_start() const { return data_start_; } + uint64_t file_size() const { return size_; } + + const TensorInfo* find(const std::string& name) const { + for (const auto& t : tensors_) + if (t.name == name) return &t; + return nullptr; + } + uint64_t count_type(const char* tn) const { + uint64_t n = 0; + for (const auto& t : tensors_) + if (std::strcmp(t.type_name(), tn) == 0) ++n; + return n; + } + const MetaValue* get(const std::string& key) const { + auto it = meta_.find(key); + return it == meta_.end() ? nullptr : &it->second; + } + const uint8_t* tensor_data(const TensorInfo& t) const { return base_ + data_start_ + t.offset; } + +private: + void open() { +#ifdef _WIN32 + HANDLE h = CreateFileA(path_.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) throw std::runtime_error("cannot open " + path_); + LARGE_INTEGER li{}; + GetFileSizeEx(h, &li); + size_ = (uint64_t)li.QuadPart; + HANDLE m = CreateFileMappingA(h, nullptr, PAGE_READONLY, 0, 0, nullptr); + if (!m) { + CloseHandle(h); + throw std::runtime_error("CreateFileMapping failed"); + } + base_ = (const uint8_t*)MapViewOfFile(m, FILE_MAP_READ, 0, 0, 0); + if (!base_) { + CloseHandle(m); + CloseHandle(h); + throw std::runtime_error("MapViewOfFile failed"); + } + map_ = m; + file_ = h; +#else + int fd = ::open(path_.c_str(), O_RDONLY); + if (fd < 0) throw std::runtime_error("cannot open " + path_); + fd_ = fd; + struct stat st{}; + if (fstat(fd, &st) != 0) throw std::runtime_error("fstat failed"); + size_ = (uint64_t)st.st_size; + void* p = mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd, 0); + if (p == MAP_FAILED) throw std::runtime_error("mmap failed"); + base_ = (const uint8_t*)p; +#endif + parse(); + } + void close() { +#ifdef _WIN32 + if (base_) UnmapViewOfFile(base_); + if (map_) CloseHandle((HANDLE)map_); + if (file_) CloseHandle((HANDLE)file_); +#else + if (base_) munmap((void*)base_, size_); + if (fd_ >= 0) ::close(fd_); +#endif + } + + void parse() { + Cursor c(base_, size_); + const uint32_t magic = c.read(); + if (magic != 0x46554747u) throw std::runtime_error("not a GGUF file (bad magic)"); + version_ = c.read(); + if (version_ != 3) + throw std::runtime_error("GGUF v" + std::to_string(version_) + ", this reader handles v3"); + const uint64_t n_tensors = c.read(); + const uint64_t n_kv = c.read(); + + for (uint64_t i = 0; i < n_kv; ++i) { + std::string key = c.str(); + MetaType t = (MetaType)c.read(); + meta_.emplace(std::move(key), read_value(c, t)); + } + tensors_.reserve((size_t)n_tensors); + for (uint64_t i = 0; i < n_tensors; ++i) { + TensorInfo t; + t.name = c.str(); + const uint32_t nd = c.read(); + if (nd == 0 || nd > 4) throw std::runtime_error("GGUF: bad n_dims for " + t.name); + t.shape.resize(nd); + for (uint32_t d = 0; d < nd; ++d) t.shape[d] = c.read(); + t.type = c.read(); + t.offset = c.read(); + tensors_.push_back(std::move(t)); + } + uint64_t align = 32; + if (const MetaValue* a = get("general.alignment")) + if (a->u) align = a->u; + alignment_ = align; + data_start_ = (c.pos() + align - 1) / align * align; + if (data_start_ > size_) throw std::runtime_error("GGUF: data section starts past EOF"); + } + + std::string path_; + const uint8_t* base_ = nullptr; + uint64_t size_ = 0, data_start_ = 0, alignment_ = 32; + uint32_t version_ = 0; +#ifdef _WIN32 + void* file_ = nullptr; + void* map_ = nullptr; +#else + int fd_ = -1; +#endif + std::vector tensors_; + std::map meta_; +}; + +// ---- architecture guard (P1.S2). The engine is specialised to ONE model; anything else must be +// refused with a precise error rather than silently mis-run. +struct Qwen4ExpGuard { + uint32_t block_count = 48, hidden = 2560, experts = 512, experts_used = 10, head_count = 24, + head_count_kv = 2; +}; + +inline std::string check_architecture(const GgufFile& g, const Qwen4ExpGuard& want = {}) { + const MetaValue* arch = g.get("general.architecture"); + if (!arch) return "missing general.architecture"; + if (arch->s != "qwen4exp") return "architecture is '" + arch->s + "', this engine requires 'qwen4exp'"; + struct Req { + const char* key; + uint64_t want; + }; + const Req reqs[] = { + {"qwen4exp.block_count", want.block_count}, + {"qwen4exp.embedding_length", want.hidden}, + {"qwen4exp.expert_count", want.experts}, + {"qwen4exp.expert_used_count", want.experts_used}, + {"qwen4exp.attention.head_count", want.head_count}, + {"qwen4exp.attention.head_count_kv", want.head_count_kv}, + }; + for (const auto& r : reqs) { + const MetaValue* v = g.get(r.key); + if (!v) return std::string("missing ") + r.key; + if (v->u != r.want) + return std::string(r.key) + " = " + std::to_string(v->u) + ", expected " + std::to_string(r.want); + } + return {}; // empty == ok +} + +} // namespace strata diff --git a/include/strata/core/device.hpp b/include/strata/core/device.hpp new file mode 100644 index 00000000..7a27e4f8 --- /dev/null +++ b/include/strata/core/device.hpp @@ -0,0 +1,71 @@ +// include/strata/core/device.hpp - P2.S1: the device arena and the runtime's device facts. +// +// `DeviceArena` is ONE cudaMalloc per planner region with bump sub-allocation below it and no frees. That is +// not a simplification for the first version: the memory plan from P1.S9 is fixed at startup, so the set of +// regions and their sizes is known before anything is allocated, and an allocator that can free would be +// solving a problem the engine does not have while adding fragmentation and failure modes it does. +// +// The reason to get this in early is that the VRAM budget is the binding constraint of the whole design +// (5.95 GB pooled between KV and the expert cache, 33.97 GB of experts in DRAM). A runtime that discovers at +// token 4000 that it has overcommitted has already lost; the plan is printed against `cudaMemGetInfo` at +// startup so the discrepancy is visible immediately. +#pragma once + +#include +#include +#include +#include + +namespace strata::core { + +struct DeviceInfo { + int ordinal = -1; + std::string name; + int cc_major = 0, cc_minor = 0; + uint64_t total_bytes = 0; // as reported by cudaMemGetInfo at query time + uint64_t free_bytes = 0; + int driver_version = 0, runtime_version = 0; + int multi_processor_count = 0; +}; + +// Throws when there is no CUDA device. The engine targets sm_120 specifically and must say so rather than +// run slowly on something else: `CMakeLists.txt` already refuses to COMPILE for another architecture, and +// this is the matching check at run time (a binary can be carried to a different machine). +DeviceInfo device_info(int ordinal = 0); + +class CudaError : public std::runtime_error { +public: + CudaError(const std::string& what, int code) : std::runtime_error(what), code_(code) {} + int code() const { return code_; } + +private: + int code_; +}; + +// One cudaMalloc, bump-allocated below. `poison` fills new allocations with a NaN-ish pattern in a debug +// build so that reading uninitialised VRAM gives a NaN rather than a plausible number - the same reasoning as +// the harness work in Phase 1: a wrong value that looks right is the expensive kind. +class DeviceArena { +public: + explicit DeviceArena(uint64_t bytes, int ordinal = 0, bool poison = false); + ~DeviceArena(); + DeviceArena(const DeviceArena&) = delete; + DeviceArena& operator=(const DeviceArena&) = delete; + + // `align` must be a power of two; 256 keeps every sub-allocation at a sector boundary. + void* alloc(uint64_t bytes, uint64_t align = 256); + + uint64_t capacity() const { return capacity_; } + uint64_t used() const { return used_; } + uint64_t peak() const { return used_; } // no frees, so used IS the peak + int ordinal() const { return ordinal_; } + void* base() const { return base_; } + +private: + void* base_ = nullptr; + uint64_t capacity_ = 0, used_ = 0; + int ordinal_ = 0; + bool poison_ = false; +}; + +} // namespace strata::core diff --git a/include/strata/core/expert_cache.hpp b/include/strata/core/expert_cache.hpp new file mode 100644 index 00000000..da856d75 --- /dev/null +++ b/include/strata/core/expert_cache.hpp @@ -0,0 +1,155 @@ +// include/strata/core/expert_cache.hpp - R4: the VRAM-resident expert tier. +// +// **WHY THIS EXISTS, IN ONE PARAGRAPH.** The CPU expert pool is the engine's largest single cost: measured, +// **663.6 MB of expert bytes per token at ~40 GB/s = 16.2 ms**, on a token of ~53 ms, and `test_expert_pool` +// says the pipeline hides **1.055 ms of 19.035** - the CPU work is 96% exposed because the residual chain is +// strictly serial. The only way past it is to stop reading those bytes: put some of the 48 x 512 experts in +// VRAM and let the GPU compute them, so the CPU reads only the misses. +// +// **THE DESIGN IS SIZED AGAINST MEASUREMENTS, NOT AGAINST THE PLAN'S ASSUMPTIONS.** +// +// * `h_expert` at 4,105 slots, **ten-fold leave-one-out: 0.6447 [0.6110, 0.6750]** (`tools/routing_kfold.py`). +// The 0.6573 the README published is in-sample and 0.4720 measured a single-prompt CORPUS, not the metric. +// * VRAM, measured on a live run rather than at startup: **6414 MiB free** with the engine at steady state, +// against **5413 MiB** for 4,105 slots. It fits, with ~1 GB spare. `strata-device`'s planner agrees and says +// FITS, but the planner runs before the dense arena exists, so the live figure is the one that counts. +// * `h_layer` held-out is **0.0456** - only 4.6% of (layer, token) pairs have all ten experts resident - so +// this cannot be a per-layer grouped kernel and the three-way split is not an optimisation. +// +// **WHAT THIS FILE IS AND IS NOT, TODAY.** It is the SLOT STORAGE and the RESIDENCY TABLE: it allocates the +// VRAM, fills it from the host arena, and answers `(layer, expert) -> slot or -1`. It does **not** yet compute +// anything - `moe_hit_grouped_s2` does not exist and the hit/miss split is not wired into the graph - so with +// the cache on and nothing consuming it, **the engine is slower by the fill cost and faster by nothing.** +// That is stated here rather than discovered from a benchmark, and it is why `--expert-cache` defaults to 0. +// +// The order of work is `Memory/R4-design-note.md` §7: slots and residency first, then the split, then the +// kernel. This is that first step, and the step it unblocks is the one that can be measured. +#pragma once + +#include +#include +#include + +namespace strata::core { + +/// Where one expert's VRAM lives, or -1. `int32_t` and not a bool because the slot index is what the kernel +/// needs, and a table that has to be re-derived from a bitmask is a table that will be re-derived differently +/// in the two places that use it. +inline constexpr int32_t kNotResident = -1; + +/// **THE STATIC RESIDENCY PLAN, READ BACK FROM THE FILE `tools/make_profile.py` WROTE.** +/// +/// `profile.bin` is `STRP`, then `version, n_layers, n_expert, slots, n_ranked`, then `n_ranked` `(layer, +/// expert)` pairs ranked by descending routing frequency, then a `n_layers x n_expert` frequency table. +/// +/// **WHY A PROFILE AND NOT THE COMPULSORY-MISS POLICY THIS ENGINE ALREADY HAD.** Compulsory-miss fills with +/// the first `S` DISTINCT experts the run happens to route, which on this workload is whatever the prompt +/// touched first - it measured `h = 0.4864`. A profile ranked by frequency over a whole trace is what the +/// plan's `0.6447` refers to, and the CPU's half of the drain is linear in `h`, so the gap is worth about +/// 4 ms of drain. The point of reading it from a file is that a profile built on ONE prompt can be scored on +/// ANOTHER, which is the only way to know whether it transfers. +/// +/// The frequency table is not returned: it is the profile's own working, and the engine measures `h` by +/// running rather than by re-deriving what the file claims. +bool read_expert_profile(const std::string& path, int64_t n_layers, int64_t n_expert, + std::vector>& ranked, int64_t& slots, std::string& err); + +class ExpertCache { +public: + ExpertCache() = default; + ~ExpertCache(); + ExpertCache(const ExpertCache&) = delete; + ExpertCache& operator=(const ExpertCache&) = delete; + + /// Allocates `n_slots * blob_bytes` of DEVICE memory and a `n_layers * n_expert` residency table, and + /// **CHECKS THE ALLOCATION AGAINST WHAT THE CARD ACTUALLY HAS** rather than assuming the planner was right. + /// A cache that silently allocates less than it was asked for would report a hit rate for slots it does + /// not have, which is the shape of error this project keeps paying for. + bool open(int64_t n_slots, int64_t n_layers, int64_t n_expert, int64_t blob_bytes, std::string& err); + /// Plan v0.3 P6: slots of the given sizes, back to back (a native pack's blobs differ per layer, and a + /// profile-filled tier never moves an expert to another layer's slot, so each slot keeps its first size). + bool open_sized(const std::vector& slot_bytes, int64_t n_layers, int64_t n_expert, std::string& err); + /// Byte offset of each slot in the arena (null for uniform slots). + const uint64_t* slot_offsets() const { return off_.empty() ? nullptr : off_.data(); } + void close(); + + bool valid() const { return base_ != nullptr; } + int64_t slots() const { return slots_; } + /// Slots actually claimed. Not the same as `slots()` - the cache does not evict, so a run that routes + /// fewer distinct experts than there are slots leaves the rest empty. + int64_t resident() const { return per_layer_ ? admitted_ : next_free_; } + int64_t bytes() const { return off_.empty() ? slots_ * blob_ : (int64_t) off_.back(); } + double gib() const { return (double) bytes() / 1073741824.0; } + + /// `(layer, expert)` -> slot index, or `kNotResident`. Bounds-checked: a bad layer or expert returns + /// `kNotResident` rather than reading whatever is adjacent in the table. + int32_t slot_of(int64_t layer, int64_t expert) const; + /// Claims the next free slot for `(layer, expert)`. Returns the slot, or `kNotResident` when the cache is + /// full - **it never evicts**, because eviction policy is a measured question (`R4.1`'s LFU-decay vs LRU + /// sweep) and a placeholder policy would set the hit rate that everything downstream is then sized against. + int32_t admit(int64_t layer, int64_t expert); + + /// **R4.2g: GIVE EACH LAYER ITS OWN SLOTS. ROUND 328 MEASURED WHY THE GLOBAL FORM CANNOT WORK.** + /// + /// `admit`'s original policy hands out slots in ARRIVAL ORDER from one counter shared by every layer + /// (`if (next_free_ >= slots_) return kNotResident;`). A position looks at 10 experts in each of 48 + /// layers, so the first `n_slots` distinct (layer, expert) pairs are **about 26 layers of POSITION 0** - + /// the cache fills entirely inside the first position it ever sees and never changes again, so hits are + /// confined to those few layers for the rest of the run. + /// + /// Measured, `--expert-cache 256`: **1781 of 60000 = 2.97%**. `Memory/cache_allocation.py` scores the + /// same run's routing and says **8 slots per layer is worth 21.4%** and 64 is worth **70.4%**. The cache + /// was never short of capacity - it was handing all of it to two layers. + /// + /// With this on, layer `l` may only use slots `[l*q, (l+1)*q)` where `q = slots()/n_layers`, the last + /// layer taking the remainder. **EVICTION IS STILL ABSENT** - the property the comment above calls + /// deliberate is preserved exactly; only WHICH slot a layer may take changes. Default off, so every + /// caller that predates this flag is bit-for-bit unaffected. + void set_per_layer_admission(bool on) { per_layer_ = on; } + bool per_layer_admission() const { return per_layer_; } + /// The slot range layer `l` may admit into under per-layer admission. Exposed so a test can check it. + void layer_slot_range(int64_t layer, int64_t& lo, int64_t& hi) const; + + /// The device address of one slot. + uint8_t* device_slot(int32_t slot); + const uint8_t* device_slot(int32_t slot) const; + + /// Copies `(layer, expert)`'s blob from `host_blob` into `slot` on `stream`. Asynchronous: the caller + /// orders it. Returns false if the indices are out of range rather than reading past the arena. + /// `bytes` (plan v0.3 P6): the blob's own size when it is smaller than the slot (a native pack); 0 = the slot. + bool fill_slot(int32_t slot, const uint8_t* host_blob, void* stream, std::string& err, int64_t bytes = 0); + + /// **THE SAME COPY, BUT BLOCKING, AND THE PROFILE FILL NEEDS IT.** `fill_slot` is asynchronous because on + /// the token path the fill and the kernel that reads it are ordered by one stream and waiting would be a + /// synchronisation per layer. At STARTUP there is no such ordering to lean on: the source is pageable host + /// memory, `verify_slot` reads the slot back on the LEGACY stream, and a legacy-stream copy is not ordered + /// against a `cudaStreamNonBlocking` one. The first version used the async form and `verify_slot` refused + /// the whole run with "slot 0 differs from the arena at byte 0" - which is the check doing its job. + bool fill_slot_blocking(int32_t slot, const uint8_t* host_blob, std::string& err, int64_t bytes = 0); + + /// Reads `slot` back to the host and compares it to `host_blob`, byte for byte. **THE ONLY THING THAT SAYS + /// THE CACHE HOLDS THE EXPERT IT CLAIMS TO.** A slot table that is right about indices and wrong about + /// bytes produces a plausible token, which is exactly the failure this project has paid for most often. + bool verify_slot(int32_t slot, const uint8_t* host_blob, std::string& err, int64_t bytes = 0); + + /// Slots filled so far, for the startup report. + int64_t fills() const { return fills_; } + +private: + uint8_t* base_ = nullptr; + std::vector residency_; ///< [n_layers * n_expert] -> slot or kNotResident + int64_t slots_ = 0; + int64_t n_layers_ = 0; + int64_t n_expert_ = 0; + int64_t blob_ = 0; + int64_t next_free_ = 0; + int64_t fills_ = 0; + /// R4.2g. `per_layer_` off (the default) leaves `next_free_` as the only admission counter, so the + /// pre-existing path is untouched. + bool per_layer_ = false; + std::vector layer_next_; ///< [n_layers] -> that layer's next free slot + std::vector off_; ///< plan v0.3 P6: slot offsets (slots + 1 entries) when sized + int64_t admitted_ = 0; +}; + +} // namespace strata::core diff --git a/include/strata/core/expert_source.hpp b/include/strata/core/expert_source.hpp new file mode 100644 index 00000000..c64bf4e7 --- /dev/null +++ b/include/strata/core/expert_source.hpp @@ -0,0 +1,354 @@ +// include/strata/core/expert_source.hpp - P2.S3/P2.S5: the expert pool's adapter to the host loop. +// +// `session_loop` publishes, per layer, the NORMED activation `x_f`, the ten routed expert ids and their router +// weights, and expects `k x n_embd` floats back. This is the piece that turns those four things into an +// `ExpertPool::run` call. Nothing here is clever, and that is the point: the pool, the kernel and the loop are +// each already verified, so this file has exactly one job - get the CONTRACT between them right. +// +// THE CONTRACT, and each clause is a way to be wrong: +// +// 1. **`x_f` IS THE SAME PINNED BUFFER EVERY LAYER.** Its ADDRESS never changes, so the `ActQ` cannot be +// cached by pointer - a cache keyed on `x_f` would quantize layer 0 and reuse it for all 47 remaining +// layers, which is a finite, plausible, completely wrong token. There is no cache here at all: the +// conversion is one 2560-element pass against a 0.3 ms/layer budget, and a correct answer is worth more +// than the microseconds. +// 2. **THE POOL DOES NOT APPLY THE ROUTER WEIGHT.** `moe_combine` (`src/core/layer.cpp:479`) sums +// `w[i] * parts[i]` on the device. `ExpertJob::weight` is a diagnostic field; setting it here would +// apply the weight twice, which is invisible in a single layer and compounds over 48. +// 3. **THE KERNEL'S GEOMETRY IS FIXED BY THE ARTIFACT** (`H = 2560`, `FF = 640`, `BLOB = 1,382,400`). A +// different `n_embd` or a different expert width is REFUSED rather than mis-indexed, because the blob's +// internal offsets are compile-time constants and reading a 640-wide expert as a 2560-wide one walks off +// the end into the next expert's bytes without faulting. +#pragma once + +#include "strata/core/expert_cache.hpp" +#include "strata/core/hit_hook.hpp" +#include "strata/kernels/cpu/pool.hpp" + +#include +#include +#include + +namespace strata::core { + +/// Where one routed expert's bytes come from. +/// +/// Phase 2 has NO cache (`phase-2-correct-engine.md`: hit rate `h = 0`), so the only implementation is a +/// file-backed reader. The interface exists anyway because Phase 3 replaces exactly this object with the VRAM +/// cache, and because a test can supply an in-memory source without a 34 GB artifact. +class ExpertSource { +public: + virtual ~ExpertSource() = default; + + /// The 1,382,400-byte blob for `(layer, expert)`, or nullptr if it cannot be produced. + /// + /// The pointer only has to stay valid until the next `blob()` call: with `h = 0` every expert is computed + /// immediately and nothing is retained. A CACHING source must return pointers into the cache, not into a + /// reused staging buffer - otherwise the pool would read the next expert's bytes while computing this one. + virtual const uint8_t* blob(int64_t layer, int64_t expert) = 0; + + /// Blobs touched, for the driver to report. A source that does not count returns 0. + virtual int64_t reads() const { return 0; } + + /// Plan v0.3 P5: whether `blob(layer, expert)` lies in page-locked, CUDA-registered memory, so an + /// asynchronous host-to-device copy can DMA it directly (no staging copy on the CPU). + virtual bool pinned(int64_t layer, int64_t expert) const { (void) layer; (void) expert; return false; } + + /// Called once before the first expert of a layer. A source that reads from disk wants to start the read + /// here so it overlaps the quantisation, and a prefetching source in Phase 3 wants the ids. + virtual void begin_layer(int64_t layer, const int32_t* ids, int64_t k) { (void) layer; (void) ids; (void) k; } + /// Plan v0.3 P6: the DEVICE address of a pinned, mapped blob (the GPU can read it over PCIe), or null. + virtual const uint8_t* device_alias(int64_t layer, int64_t expert) const { (void) layer; (void) expert; return nullptr; } +}; + +/// Plan v0.3 P6: what the GPU computes in a verify window's layer, written by the pool (mapped host memory) right +/// after the ring and published before the CPU starts its own share. Groups of entries that share a blob; the +/// blob is a VRAM slot or a pinned host blob read over PCIe. +struct GpuPlanSink { + int32_t* counts = nullptr; ///< [0] groups, [1] entries + int32_t* start = nullptr; ///< cap + 1 + int32_t* dst = nullptr; ///< cap: the entry's row of parts (token * k + j) + int32_t* tok = nullptr; ///< cap: the entry's token + unsigned long long* ptr = nullptr; ///< cap: the group's blob, device address + /// Plan v0.3 P6 (DMA): the PCIe share is a second list of groups - `ptr2[q]` is staging slot q, `start2` indexes + /// the same dst/tok entries - computed by the GPU after `fetch` has copied their blobs into staging with the + /// copy engine. counts[0] = VRAM groups, counts[1] = all entries, counts[2] = PCIe groups. + unsigned long long* ptr2 = nullptr; + int32_t* start2 = nullptr; + unsigned long long staging = 0; + int64_t staging_cap = 0; + int64_t cap = 0; + void (*publish)(void* ctx) = nullptr; + /// Starts the DMA copies of `n` host blobs (pinned) into staging slots 0..n-1 and signals the GPU when they land. + void (*fetch)(void* ctx, const uint8_t* const* src, int n, size_t bytes) = nullptr; + void* ctx = nullptr; + /// Plan v0.3 P6: how a PCIe group reaches the GPU. 0 = the copy engine stages it (DMA, `fetch`); 1 = the grouped + /// kernel reads the mapped arena directly; 2 = a copy kernel stages it inside the graph. For 1 and 2 `ptr2` + /// holds the arena's device alias. + int pcie_mode = 0; +}; + +/// The adapter's own state. One per session, reused every layer so the token path allocates nothing (P2.T10). +struct ExpertDispatch { + strata::kernels::cpu::ExpertPool* pool = nullptr; + ExpertSource* src = nullptr; + int64_t n_expert = strata::kernels::cpu::NE; + + /// Counters, for the driver to report rather than for control flow. + int64_t layers = 0; + int64_t experts = 0; + int64_t missing = 0; + + // ================================ R4: THE VRAM TIER, MEASURED BEFORE IT IS USED ================================ + // + // **THE CACHE IS CONSULTED AND FILLED HERE, AND NOTHING IS COMPUTED FROM IT YET.** That is deliberate and + // it is `R4-design-note.md` §7 step 2: the *dispatch* is measured on its own before any kernel is written, + // because a hit rate measured after the kernel exists cannot say whether a disappointing result was the + // policy, the split or the kernel. + // + // So every expert is still computed by the CPU, exactly as before, and the run is numerically identical + // with the cache on or off. What changes is that the engine now reports **h on its own routing, on a real + // workload** - which is the number `R4.1` asks for and which until now existed only from offline traces. + // + // The policy is compulsory-miss: the first time `(layer, expert)` is routed, if a slot is free it is + // admitted and filled from the arena. No eviction, because eviction policy is the measured question + // (`R4.1`'s LFU-decay vs LRU sweep) and a placeholder would set the hit rate everything is sized against. + ExpertCache* cache = nullptr; + int64_t cache_hits = 0; ///< lookups already resident + int64_t cache_admitted = 0; ///< lookups that took a slot + int64_t cache_refused = 0; ///< lookups with no slot free (the cache is full) + void* cache_stream = nullptr; + const char* cache_fail = nullptr; + + // ================================ R4.2c: THE HITS GO TO THE GPU ================================ + // + // **THE SPLIT IS BY ROUTER INDEX, AND THAT IS THE WHOLE TRICK.** A layer routes ten experts; the resident + // ones are computed on the GPU and the rest on the CPU, and both answers have to end up in `parts` at the + // index the ROUTER gave them, because that is the order `moe_combine` weights against. So the CPU's `out` + // rows are zeroed for the hits, and each hit carries its routed index to the kernel as `dst`. + // + // Everything below is per-session state rather than per-call, so the token path allocates nothing (P2.T10). + const uint8_t* cache_base = nullptr; ///< the slot arena on the DEVICE + int64_t cache_blob = 0; ///< bytes per slot + const uint64_t* cache_slot_off = nullptr; ///< plan v0.3 P6: per-slot offsets when the slots differ in size + void* hit_scratch = nullptr; ///< `moe_hit_grouped_scratch_bytes(K, ...)` + float* parts_out = nullptr; ///< the graph's `parts` buffer, on the device + /// Where the GPU's hits land, `K x n_embd`, DEVICE and separate from `parts_out` on purpose: see + /// `HitPhase`. Zeroed by the hit path each layer before the kernel writes it. + float* hit_out = nullptr; + int64_t parts_elems = 0; ///< `K * n_embd`, the length of both buffers + const float* mixed = nullptr; ///< the layer's normed activation, for the hit kernel's Q8_0 + uint8_t* x_q8_0_hit = nullptr; ///< `(n_embd/32) * 34` bytes, its own buffer + /// **R4.2h: THE CPU's fp32 ACTIVATION SCALES, `(n_embd/32)` FLOATS.** Without them the GPU's hits are + /// computed with the `block_q8_0`'s fp16 `d` while the CPU's misses use the fp32 `ActQ::scale` + /// (`cpu/expert.cpp:92`) - **4.761e-04 relative on 80 of 80 chunks**, measured with both real + /// implementations linked in `bench/micro/act_quant_parity.cu`. That disagreement is why turning the + /// cache on changed the generated tokens. Required whenever the hit path runs. + float* x_q8_0_hit_scale = nullptr; + int32_t* d_slot = nullptr; ///< device, K entries + int32_t* d_dst = nullptr; ///< device, K entries + std::vector h_slot, h_dst; ///< host staging, sized at session setup + /// **PER ROUTER INDEX, DECIDED IN `Launch` AND CONSUMED BY THE POOL.** The two callbacks share it + /// so the decision is made exactly once, on this layer's ids, and neither side can re-decide it. + std::vector is_hit; + bool decided = false; + /// Plan v0.3 P4 token graph: the STATIC residency table (`n_layers x n_expert`, slot or -1), the host's copy + /// of what the device hit path reads. When set, the pool leaves a resident expert's row at zero (the GPU + /// computes it) without any `Launch` callback. + const int32_t* host_res = nullptr; + /// Plan v0.3 P4: split every expert by rows across the pool's threads (default on; A/B `--no-split-rows`). + bool split_rows = true; + + // ================================ R4.2d: DID THE GPU ACTUALLY START? ================================ + // + // **THE HIT PATH IS 0.91 ms SLOWER AND THE ONLY EXPLANATION LEFT IS THAT IT NEVER OVERLAPS.** The kernel + // is 159 GB/s against the CPU's 35, the CPU's half of the drain falls 6 ms, and none of it reaches the + // token - which is what it would look like if the enqueued hit kernels did not BEGIN until the host next + // entered the driver, i.e. after `pool()` returned. That is the third sighting of this driver behaving + // that way (rounds 195/287 on the doorbell, round 36 on the head and sampler) and it decides whether R4 can + // pay at all, so it gets measured rather than argued. + // + // `hit_done` is recorded on the stream straight after the hit kernel. `Combine` - which runs after the + // pool - queries it: SUCCESS means the GPU finished while the CPU was working, NOT-READY means it had not. + // Same shape as the doorbell's `rings_mid_graph`, and for the same reason. + void* hit_done = nullptr; ///< cudaEvent_t, created at session setup + int64_t hit_ready = 0; ///< layers where the hit work was DONE by the time the pool returned + int64_t hit_late = 0; ///< layers where it was not + /// The candidate fix, as an A/B arm: enter the driver once right after the hit launch. If submission is + /// lazy, this starts the GPU work before the pool instead of after it. + bool hit_poke = false; + bool hit_cpu_order = false; ///< experimental CPU-order GPU expert arithmetic; opt-in only + int64_t n_hits = 0; ///< this layer's hits + /// Set by `Launch` and consumed by `Combine`, so a `Combine` with no `Launch` in front of it cannot + /// add a stale buffer into `parts`. + bool hit_pending = false; + const char* hit_fail = nullptr; + + /// Whether the hit path is wired up. All of it or none of it: a half-configured hit path would compute + /// some experts twice and others not at all, which is a wrong token rather than an error. + bool hits_ready() const { + return cache != nullptr && cache_base != nullptr && parts_out != nullptr && hit_out != nullptr && + mixed != nullptr && + hit_scratch != nullptr && x_q8_0_hit != nullptr && x_q8_0_hit_scale != nullptr && d_slot != nullptr && d_dst != nullptr; + } + + std::vector jobs; + strata::kernels::cpu::ActQ act; + /// Plan v0.3 P6 verify window: one quantized activation per token, the multi-token jobs, and the expert -> + /// job map (reset after every layer). + std::vector act_multi; + /// Plan v0.3 P6: a native pack's per-token activations (MAXT x kNativeActBytes). + std::vector nact_multi; + std::vector jobs_multi; + std::vector job_of; + /// Plan v0.3 P6: the verify window's GPU plan (VRAM hits + the PCIe share of the misses); `pcie_num`/256 of + /// each layer's distinct missed experts (the last ones in routing order) are read by the GPU over PCIe. + GpuPlanSink* plan = nullptr; + int pcie_num = 0; + int64_t pcie_experts = 0; ///< distinct experts the GPU read over PCIe in verify windows + double ms_plan = 0, ms_actq = 0, ms_jobs = 0, ms_run = 0; ///< verify-window dispatch sections + /// Plan v0.3 P6: decayed routing counts per (layer, expert) during decode (sized by the caller; empty = off), + /// which the driver uses to swap the most-routed missing experts into the VRAM tier between rounds. + std::vector usage; + int64_t multi_misses = 0; ///< distinct (layer, expert) pairs the CPU computed in verify windows + int64_t multi_entries = 0; ///< routed (token, expert) entries the CPU served in verify windows + /// Set when `dispatch` could not produce an answer. The loop itself has no error channel, so this is + /// where a source failure surfaces: the driver checks it after `session_loop` returns rather than the + /// engine computing from a half-filled `parts`. + /// + /// **`failed` LATCHES AND `fail` IS ONLY THE MESSAGE.** Clearing the message must not re-arm the adapter: + /// a session that failed at layer 5 has already fed `moe_combine` whatever `parts` held, so every layer + /// after it is built on a hole - and resuming into a plausible-looking token is the exact outcome this + /// whole mechanism exists to prevent. `failed` is what the short-circuit reads. + bool failed = false; + const char* fail = nullptr; + int64_t fail_layer = -1; + int64_t fail_expert = -1; +}; + +/// `strata::core::PoolFn`, exactly. Silent on failure BY SIGNATURE - see `ExpertDispatch::fail`. +void expert_pool_dispatch(void* user, const float* x_f, const int32_t* ids, const float* weights, int64_t n_embd, + int64_t k, float* out); + +/// Plan v0.3 P6: the pool for a verify window of `n_tok` tokens. `x_f` is (n_tok, n_embd), `ids` (n_tok, k) and +/// `out` (n_tok * k, n_embd). Each distinct missed expert is computed once for all the tokens routed to it; +/// resident experts' rows are zeroed (the GPU adds them). Requires `host_res` (the token-graph residency). +void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32_t* ids, int64_t n_tok, int64_t k, + float* out); + +/// **THE HITS, LAUNCHED AFTER THE MISSES ARE STAGED AND BEFORE `post[l]`.** Same shape as `PoolFn` and for the +/// same reason: `session_loop` owns the ORDER and this owns the work, so the loop needs to know nothing about +/// expert caches. Returns immediately when there are no hits, which is every layer until the cache is warm. +/// +/// It must run AFTER the host has copied the misses into `parts_dev` (it writes into the same buffer, on rows +/// the CPU zeroed) and BEFORE `post[l]` (which reads it). Both are stream-ordered on the loop's own stream. +void expert_hit_run(void* user, void* stream, HitPhase phase, const int32_t* ids, int64_t k); +/// The pool half of the same decision; see `ExpertDispatch::is_hit`. + +/// **PHASE 2'S ONLY SOURCE: `experts.bin`, memory-mapped, no cache.** +/// +/// `experts.bin` is 33,973,862,400 B and `BLOB` is 1,382,400, so it holds exactly `48 x 512 = 24,576` blobs and +/// the index is `layer * 512 + expert` with NO padding. A mapping is therefore the whole implementation: the +/// blob pointer is base plus a multiply, and the page fault that follows is the read. +/// +/// **AND THAT IS NOT AS SLOW AS IT SOUNDS, WHICH IS THE POINT.** The expert set is 34 GB and this machine has +/// 64 GB of DDR5, so a warm OS page cache holds ALL of it - the second token onward is a DRAM read at the +/// measured 44.14 GB/s, not a disk read. The first token's 663.6 MB comes off the disk and is the cold-path +/// number; `benches` should report the two separately rather than blending them. +/// +/// IT IS NOT AN LRU, AN LFU OR A PREFETCHER. Phase 3 replaces this object with those. Phase 2 is hit rate +/// `h = 0` on purpose, so that the CPU path and the host round trip are exercised on every layer of every +/// token - which is the only way they get debugged before speed matters. +class FileExpertSource : public ExpertSource { +public: + FileExpertSource() = default; + ~FileExpertSource() override; + FileExpertSource(const FileExpertSource&) = delete; + FileExpertSource& operator=(const FileExpertSource&) = delete; + + /// Maps `/experts.bin` and checks its size against `n_layers * n_expert * BLOB`. + /// + /// The size check is not a formality: a short file would fault at the END of a long sequence, and an + /// over-long one means the pack is not the one the geometry came from. Refuses with the two numbers. + bool open(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, std::string& err); + void close(); + + bool mapped() const { return base_ != nullptr; } + int64_t blobs() const { return blobs_; } + + const uint8_t* blob(int64_t layer, int64_t expert) override; + + /// Blobs touched, for the driver to report. With `h = 0` this is `48 * k` per token and the number is only + /// interesting once Phase 3 makes it not so. + int64_t reads() const override { return reads_; } + +private: + const uint8_t* base_ = nullptr; + int64_t blobs_ = 0; + int64_t n_expert_ = 0; + int64_t reads_ = 0; +#if defined(_WIN32) + void* file_ = nullptr; + void* mapping_ = nullptr; +#else + int fd_ = -1; +#endif +}; + +// ================================ THE RESIDENT ARENA (R2.1) ================================ +// +// **THE MMAP ABOVE IS THE REVIEW'S FINDING C1 AND IT IS WORTH 2.2x.** Read the comment on `FileExpertSource` +// about the page cache holding all 34 GB: that is true of the expert file ALONE, and it is not what this engine +// does. The PLE/n-gram shard is a 26.8 GB file that the engine also maps, so 34 GB of experts plus 26.8 GB of +// n-gram is 60.8 GB of mapped, file-backed pages on a 63 GB machine - and file-backed pages are exactly the ones +// the OS drops from the standby list when it wants memory. The expert stream then re-faults from disk. +// +// Measured, this round: the pool runs at **~19 GB/s in the engine against 42.8 GB/s in `bench/micro/cpu_s2.cpp` +// on the same machine, reading the same 34 GB**. The micro does `std::fread` into a heap arena and reads +// ordinary (anonymous, resident) memory; the engine reads `MapViewOfFile`. That is the whole difference, and it +// is why this class exists. +// +// It reads `experts.bin` into a `PinnedArena` once at startup, so the expert stream comes from anonymous memory +// the OS has no cheaper reason to evict. `PinnedArena` also tries `cudaHostRegister`, which the GPU needs for +// Phase 3's cache fills and the CPU/PCIe miss split - but registration is best-effort and reported, not assumed. +class ArenaExpertSource : public ExpertSource { +public: + ArenaExpertSource() = default; + ~ArenaExpertSource() override; + ArenaExpertSource(const ArenaExpertSource&) = delete; + ArenaExpertSource& operator=(const ArenaExpertSource&) = delete; + + /// Allocates and loads `/experts.bin`. Prints nothing; the caller reports `note()` and the load + /// rate, because those are the two numbers that say whether the arena is the one that was asked for. + bool open(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, int threads, std::string& err); + /// Plan v0.3 P6: a native pack without experts.bin takes its experts from the model's shard 1. + void set_gguf(const std::string& shard1) { gguf_ = shard1; } + void close(); + + bool mapped() const { return base_ != nullptr; } + int64_t blobs() const { return blobs_; } + const uint8_t* blob(int64_t layer, int64_t expert) override; + int64_t reads() const { return reads_; } + bool pinned(int64_t layer, int64_t expert) const override; + const uint8_t* device_alias(int64_t layer, int64_t expert) const override; + + /// What backing was obtained and why, for the startup print. "The engine adapts to the machine it is on" is + /// only true if the engine says what it got. + const std::string& note() const { return note_; } + double load_gib_per_second() const { return gib_per_s_; } + +private: + void* arena_ = nullptr; ///< the PinnedArena, owned + std::vector dev_slice_; ///< device alias of each registered slice (or of the whole range) + uint64_t slice_bytes_ = 0; + const uint8_t* base_ = nullptr; + int64_t blobs_ = 0; + int64_t n_expert_ = 0; + int64_t reads_ = 0; + std::string note_; + double gib_per_s_ = 0.0; + uint64_t pinned_bytes_ = 0; + std::string gguf_; +}; + +} // namespace strata::core diff --git a/include/strata/core/graph.hpp b/include/strata/core/graph.hpp new file mode 100644 index 00000000..369bfe3f --- /dev/null +++ b/include/strata/core/graph.hpp @@ -0,0 +1,117 @@ +// include/strata/core/graph.hpp - P2.S5: the GraphRegistry. +// +// `GraphRegistry`: capture/replay wrappers keyed by `(layer_type, n_tokens)`, with static input/output +// buffers at fixed addresses. +// +// THREE THINGS ARE BAKED INTO THIS INTERFACE, and each one is a measurement rather than a preference. All +// three come from `bench/micro/graph_capture.cu`, which was written to answer the phase's own instruction - +// "verify on the installed CUDA version with a minimal test before building on it". +// +// 1. **THE WAIT MUST POLL THE DRIVER, NOT MEMORY.** The phase's host loop says "spin on +// doorbell_seq == expected ... no cudaStreamSynchronize anywhere". A spin that only reads memory NEVER +// RUNS THE KERNEL: 5,907,703 spins over 500 ms, and the datum flips to 1 the instant anything calls into +// the driver and never before. On Windows the display driver model BATCHES command submission, and a +// thread that only reads memory gives it no reason to flush. Hence `wait_ms`, which polls an event - +// a QUERY and not a blocking sync, so P2.X3's "zero synchronization calls in the layer loop" still holds, +// and it costs 0.021 ms. +// +// 2. **A GRAPH RE-READS ITS INPUT BUFFERS AT REPLAY, BUT NOT ITS KERNEL ARGUMENTS.** Verified: eight +// replays with a different pinned input each time all tracked correctly, so the data path is live; but a +// kernel argument is copied into the node at capture and is never re-read. **Anything that changes per +// step - a position, a page-table base, a token id - must be DATA in a device buffer, never an argument.** +// This is the phase's "graph capture with pointers that change between steps silently replays stale +// addresses" hazard, and it now has a measurement behind it. +// +// 3. **FIXED ADDRESSES ARE THE CALLER'S OBLIGATION.** The registry cannot check them. Every buffer a +// captured body touches must be allocated before `record` and never reallocated; the registry holds the +// graph, not the memory. +// +// What the registry is FOR: a layer's ops are the same every token, so recording them once and replaying +// removes the per-call host cost of submitting them individually. Measured, that cost is 10-22 us PER CALL +// into the driver on this machine, and a layer with 8 submissions is ~0.1 ms of round trip. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace strata::core { + +/// The two attention families. `docs/semantics.md`: 36 GDN layers and 12 QSA, at indices 3, 7, ... 47. +enum class LayerType : int { GDN = 0, QSA = 1 }; +inline const char* to_string(LayerType t) { return t == LayerType::GDN ? "GDN" : "QSA"; } + +/// One recorded graph. Move-only: it owns a `cudaGraphExec_t` and an event, and two owners would +/// double-destroy. +class CapturedGraph { +public: + CapturedGraph() = default; + ~CapturedGraph() { reset(); } + CapturedGraph(const CapturedGraph&) = delete; + CapturedGraph& operator=(const CapturedGraph&) = delete; + CapturedGraph(CapturedGraph&& o) noexcept { *this = std::move(o); } + CapturedGraph& operator=(CapturedGraph&& o) noexcept; + + /// Start recording everything the caller launches into `stream`. + bool begin(void* stream, std::string& err); + + /// Stop recording, instantiate, and record the completion event. A capture that produced ZERO nodes is + /// reported as an error: it means the body did nothing, which is a wiring mistake and NOT an empty graph + /// to be replayed happily forever. + bool end(void* stream, std::string& err); + + /// Replay. Does not synchronise. + bool launch(void* stream, std::string& err) const; + + /// The host loop's wait: poll `done_` until it completes or `timeout_ms` elapses. Returns false on + /// timeout, which the caller must treat as a hang and not as "probably fine". See NOTE 1 above for why + /// this exists instead of a memory spin. + bool wait_ms(int timeout_ms) const; + + size_t nodes() const { return nodes_; } + bool valid() const { return exec_ != nullptr; } + +private: + void reset(); + cudaGraph_t graph_ = nullptr; + cudaGraphExec_t exec_ = nullptr; + cudaEvent_t done_ = nullptr; + size_t nodes_ = 0; +}; + +/// Capture-once, replay-many, keyed by `(layer_type, n_tokens)`. +/// +/// `record` is given a CALLABLE that launches the layer's ops. On the first call for a key it captures; +/// afterwards it returns the cached graph and does NOT run the callable - so a caller that mutates state +/// inside the body will be surprised, which is why the body must only launch. +class GraphRegistry { +public: + explicit GraphRegistry(void* stream) : stream_(stream) {} + + /// Capture `body` for `key` if it is not already recorded. Returns false and fills `err` on failure. + /// Recording the same key twice is a no-op and is NOT an error - that is the point of a registry, and a + /// regression test asserts that the body runs exactly once across many calls. + bool record(LayerType type, int n_tokens, const std::function& body, std::string& err); + + const CapturedGraph* find(LayerType type, int n_tokens) const; + + /// Launch and wait, in that order, the two things the host loop does per layer. + bool launch(LayerType type, int n_tokens, int timeout_ms, std::string& err) const; + + size_t size() const { return graphs_.size(); } + /// How many times a BODY was actually captured. With a working cache this equals `size()`. + size_t captures() const { return captures_; } + +private: + using Key = std::pair; + void* stream_ = nullptr; + std::map graphs_; + size_t captures_ = 0; +}; + +} // namespace strata::core diff --git a/include/strata/core/hit_hook.hpp b/include/strata/core/hit_hook.hpp new file mode 100644 index 00000000..8c21035c --- /dev/null +++ b/include/strata/core/hit_hook.hpp @@ -0,0 +1,41 @@ +// include/strata/core/hit_hook.hpp - R4.2c: the seam between the host loop and the VRAM expert tier. +// +// **IT IS ITS OWN HEADER BECAUSE BOTH SIDES NEED IT AND NEITHER SHOULD OWN IT.** `session.hpp` declares the +// loop that calls the hook; `expert_source.hpp` declares the implementation that answers it. Putting the type +// in either one makes the other include a header it has no other use for, and the loop knowing what an expert +// cache is (or the adapter knowing what a graph is) is exactly the coupling this seam exists to avoid. +#pragma once + +#include + +namespace strata::core { + +/// **TWO PHASES, AND THE SPLIT IS THE WHOLE POINT.** `Launch` runs BEFORE the CPU pool and `Combine` runs +/// AFTER the misses have been copied into `parts`. That ordering is what lets the GPU's resident experts run +/// at the same time as the CPU's misses instead of after them: +/// +/// ring -> hits(Launch) [GPU: quantize + grouped experts into `hit_out`] +/// -> pool() [CPU: the misses, CONCURRENT with the GPU] +/// -> H2D misses into `parts` +/// -> hits(Combine) [GPU: parts += hit_out] +/// -> post[l] +/// +/// **MEASURED, AND WHY IT IS NOT THE OBVIOUS ORDER.** The first version called the hit path once, after the +/// pool, writing straight into `parts`. The drain duly fell **18.2 -> 10.2 ms** - the CPU really was doing +/// half the experts - and **the token did not move at all**, because the GPU's half had simply been moved onto +/// the critical path behind the CPU's instead of beside it. A second buffer and one `add_inplace` buy the +/// overlap back. +enum class HitPhase { Launch, Combine }; + +/// `ids` AND `k` ARE THE ROUTER'S OWN OUTPUT, AND THEY ARE WHY THIS HOOK TAKES ARGUMENTS AT ALL. +/// +/// The first version took only `(user, stream, phase)` and left the hit/miss decision inside the pool +/// callback - which runs AFTER `Launch`. So `Launch` read the hit list from the PREVIOUS layer and computed +/// the previous layer's experts into the current layer's rows. It produced a token, the timings looked +/// plausible, and **C1 moved from mean KL 9.69e-02 to 1.03e+00 and top-1 from 0.867 to 0.333.** The decision +/// has to happen in `Launch`, on the ids the doorbell published for THIS layer, and the pool then consumes it. +/// +/// A null `HitFn` means "no VRAM tier" - the default, and what every caller had before R4. +using HitFn = void (*)(void* user, void* stream, HitPhase phase, const int32_t* ids, int64_t k); + +} // namespace strata::core diff --git a/include/strata/core/layer.hpp b/include/strata/core/layer.hpp new file mode 100644 index 00000000..031a076a --- /dev/null +++ b/include/strata/core/layer.hpp @@ -0,0 +1,566 @@ +// 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/ngram.hpp" +#include "strata/kernels/ple.hpp" + +#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; + int32_t* page_table = nullptr; ///< (n_pages,) logical page -> physical page + int64_t n_pages = 0; + int64_t max_cells = 0; + + 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 + 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); +/// 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); +bool qsa_kv_int8(); +uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaState& st, + const QsaState* share_rope = nullptr); +/// 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 diff --git a/include/strata/core/layout.hpp b/include/strata/core/layout.hpp new file mode 100644 index 00000000..7b27fec9 --- /dev/null +++ b/include/strata/core/layout.hpp @@ -0,0 +1,97 @@ +// include/strata/core/layout.hpp - the pack's LAYOUT, resolved by name and checked against the kernels. +// +// `WeightTable` answers "where are the bytes". This answers "is this the tensor I think it is", and it is +// the layer where a pack and a kernel disagree - which is the failure this project keeps paying for. Two +// examples, both real: +// +// * round 194 sized the indexer key store from `indexer.head_count = 4`, which counts QUERY heads. The +// cached key is ONE shared head of 128 (`indexer.k_proj` is [2560, 128]), so the term was 4x too big and +// the error survived a round because it moved in the direction that TIGHTENS the budget. +// * `docs/semantics.md` and the tensor manifest agree on every dimension here, but nothing CHECKED that a +// named tensor had the shape the kernel reading it assumes. +// +// So this header does two things: it names the tensors per layer type, and it asserts every shape the +// kernels depend on. A mismatch is reported with the tensor name, the shape found and the shape required, +// at LOAD time - not as a wrong number inside a GEMV at token 4000. +#pragma once + +#include "strata/core/weights.hpp" + +#include +#include + +namespace strata::core { + +/// The model's geometry, taken from `docs/semantics.md` and the artifact's own metadata. Every field here +/// is a number a kernel depends on, so a change is a change to a kernel contract and not a tuning knob. +struct ModelGeometry { + int64_t n_embd = 2560; + int64_t n_layers = 48; + int64_t qsa_interval = 4; ///< every 4th layer is full attention: layers 3, 7, ... 47 + + // GDN (36 layers) + int64_t ssm_state_size = 128; + int64_t ssm_k_heads = 16; + int64_t ssm_v_heads = 48; + int64_t ssm_d_conv = 4; + int64_t ssm_conv_channels = 10240; ///< 2*128*16 + 128*48 + int64_t ssm_value_dim = 6144; ///< 128 * 48 + + // QSA (12 layers) + int64_t n_head = 24; + int64_t n_head_kv = 2; + int64_t head_dim = 256; + int64_t idx_q_heads = 4; + int64_t idx_key_dim = 128; + + // gated residual, on every layer + int64_t hc = 4; + int64_t hc_lr = 320; + + // MoE, on every layer + int64_t n_expert = 512; + int64_t n_ff = 640; + + int64_t hc_dim() const { return hc * n_embd; } + /// `layer % qsa_interval == qsa_interval - 1` is full attention. Derived, not a second list. + int64_t n_qsa_layers() const { return n_layers / qsa_interval; } + int64_t n_gdn_layers() const { return n_layers - n_qsa_layers(); } +}; + +/// True for the full-attention layers. `docs/semantics.md` gives this twice over - `full_attention_interval +/// = 4` and an explicit `attention.compress_ratios` array - and this is the first of the two. +inline bool is_qsa_layer(const ModelGeometry& g, int64_t layer) { + return layer % g.qsa_interval == g.qsa_interval - 1; +} + +/// One layer's tensors, resolved by NAME. `get("attn_qkv.weight")` looks up `blk..attn_qkv.weight` +/// and returns null if the pack does not have it - a null is information, because a GDN layer has no +/// `attn_q` and a QSA layer has no `attn_qkv`. +/// +/// Holds a reference to the table; the table must outlive it. +class LayerView { +public: + LayerView(const WeightTable& table, int64_t layer) : table_(&table), layer_(layer) {} + + int64_t layer() const { return layer_; } + std::string name(const char* suffix) const; + const WeightRef* get(const char* suffix) const { return table_->find(name(suffix)); } + +private: + const WeightTable* table_; + int64_t layer_; +}; + +/// Every shape the kernels depend on, asserted for ONE layer. Returns false and fills `err` with the first +/// mismatch, naming the tensor, what it has and what is required. +/// +/// This is deliberately a separate function from `LayerView`: a caller that only wants the pointers should +/// not pay for the checks, and a caller that wants the checks should get ALL of them rather than the ones +/// its own call site happens to touch. +bool check_layer(const WeightTable& table, const ModelGeometry& g, int64_t layer, std::string& err); + +/// `check_layer` over every layer, plus the cross-layer properties: exactly 12 QSA layers at the right +/// indices, every GDN layer having the GDN set and no QSA tensor, and vice versa. Returns the first failure. +bool check_all(const WeightTable& table, const ModelGeometry& g, std::string& err); + +} // namespace strata::core diff --git a/include/strata/core/mtp.hpp b/include/strata/core/mtp.hpp new file mode 100644 index 00000000..ce2ad113 --- /dev/null +++ b/include/strata/core/mtp.hpp @@ -0,0 +1,135 @@ +// include/strata/core/mtp.hpp - plan v0.3 P6: the MTP draft layer on the GPU. +// +// The model ships one multi-token-prediction layer (vLLM 0.30.0 `qwen4_exp` MTP; transcribed and validated in +// `tools/mtp_probe.py`, where it accepts 0.89 / 0.86 / 0.85 of greedy drafts at steps 1-3). Cell i of its +// sequence pairs the main model's final multi-stream residual at position i with the token at position i+1, at +// rope position i (vLLM's convention); its output predicts the token at position i+2, and its own residual feeds +// the next draft step. +// +// e = fc_embedding(rms(embed(tok)) * (1+w)) h = fc_hidden per stream(rms_10240(R) * (1+w)) +// R = h + e -> attention hyper-connection -> QSA attention (own K/V) -> MLP hyper-connection -> +// MoE (512 experts, top-10, shared expert) -> R += y * 2 sigmoid(inject / hc) -> final mixer -> head +// +// Runtime choices, each a trade of exactness for simplicity that only affects DRAFT quality, never the output +// (the verify window decides every emitted token): +// * the large projections run as Q8_0 through the multi-column MMVQ (`tools/mtp_rt.py` quantizes them); +// * the attention is DENSE over every cell the layer has seen - identical to the model's sparse selection +// below 2,051 cells - so speculative cells (draft steps, rejected window rows) never touch indexer state and +// are simply overwritten when their positions are processed again; +// * all 512 routed experts live in VRAM (708 MB) and run through the grouped hit kernels. +#pragma once + +#include "strata/core/layer.hpp" +#include "strata/core/session.hpp" + +#include + +#include +#include +#include + +namespace strata::core { + +class NativeHead; + +class MtpDrafter { +public: + MtpDrafter() = default; + ~MtpDrafter(); + MtpDrafter(const MtpDrafter&) = delete; + MtpDrafter& operator=(const MtpDrafter&) = delete; + + /// Loads `rt_dir` (from tools/mtp_rt.py) and allocates the layer's K/V and buffers for up to `max_t` rows. + /// Call before the VRAM expert tier is sized: this takes ~0.9 GB. + bool load(const std::string& rt_dir, const ModelGeometry& g, SessionState& ss, int max_t, std::string& err, + int64_t window = 32768); + /// The prompt's length: prefill() skips the cells the attention window can never reach again. + void set_prompt_len(int64_t n) { prompt_len_ = n; } + uint64_t vram_bytes() const { return vram_; } + /// The main model's embedding and head, and the verify window's final residuals (T rows, hc*n_embd each). + bool bind(const WeightTable& wt, const NativeHead* head, const float* window_R, std::string& err); + + /// Prompt cells [cell0, cell0 + n): residual rows `R_rows` (device, hc*n_embd each) and `next_tokens` (host, + /// the token at position cell+1). Runs in batches of up to max_t rows. + bool prefill(const float* R_rows, const int32_t* next_tokens, int64_t n, int64_t cell0, std::string& err); + + /// One round: catch-up over T cells from `p` (rows = the window's final residuals, `tokens` = the window's + /// argmaxes: row t pairs R_{p+t} with the token at p+t+1), then the draft chain from row `a` (the last + /// accepted row) for T-1 drafts at cells p+a+1 ... `drafts` gets T-1 tokens. + bool draft(int T, const int32_t* tokens, int64_t p, int a, int32_t* drafts, std::string& err, + float* probs = nullptr, float min_p = 0.0f, int* n_drafts = nullptr); + + /// The first round: one cell (`cell`) from `R_row` (device) and `token` -> T-1 drafts. + bool draft_first(int T, const float* R_row, int32_t token, int64_t cell, int32_t* drafts, std::string& err, + float* probs = nullptr, float min_p = 0.0f, int* n_drafts = nullptr); + + double ms_draft = 0, ms_prefill = 0; + int64_t rounds = 0; + +private: + bool record_forward(int T, int step_row0, cudaStream_t cs, std::string& err); + bool capture_prefill(int T, std::string& err); + bool capture_round(int T, std::string& err); + bool capture_step(int j, std::string& err); + cudaGraphExec_t step_exec_[9] = {}; + const float* f32(const char* name) const; + const uint16_t* bf16(const char* name) const; + const void* q8(const char* name) const; + + const ModelGeometry* g_ = nullptr; + SessionState* ss_ = nullptr; + const WeightTable* wt_ = nullptr; + const NativeHead* head_ = nullptr; + const float* window_R_ = nullptr; + int max_t_ = 0; + int64_t n_vocab_ = 0; + uint64_t vram_ = 0; + cudaStream_t cs_ = nullptr; + cudaGraphExec_t prefill_exec_[9] = {}; + cudaGraphExec_t round_exec_[9] = {}; + + struct Tensor { std::string name, kind; int64_t rows = 0, cols = 0; uint64_t off = 0, bytes = 0; }; + std::vector tensors_; + uint8_t* dense_ = nullptr; + uint8_t* experts_ = nullptr; + void* state_arena_ = nullptr; + QsaState st_; + void* arena_ = nullptr; + + // mapped staging: tokens, step records (2*max_t rows), positions per head (2*max_t rows), the selected row, + // the drafts out + int32_t *h_tok_ = nullptr, *m_tok_ = nullptr, *h_step_ = nullptr, *m_step_ = nullptr; + int32_t *h_pos_ = nullptr, *m_pos_ = nullptr, *h_row_ = nullptr, *m_row_ = nullptr; + int32_t *h_out_ = nullptr, *m_out_ = nullptr; + float *h_prob_ = nullptr, *m_prob_ = nullptr; // each draft's probability under the draft layer + // the draft head: the main head's rows for a token subset (rt/draft_vocab.bin), or the whole head + uint8_t* dhead_ = nullptr; + int32_t* dvocab_ = nullptr; + int64_t n_dvocab_ = 0; + std::string rt_dir_; + int64_t window_ = 0; // attention over the last window_ cells (0 = every cell) + int64_t prompt_len_ = 0; + float* probs_ = nullptr; + // device + int32_t *tok_ = nullptr, *step_ = nullptr, *pos_ = nullptr, *row_ = nullptr, *ident_ = nullptr; + float *Rin_ = nullptr, *R_ = nullptr, *emb_ = nullptr, *en_ = nullptr, *e2_ = nullptr, *hn_ = nullptr, *h2_ = nullptr; + float *mixed_ = nullptr, *inj_ = nullptr, *inj2_ = nullptr, *lo_ = nullptr, *rs_ = nullptr, *bo_ = nullptr; + float* xn_ = nullptr; + uint8_t* xq_ = nullptr; + float *qfull_ = nullptr, *qcur_ = nullptr, *kcur_ = nullptr, *vcur_ = nullptr, *attn_ = nullptr, *attn32_ = nullptr; + float* attn_scratch_ = nullptr; + float *logits_ = nullptr, *w_ = nullptr, *shared_ = nullptr, *parts_ = nullptr, *y_ = nullptr, *sample_ = nullptr; + int32_t *ids_ = nullptr, *hit_slot_ = nullptr, *hit_dst_ = nullptr, *hit_count_ = nullptr, *out_ids_ = nullptr; + uint8_t* hit_xq_ = nullptr; + unsigned long long* grp_ptr_ = nullptr; + int32_t *grp_start_ = nullptr, *grp_counts_ = nullptr; + float* hit_xs_ = nullptr; + void* hit_scratch_ = nullptr; + float* sh_scratch_ = nullptr; + uint16_t* x_bf16_ = nullptr; + float* head_logits_ = nullptr; + float* dummy_inj_ = nullptr; + int64_t cap_ = 0, attn_scratch_floats_ = 0; +}; + +} // namespace strata::core diff --git a/include/strata/core/native_dense.hpp b/include/strata/core/native_dense.hpp new file mode 100644 index 00000000..1d41216d --- /dev/null +++ b/include/strata/core/native_dense.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +namespace strata::core { +class WeightTable; + +// Experimental GDN/QSA/shared-expert projection overrides. Upload unchanged native GGUF +// blocks once, then attach them to the matching canonical WeightRef. Unsupported +// types retain their canonical paths. Owns one Q8_1 scratch vector shared by all +// these projections, so use one ordered session stream and keep this object +// alive until all graphs that reference it have been destroyed and synchronized. +class NativeDense { +public: + NativeDense() = default; + ~NativeDense(); + NativeDense(const NativeDense&) = delete; + NativeDense& operator=(const NativeDense&) = delete; + bool load(const std::vector& shards, WeightTable& table, std::string& err, + bool include_ple_key = false); + /// Plan v0.3 P1: the canonical tensor names `load` would serve natively from these shards (eligible name, + /// supported type, 2-D), read from the GGUF headers only - so the canonical arena can skip them. + static bool served_names(const std::vector& shards, bool include_ple_key, + std::set& out, std::string& err); + uint64_t weight_bytes() const { return bytes_; } + size_t tensor_count() const { return weights_.size(); } + +private: + std::vector weights_; + void* scratch_ = nullptr; + uint64_t bytes_ = 0; +}; +} // namespace strata::core diff --git a/include/strata/core/native_head.hpp b/include/strata/core/native_head.hpp new file mode 100644 index 00000000..22b4c8db --- /dev/null +++ b/include/strata/core/native_head.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +namespace strata::core { + +/// Opt-in native output projection. Uploads unchanged GGUF blocks once at startup and owns its +/// activation scratch. The normal canonical head remains available for numerical A/B comparisons. +/// Plan v0.3 P6: any type the native MMVQ takes (Q5_K in the Q2_0 / IQ3_XXS files, IQ4_XS in IQ2_XS). +class NativeHead { +public: + NativeHead() = default; + ~NativeHead(); + NativeHead(const NativeHead&) = delete; + NativeHead& operator=(const NativeHead&) = delete; + + bool load(const std::string& gguf, int64_t n_in, int64_t n_out, std::string& err); + bool run(const float* mixed, float* logits, void* stream, std::string& err) const; + uint64_t weight_bytes() const { return bytes_; } + bool loaded() const { return weights_ != nullptr; } + /// Plan v0.3 P6: the GGUF blocks, for the verify window's multi-column head. + const void* weights() const { return weights_; } + int type() const { return type_; } + /// Bytes of one vocabulary row. + size_t row_bytes() const { return n_out_ > 0 ? (size_t) (bytes_ / (uint64_t) n_out_) : 0; } + +private: + void* weights_ = nullptr; + void* scratch_ = nullptr; + uint64_t bytes_ = 0; + int n_in_ = 0, n_out_ = 0; + int type_ = -1; +}; + +/// Plan v0.3 P6: `token_embd.weight` in its GGUF form (the IQ model files), in mapped pinned host memory: a row +/// is read over PCIe per token, so the table costs no VRAM. `embed_row`, the verify window and the MTP drafter +/// use it instead of the canonical table when it is set (`set_native_embed`). +class NativeEmbed { +public: + NativeEmbed() = default; + ~NativeEmbed(); + NativeEmbed(const NativeEmbed&) = delete; + NativeEmbed& operator=(const NativeEmbed&) = delete; + bool load(const std::string& gguf, int64_t n_embd, int64_t n_vocab, std::string& err); + /// Rows for device token ids. + void gather_dev(const int32_t* tokens, int64_t n_tok, float* out, void* stream) const; + /// One row for a host token id. + void gather_one(int64_t token, float* out, void* stream) const; + uint64_t bytes() const { return bytes_; } + int type() const { return type_; } + +private: + void* host_ = nullptr; + const void* dev_ = nullptr; + uint64_t bytes_ = 0; + size_t row_ = 0; + int64_t n_embd_ = 0, n_vocab_ = 0; + int type_ = -1; +}; +void set_native_embed(const NativeEmbed* e); +const NativeEmbed* native_embed(); + +} // namespace strata::core diff --git a/include/strata/core/pinned.hpp b/include/strata/core/pinned.hpp new file mode 100644 index 00000000..af704691 --- /dev/null +++ b/include/strata/core/pinned.hpp @@ -0,0 +1,95 @@ +// include/strata/core/pinned.hpp - P2.S1: the host arena the expert weights live in. +// +// 33.97 GB of expert weights cannot fit in a 12 GB card, so they stay in host memory and are streamed. That +// makes this arena the engine's real working set: it must be PAGE-LOCKED for the copy engine to reach full +// bandwidth, and it must say which backing it got, because the two options differ by more than twice in TLB +// reach: +// +// * LARGE PAGES (2 MB) - `mmap(MAP_HUGETLB)` / hugetlbfs on Linux, `VirtualAlloc(MEM_LARGE_PAGES)` on +// Windows. 33.97 GB at 4 KB pages is 8.3 million TLB entries, which does not fit in any TLB, so every +// block of every expert matvec takes TLB misses. +// * NORMAL PAGES - the fallback. Correct, slower, and it must be REPORTED rather than silently accepted: +// "the engine adapts to the machine it is on" is only true if the engine says what it got. On Windows +// large pages additionally need SeLockMemoryPrivilege, which a normal user account does not have, so this +// fallback is the common case and not an error path. +#pragma once + +#include +#include +#include + +namespace strata::core { + +enum class PageBacking { LargePages, NormalPages, PinnedByCuda }; + +struct PinnedArena { + void* base = nullptr; + uint64_t capacity = 0; + PageBacking backing = PageBacking::NormalPages; + std::string note; // why the backing is what it is, for the startup print + uint64_t locked_bytes = 0; // resident via the working-set lock when CUDA could not pin it + /// Plan v0.3 P5: when the whole arena cannot be registered, it is registered in `slice`-byte pieces from the + /// start; this is the pinned prefix (a copy that stays inside one slice can then DMA straight from the arena). + uint64_t registered_bytes = 0; + uint64_t slice_bytes = 0; + int registered_slices = 0; + + PinnedArena() = default; + /// `slice`: the piece size for the per-slice registration fallback (0 = none). + explicit PinnedArena(uint64_t bytes, uint64_t slice = 0); + /// Plan v0.3 P6: slices of different sizes (one per layer of a native pack), given as their start offsets + /// followed by the end of the last one. `slice_starts` holds the registered ones. + PinnedArena(uint64_t bytes, const std::vector& bounds); + std::vector slice_starts; + ~PinnedArena(); + PinnedArena(const PinnedArena&) = delete; + PinnedArena& operator=(const PinnedArena&) = delete; + + bool valid() const { return base != nullptr; } + uint8_t* data() const { return (uint8_t*) base; } +}; + +struct LoadStats { + double seconds = 0.0; + uint64_t bytes = 0; + uint64_t layers = 0; + std::vector layer_checksums; // one FNV-1a per layer + double gib_per_second() const { return seconds > 0 ? (double) bytes / (1024.0 * 1024 * 1024) / seconds : 0.0; } +}; + +// Load `layers` layers of the expert arena into `dst` with `threads` readers, `chunk` bytes at a time. +// Each thread opens its OWN handle and seeks, which is the portable form of parallel pread: a shared handle +// needs a lock around the seek and defeats the parallelism on Windows. +LoadStats load_experts(const std::string& path, uint8_t* dst, uint64_t blob_bytes, uint64_t blobs_per_layer, + uint64_t layers, int threads, uint64_t chunk); +/// Plan v0.3 P6: the same with one byte range per layer (`layer_off[L]`, `layer_bytes[L]`). +LoadStats load_experts_ranges(const std::string& path, uint8_t* dst, const std::vector& layer_off, + const std::vector& layer_bytes, int threads, uint64_t chunk); + +// FNV-1a 64. Per layer, so a corrupt or short read names WHICH layer rather than just failing a whole-file +// comparison - the same reason the Phase 1 tools report the first differing element. +uint64_t fnv1a64(const uint8_t* p, uint64_t n, uint64_t seed = 1469598103934665603ull); + +// ---- the expert stream ----------------------------------------------------- +// +// THIS IS THE NUMBER THE OBJECTIVE DEPENDS ON. With one expert per stream, the design's own arithmetic is: +// +// per token: E = 663.6 MB of expert weights, of which only the MISSES cross the bus +// miss fraction (1 - h) x 663.6 MB / measured bandwidth = milliseconds per token +// +// So `h` (the VRAM hit rate, ~0.167 by design) is only worth anything if a per-expert transfer can actually +// reach the bus's bandwidth. A 1,382,400-byte copy is SMALL, and if small copies run at a fraction of large +// ones then the architecture's central constant is wrong and no kernel optimisation can fix it. That is what +// this measures: the same total bytes at several granularities, so the difference is visible. +struct StreamStats { + double seconds = 0.0; + uint64_t bytes = 0; + uint64_t chunk = 0; + double gib_per_second() const { return seconds > 0 ? (double) bytes / (1024.0 * 1024 * 1024) / seconds : 0.0; } +}; + +// Copy `bytes` from pinned host memory at `src` to a device buffer of `chunk` bytes, in `chunk`-sized +// transfers, `iters` times. The destination is REUSED, so this measures the bus and not allocation. +StreamStats stream_bandwidth(const uint8_t* src, uint64_t bytes, uint64_t chunk, int iters); + +} // namespace strata::core diff --git a/include/strata/core/session.hpp b/include/strata/core/session.hpp new file mode 100644 index 00000000..e9c39ced --- /dev/null +++ b/include/strata/core/session.hpp @@ -0,0 +1,411 @@ +// include/strata/core/session.hpp - ONE TOKEN through all 48 layers, and the doorbell protocol between them. +// +// P2.S5's host loop, and the first place the engine runs a whole forward pass rather than one block. What it +// owns is the ORDER across layers and the state that outlives a layer: +// +// * the gated residual stack `R` - (hc, n_embd), which every block reads and writes; +// * 12 QSA states (KV cache + indexer) and 36 GDN states (recurrence + conv history), because `is_qsa_layer` +// splits the 48 layers 12/36 and the two kinds of state have nothing in common; +// * the per-token expert handoff, which is where the CPU pool meets the GPU. +// +// WHY THE LAYER GRAPHS ARE CAPTURED AND THE LOOP IS NOT. A graph bakes in its arguments, so the graph unit is +// ONE LAYER: everything per-token (the position, the step counts) arrives through fixed-address device buffers +// (rounds 211-214), and the HOST decides when each layer runs. That decision is the whole point - it is what +// lets the CPU expert pool start while the GPU is still working on the next layer. +#pragma once + +#include "strata/core/hit_hook.hpp" +#include "strata/core/layer.hpp" + +#include + +#include +#include +#include + +namespace strata::core { + +/// Everything a sequence needs that is NOT a weight: the per-layer state, the block scratch, and the pinned +/// handoff between the GPU and the CPU expert pool. +struct SessionState { + int64_t max_cells = 0; + + GdnBuffers gdn; ///< the 36 GDN layers share one set of scratch; their STATE is per layer + float* gdn_state = nullptr; ///< (n_gdn_layers, gdn_state_floats) + + QsaState* qsa_states = nullptr; ///< one per QSA layer + void* qsa_state_arena = nullptr; + QsaBuffers qsa_bufs; ///< scratch, shared across the 12 (they never run concurrently) + void* qsa_buf_arena = nullptr; + + MoEBuffers moe; + void* moe_arena = nullptr; + BlockBuffers block; + void* block_arena = nullptr; + + float* R = nullptr; ///< alias of `block.R`, named for what it means at this level + int64_t k = 10; ///< experts per token + /// The doorbell the host loop polls. Null until a caller provides one - the engine runs without it, and + /// neither `session_token` nor `session_replay` looks at it. + const Doorbell* db = nullptr; + + // ================================ THE PLE, AND IT IS SEQUENCE STATE ================================ + // + // **THE PLE IS A LAYER-1 MODULE AND THIS ENGINE DID NOT RUN IT, WHICH IS WHY C1 FAILED** (LEDGER L123). + // Every part of it was already built and parity-tested - 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 it was ever called from the model path. These fields are what + // was missing on the state side. + // + // `ple_prev` is the last two tokens OLDEST FIRST with `-1` where there is none, which is exactly + // `ngram_rows`' `prev` argument; `ple_token` is the token being decoded. **They are advanced by the + // DRIVER, once per token**, because the hash is a function of the sequence and not of a layer. + int32_t ple_prev[2] = {-1, -1}; + int32_t ple_token = -1; + /// `NG_HIST` rows of `hc_dim` NORMALIZED history, **ROW-FASTEST**: `ple_hist[row + NG_HIST*channel]`. + /// That is `ggml_reshape_3d(state, d_conv-1, conv_channels, n_seqs)`, not a layout choice - the transposed + /// reading fails ggml's own assert (`ple_layer_xcheck.cpp` L79-83). + float* ple_hist = nullptr; + /// **THE WHOLE PLE RUN LIVES HERE, AND THAT IS NOT A CONVENIENCE.** Its history and its token window are + /// sequence state, so the session is where they belong - and putting the rest of it here too means + /// `session_capture`/`session_loop`/`session_token` need no new parameter to reach it. A `PleRun` whose + /// `ready()` is false is skipped, which is what every caller had before LEDGER L123. + PleRun ple; +}; + +/// Bytes for a whole session at `max_cells` of context. Every layer's state is sized at once, because P2.T10 +/// requires ZERO token-path allocations - a `cudaMalloc` that happened on the first token of a longer sequence +/// would satisfy every test here and fail that one. +uint64_t session_bytes(const ModelGeometry& g, int64_t max_cells, int64_t k); +/// Carves `base` (DEVICE memory) into `s`. Returns the bytes used. +uint64_t session_init(const ModelGeometry& g, int64_t max_cells, int64_t k, void* base, SessionState& s); +/// Zeroes every layer's state - the residual to `R_init`, everything else to zero, so a fresh sequence starts +/// from the reference's own `zeros()`. +void session_zero(SessionState& s, const ModelGeometry& g, const float* R_init, void* stream); + +/// One token: layers 0..47 in order, each a `block_layer`, and the residual is updated in place. +/// +/// `parts` is (k, n_embd) DEVICE memory, filled by the caller - by the CPU expert pool and the VRAM-resident +/// experts. In this phase `moe_layer` consumes it directly; the doorbell protocol's job is to let it be +/// produced DURING the layer rather than before it, which is a change to *when* the host runs, not to what this +/// function computes. +/// +/// `sync_every_layer` inserts a device synchronisation and an error check after EVERY layer. That is the +/// `--sync-every-layer` debug mode, and it is deliberately a parameter rather than a compile-time choice: the +/// errors it catches (a kernel reading a buffer that a later layer writes) are invisible otherwise, and the +/// synchronisation it costs is exactly what P2.X3 forbids in the fast path. +bool session_token(const WeightTable& tables, const ModelGeometry& g, int64_t pos, int32_t pos_base, + SessionState& s, const float* parts, void* stream, bool sync_every_layer, + std::string& err); + +// ================================ the captured form ================================ + +/// ONE GRAPH PER LAYER, because a graph bakes in its POINTERS and every layer has different weights AND +/// different state. That is not a limitation to work around - it is what makes the per-layer graph the right +/// unit: the host decides when each layer runs, which is what lets the CPU expert pool start while the GPU is +/// still working on the next one. +/// +/// WHY THIS IS WORTH DOING, MEASURED: **the engine is launch-bound, not bandwidth-bound.** Every kernel in +/// `gdn_layer` measured ~0.028 ms - including `gdn_gate`, which processes FORTY-EIGHT elements, and a kernel +/// cannot execute for 28 us. That is what a null-stream launch costs on this driver, and `gdn_layer` makes ~19 +/// of them while a block makes ~45. Replaying the 43-node block graph measured **1.585 ms against 2.393 ms** +/// for the same work as direct launches - a 1.51x that is pure launch overhead. +struct SessionGraphs { + cudaGraphExec_t* execs = nullptr; ///< one per layer, in layer order + int64_t n = 0; + /// **THE SECOND GRAPH PER LAYER: `moe_finish` + `gr_write`, launched AFTER the host has run the pool.** + /// + /// Without it the loop has to hand layer `l`'s expert vectors to layer `l+1`, which multiplies them by + /// layer `l+1`'s router weights - see `layer.hpp`'s note on `moe_route`. With it, `post[l]` is launched + /// after the pool returns, so layer `l`'s experts are combined with layer `l`'s weights. + /// + /// The cost is the overlap, and it is worth stating: 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 the CPU term is answered by Phase 3's VRAM cache and not by this pipeline. + cudaGraphExec_t* posts = nullptr; + bool captured = false; + /// THE DEVICE ADDRESS THE GRAPHS WERE CAPTURED WITH. The host loop copies each layer's expert outputs + /// here before launching the next layer, and a graph bakes the POINTER, so it has to be this one. + float* parts_dev = nullptr; + + /// **DIAGNOSTIC, AND IT IS THE ONE THAT SAYS WHETHER THE DOORBELL IS DECORATIVE.** + /// + /// Counts the layers where the ring was observed while the graph was STILL RUNNING - i.e. the layers where + /// the pool actually had a window to work in. A nonzero `h_seq` read at the same moment + /// `cudaEventQuery` returns `cudaSuccess` means the layer was already over, and the pool then runs with the + /// GPU IDLE, which makes the whole pipeline equivalent to a serialised one. + /// + /// That distinction is invisible in the loop's RETURN VALUE - both paths produce a correct token - and it + /// is invisible in a per-layer timing, because the layer still takes the same time either way. It shows up + /// only as "the overlap hides nothing", which is why it is counted here. + int64_t rings_mid_graph = 0; + + /// **THE WINDOW, MEASURED RATHER THAN ASSUMED.** Milliseconds from `cudaGraphLaunch` to the moment the + /// ring was observed, summed over the loop. The pool's window is `layer period - this`, and that is the + /// entire budget the overlap has: if the CPU's per-layer work exceeds it, the GPU goes idle for the + /// difference on every layer and a "pipelined" loop measures the same as a serialised one. + /// + /// It is NOT the same as the doorbell's position inside the graph. It includes the launch itself, the + /// driver's submission, and however long the host took to notice - all of which are real and none of which + /// the pool gets to use. + double ms_to_ring = 0; + + // ================================ R0.9: THE LAYER AS THREE GRAPHS ================================ + // + // **THE CAPTURED PER-STAGE TABLE THE PROTOCOL HAS DEMANDED SINCE R0 AND NOBODY HAS HAD.** Round 309's + // came from `--no-capture`, and round 312 showed its `moe_finish` figure was a host-gap artefact four + // times the captured cost of the same work - so the plan has been choosing between R3.3, R3.4 and R3.5 + // without knowing what any of them is worth. + // + // An event recorded BETWEEN two `cudaGraphLaunch` calls is not inside a capture and is therefore valid, + // so the layer is captured at its own documented seam (`block_layer_pre`'s `half`) and timed from outside. + /// **R0.11: FIVE PREFIX GRAPHS, ADDED BESIDE `preA`/`preB` RATHER THAN REPLACING THEM.** + /// `preP[k]` (k = 1..5) runs stages `0..k-1`. Consecutive differences are the per-stage times, which is + /// what prices the two `gr_read`s and the `gr_write` (R3.3) against GDN's interior (R3.4). A prefix and not + /// a range, because the stages are a chain: stage 1 reads what stage 0 wrote and stage 2 mutates the + /// residual in place. + /// + /// **THE FIFTH PREFIX USED TO BE `execs[l]`, AND THAT WAS THE BUG ROUND 324 COULD NOT FIND.** Prefix 5 + /// has ~7 more nodes than prefix 4, so the two differ in launch setup as well as in execution - and the + /// difference attributed ALL of it to stage 4, the router. Capturing prefix 5 the same way as the others + /// makes the two comparable and the difference purely stage 4's. + cudaGraphExec_t* preP[5] = {nullptr, nullptr, nullptr, nullptr, nullptr}; + cudaGraphExec_t* preA = nullptr; ///< `half == 1`: gr_read -> attention -> gr_write (the MIXER) + cudaGraphExec_t* preB = nullptr; ///< `half == 2`: gr_read -> moe_route (FFN front + ROUTER) + bool split_captured = false; + + /// How many times `session_loop` has run. `rings_mid_graph` and `ms_to_ring` are CUMULATIVE and the loop + /// is called once per token, so anything reporting a ratio needs this - and prefill runs the loop too, so + /// "per generated token" is the wrong denominator. + int64_t calls_total = 0; + + /// **WHERE THE PER-LAYER ROUND TRIP GOES, AND `ms_to_ring` ALONE CANNOT SAY.** + /// + /// `--no-pool` measures **38.73 ms/token against a 26.32 ms pure-GPU floor**, so ~12.4 ms/token - a quarter + /// of the token - is spent in the loop with no expert work at all: 0.258 ms per layer. `ms_to_ring` covers + /// only the first half of that; it stops when the ring is seen, and everything the host then does before + /// the next `pre` is launched is invisible to it. + /// + /// So the round trip is split at the ring, and **`ms_to_ring` above IS the first half** - do not add a + /// second counter for it: + /// + /// * `ms_to_ring` - launch -> ring observed. The GPU is working (or already finished) here. + /// * `ms_host` - ring observed -> `pre[l+1]` launched. **The GPU has nothing queued and is IDLE for + /// all of this unless `pre[l]`'s tail still covers it.** This is + /// `cudaMemcpyAsync(parts)` + `cudaGraphLaunch(post[l])` + `cudaGraphLaunch(pre[l+1])` + + /// `cudaEventRecord`, and if it is the large half then the fix is fewer driver calls + /// (R2.4), not a faster kernel. + /// + /// Cumulative, like `ms_to_ring`; divide by `calls_total * n_layers`. + double ms_host = 0; +}; + +/// Captures all 48 layer graphs. Idempotent: a second call is a no-op. +/// +/// `pos`/`pos_base` are baked in ONLY through the fixed-address pinned staging in each `QsaState`, which the +/// replay re-reads every token - so the position is data, not an argument. That is the whole reason rounds +/// 211-214 moved the per-token counts into device buffers. +bool session_capture(const WeightTable& tables, const ModelGeometry& g, SessionState& s, const float* parts, + SessionGraphs& gr, std::string& err, bool split = false); + +/// One token by REPLAYING the captured graphs. Identical arithmetic to `session_token`; the only difference is +/// that ~2,000 kernel launches become 48 graph launches. +bool session_replay(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, SessionGraphs& gr, + void* stream, std::string& err); + +/// One token by replaying BOTH halves of every layer - `pre[l]` and `post[l]` - with no pool running. +/// +/// **THIS IS THE TRUE PER-TOKEN GPU FLOOR, AND `session_replay` IS NOT.** `session_replay` launches only +/// `gr.execs[l]`, the `pre` graphs, so it omits the 48 `post` graphs (shared expert, combine, second `gr_write`) +/// entirely - and everything published as "39.8 ms pure GPU" came from it while being described as the whole +/// GPU. See `Memory/ERRORS.md` A4. +/// +/// `parts` is left at whatever the buffer holds, because no pool writes it; that is deliberate and makes the +/// measurement pure GPU work. The LM head is NOT included here - the caller times it separately, since it runs +/// on the host-side path rather than inside a captured graph. +bool session_replay_full(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, std::string& err); + +/// **ONE TOKEN THROUGH THE SPLIT GRAPHS, WITH EVERY STAGE TIMED FROM OUTSIDE THE CAPTURE.** Requires +/// `session_capture(..., split = true)`. Replays `preA[l]`, `preB[l]`, `post[l]` in order on one stream - +/// the same order `session_loop` uses - and reports the three totals in milliseconds for the whole token. +/// +/// The stages are ordered by the stream, so an event pair around each launch measures that launch's GPU work +/// plus the gap the driver left before it. That gap is real and is why the three totals sum slightly above +/// `session_replay_full`; the comparison is printed by the caller rather than hidden here. +/// **R0.11: THE FIVE STAGES SEPARATELY, BY DIFFERENCING PREFIXES.** `stage_ms` is resized to 5 and holds +/// stages 0..4 in milliseconds for a whole token; `mixer_per_layer` holds prefix 3's time for each layer, which +/// is what R0.10's GDN-versus-QSA split needs. Requires `session_capture(..., split = true)`. +/// +/// **THE RESIDUAL IS SAVED AND RESTORED BETWEEN PREFIXES.** Stage 2 is `gr_write`, which mutates `R` in +/// place, so without the restore every prefix after the first reads a residual the earlier ones already +/// advanced - and every number after the first would be measuring a different tensor while looking correct. +bool session_replay_stage_prefixes(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, std::vector& stage_ms, + std::vector& mixer_per_layer, std::string& err); + +/// **R3.5c: ONE PREFIX, ALL 48 LAYERS, BACK TO BACK.** `k` in 1..5 replays `preP[k-1][l]` (or `execs[l]` for +/// k = 5) for every layer with no other graph in between, and reports the whole sweep in milliseconds. +/// +/// **WHY THIS EXISTS: TO RULE OUT THE PREFIX TABLE'S OWN BIAS.** The five-stage table differences prefix +/// times measured with FIVE DIFFERENT GRAPHS LAUNCHED PER LAYER. Stage 4's interval therefore contains the +/// launch of `execs[l]` - the full five-stage graph - while stage 3's contains `preP[3][l]`, a four-stage one. +/// A launch cost that scales with graph size would inflate stage 4 specifically, and stage 4 is the router, +/// which is the thing under investigation. This sweep launches ONE graph type per layer, so there is nothing +/// to switch between and nothing to attribute wrongly. +/// +/// Five separate sweeps give five totals; their consecutive differences are the stage times on the same +/// arithmetic as before, and **if the two methods disagree the difference is the bias and the sweep is right.** +bool session_replay_stage_sweep(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, int k, double& ms, std::string& err); + +bool session_replay_stages(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, double& ms_mixer, double& ms_ffn, double& ms_post, + std::string& err); + +/// **THE SAME REPLAY, KEEPING EVERY LAYER INSTEAD OF SUMMING THEM.** +/// +/// The 48 layers are not 48 of the same thing: 36 are GDN and 12 are QSA, and the mixer - 55.8% of the GPU +/// floor - contains both. A total cannot separate them; a per-layer vector can, and it is already being +/// measured. `out` is resized to `n_layers` and holds each layer's mixer time in milliseconds. +/// +/// **THIS IS THE CHEAPEST HALF OF R0.10 AND IT NEEDS NO NEW SPLIT.** Round 309's uncaptured table put GDN at +/// 10.88 ms for 36 layers and QSA at 4.56 for 12, which would make the recurrence the largest single R3 +/// target; that table also had `moe_finish` wrong by 4x, so the ratio is worth re-deriving from the captured +/// graph rather than inherited. +bool session_replay_stages_per_layer(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, std::vector& mixer_per_layer, + double& ms_ffn, double& ms_post, std::string& err); + +/// Releases the graph execs. The `SessionGraphs` struct holds no device memory of its own. +void session_graphs_free(SessionGraphs& gr); + +// ================================ THE HOST LOOP ================================ + +/// WHAT THE POOL HAS TO DO, as the loop sees it: given the `x_f` a layer published and the miss list it +/// selected, produce the `k` expert outputs into `out` (k x n_embd, HOST memory). A function pointer rather +/// than a virtual class because the loop is the thing under test and the pool is the thing being faked. +using PoolFn = void (*)(void* user, const float* x_f, const int32_t* ids, const float* weights, int64_t n_embd, + int64_t k, float* out); + +/// **R4.2c: THE VRAM-RESIDENT HALF OF THE LAYER'S EXPERTS.** Called by the loop after the misses have been +/// staged into `parts` and before `post[l]` is launched, so its kernels are stream-ordered between them: the +/// misses are already in `parts`, the hits overwrite the rows the CPU zeroed, and `post[l]`'s `moe_combine` +/// reads the sum and cannot tell which engine produced which row. +/// +/// **WHY IT IS A CALLBACK RATHER THAN MORE PARAMETERS.** `session_loop` owns the ORDER across layers and has +/// no business knowing what an expert cache is. Everything the hit path needs lives on the caller's `user`, +/// and this has the same shape as `PoolFn` for the same reason. +/// +/// A null pointer means "no VRAM tier" - the default, and what every caller had before R4. +// The hit hook's type lives in its own header - see `hit_hook.hpp` for why and for the ordering it wants. + +/// `phases/phase-2-correct-engine.md` P2.S5's host loop, which is the point of everything above it: +/// +/// launch G[l] -> poll the doorbell -> read the miss list -> dispatch the pool -> wait for it -> +/// write y_miss -> launch G[l+1] +/// +/// **NO `cudaStreamSynchronize` IN THE LOOP**, which is P2.X3. Waiting is `cudaEventQuery` on an event recorded +/// right after the launch - a QUERY, and it is not optional: round 195 measured a host spin that only read +/// memory never running the kernel at all, 5,907,703 spins over 500 ms with the datum flipping only when +/// `cudaStreamSynchronize` was called. +/// +/// **THE OVERLAP IS THE ONE-LAYER DELAY.** A layer publishes `x_f` at ~71% of its own duration, so the pool has +/// the remaining 29% of that layer to work in; its result is consumed by the NEXT layer, which is why the +/// combine is not waiting on it. `overlap = false` runs the same sequence with a synchronisation after every +/// layer, which is the comparison that says whether the pipeline is worth its complexity - and it is a +/// parameter rather than a second function so that the two cannot drift. +/// +/// **`dump_layers`, WHEN NOT NULL, IS THE C1 ORACLE AND IT IS A HOST BUFFER OF `(n_layers + 1) * hc * n_embd` +/// FLOATS.** Slot 0 is the residual the function was ENTERED with - the token's embedding broadcast to every +/// stream, which is the reference's `hc_init` node - and slot `l + 1` is `s.R` after layer `l`, which is the +/// reference's `l_last-`. Each is enqueued as a device-to-host copy, and the final `cudaStreamSynchronize` +/// at the end of the loop is what makes every one of them complete. **NO EXTRA SYNCHRONISATION IS INSERTED**, +/// which is the whole reason this is written as an enqueue rather than a read: `pre[l+1]` does not write `R` and +/// `post[l+1]` is launched after the copy on the same stream, so FIFO order alone makes slot `l + 1` a true +/// snapshot. It costs 40 KB per layer per token, and it is the only way to bisect the engine against the +/// reference layer by layer instead of against one end-to-end perplexity. +/// Per-token scratch that MUST NOT be reallocated per token (P2.T10, review finding H3). +/// +/// **`session_loop` IS CALLED ONCE PER TOKEN, SO ANYTHING IT ALLOCATES IS A PER-TOKEN ALLOCATION.** It used to +/// allocate three things on every token of every run: +/// +/// * `cudaHostAlloc` for the pool's pinned staging, freed by an RAII destructor at the end of the call - and +/// `cudaFreeHost` IMPLICITLY SYNCHRONISES THE DEVICE, so every token ended with a device sync; +/// * `cudaEventCreate` / `cudaEventDestroy` for the doorbell probe; +/// * a host-affinity change (`physical_cores` + pin + restore). +/// +/// A comment in the old code said "allocated once", but once per token is not once. +/// +/// Own one of these at session setup and pass it in. **Passing `nullptr` keeps the old behaviour** - the loop +/// allocates and frees locally - so every existing caller and test is unaffected, but the allocation is then +/// visible at the call site instead of hidden inside a hot function. +struct SessionLoopScratch { + float* y_miss = nullptr; ///< pinned host staging for the pool's answer, `parts_bytes` long + size_t parts_bytes = 0; + cudaEvent_t probe = nullptr; + long long pinned_core = -1; ///< the affinity to restore, or -1 if the host was never pinned + bool pinned = false; + + /// Allocates the buffers and pins the host thread. Call ONCE, at session setup. + bool init(size_t parts_bytes_in, std::string& err); + /// Frees everything and restores the affinity. Safe to call twice, or on a default-constructed object. + void free(); +}; + +bool session_loop(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, SessionGraphs& gr, + PoolFn pool, HitFn hits, void* user, bool overlap, void* stream, std::string& err, + float* dump_layers = nullptr, SessionLoopScratch* scratch = nullptr); + +// ================================ plan v0.3 P3: THE WHOLE TOKEN AS ONE GRAPH ================================ +// +// Under WDDM every graph launch costs ~0.3-0.4 ms of submission latency, and the per-layer loop above makes 96 +// of them per token: measured 48.6 ms/token for 12 ms of GPU kernels and 27 ms of pool. The token graph holds +// all 48 layers. Between `pre[l]` (which rings the doorbell) and the parts copy, a one-thread kernel waits ON +// THE DEVICE until the host writes the ring value into `db.h_flag`; the host only polls rings, runs the pool +// and writes flags, with no driver call in the loop (bench/micro/device_wait.cu: 5.2 us per handoff). +// +// for l: pre[l] (.. router, doorbell ring, shared expert) -> doorbell_wait -> parts <- y_miss (H2D) +// -> post[l] (combine, gr_write) +// +// Same kernels, same order per layer as `session_loop`, so the token is bitwise the same. No VRAM expert tier +// yet (the hit decision is a host step between ring and post); the caller falls back to `session_loop` then. +struct TokenGraph { + cudaGraphExec_t exec = nullptr; + bool captured = false; + int64_t n_layers = 0; + const float* y_src = nullptr; ///< the pinned host staging the graph's H2D copies read (baked in) + size_t parts_bytes = 0; + int64_t calls = 0; + double ms_wait = 0; ///< host waiting for rings (the GPU is running) + double ms_pool = 0; ///< host inside the pool + int64_t flushes = 0; ///< cudaStreamQuery calls made because a ring was slow to appear +}; + +/// Plan v0.3 P4: the VRAM expert tier inside the token graph. Residency is STATIC during a token (a +/// `n_layers x n_expert` table, slot or -1, on the device; the host pool reads its own copy), so the hit list +/// is built on the device right after the ring and the hits run while the CPU computes the misses. +struct TokenHits { + const int32_t* d_res = nullptr; ///< device [n_layers * n_expert] + int64_t n_expert = 0; + const uint8_t* cache_base = nullptr; ///< slot 0 of the VRAM expert arena + int64_t blob = 0; ///< bytes per slot + int32_t* d_slot = nullptr; ///< device, k entries + int32_t* d_dst = nullptr; ///< device, k entries + int32_t* d_count = nullptr; ///< device, 1 + uint8_t* x_q8 = nullptr; ///< the activation's q8_0 blocks, (n_embd/32)*34 B + float* x_scale = nullptr; ///< and its fp32 scales (the CPU pool's contract) + void* scratch = nullptr; ///< moe_hit_grouped_scratch_bytes(k, ...) + float* hit_out = nullptr; ///< device, k * n_embd + bool on() const { return d_res && cache_base && d_slot && d_dst && d_count && x_q8 && x_scale && scratch && hit_out; } +}; + +bool session_capture_token(const WeightTable& tables, const ModelGeometry& g, SessionState& s, float* parts_dev, + const float* y_miss_host, size_t parts_bytes, TokenGraph& tg, std::string& err, + const TokenHits* hits = nullptr); +bool session_run_token(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, TokenGraph& tg, + PoolFn pool, void* user, float* y_miss_host, void* stream, std::string& err); +void token_graph_free(TokenGraph& tg); + +} // namespace strata::core diff --git a/include/strata/core/verify.hpp b/include/strata/core/verify.hpp new file mode 100644 index 00000000..384ccc08 --- /dev/null +++ b/include/strata/core/verify.hpp @@ -0,0 +1,160 @@ +// include/strata/core/verify.hpp - plan v0.3 P6: the speculative VERIFY window. +// +// T tokens at consecutive positions p0 .. p0+T-1 - the last accepted token and T-1 drafts - go through all 48 +// layers in ONE captured graph, and the head's argmax is produced for every one of them. Token t's argmax is +// what plain greedy decode would produce after token t, BIT FOR BIT: every kernel here is either the single-token +// kernel applied per token, or a multi-token kernel whose per-token arithmetic is the single-token kernel's +// (multi-column MMVQ in exact mode, the T-token GDN kernels, the per-token hit activation, the multi-token CPU +// expert rows). So a draft is accepted exactly when greedy decode would have produced it. +// +// What the window costs is the dense weights read ONCE for T tokens and the union of the T tokens' missed +// experts on the CPU (measured on decode traces: 1.75x one token's misses for T=2, 2.4x for 3, 3.05x for 4). +// +// STATE. The window appends K/V and indexer keys for all T positions and leaves the GDN state untouched. +// `commit(n_keep)` then makes the first `n_keep` tokens permanent: the GDN conv history and recurrent state are +// advanced by replaying those tokens from inputs the window stored, the indexer's key tail is restored from a +// snapshot and the accepted keys re-appended (a rejected key can land in a slot the current block still needs), +// and the PLE history is set to its snapshot after token n_keep-1. K/V cells past the accepted prefix are simply +// overwritten when those positions are processed again, before any query can read them. +// +// Requires the default native decode configuration (native projections, fused GR, fused GDN, fast attention and +// selection, native indexer) and a profile-filled VRAM expert tier with its residency table on the device. +#pragma once + +#include "strata/core/expert_source.hpp" +#include "strata/core/layer.hpp" +#include "strata/core/session.hpp" + +#include + +#include +#include + +namespace strata::core { + +class NativeHead; + +/// The CPU pool for a window: x_f (n_tok, n_embd), ids (n_tok, k) -> out (n_tok * k, n_embd), hit rows zeroed. +using PoolMultiFn = void (*)(void* user, const float* x_f, const int32_t* ids, int64_t n_tok, int64_t k, float* out, + int64_t layer); + +struct VerifyHits { + const int32_t* d_res = nullptr; ///< device [n_layers * n_expert] slot or -1 + const uint8_t* cache_base = nullptr; ///< slot 0 of the VRAM expert arena + int64_t blob = 0; +}; + +class Verifier { +public: + Verifier() = default; + ~Verifier(); + Verifier(const Verifier&) = delete; + Verifier& operator=(const Verifier&) = delete; + + /// `max_t` <= kVerifyMaxT. `head` may be null (the canonical head is then run per token). + bool init(const WeightTable& wt, const ModelGeometry& g, SessionState& ss, const VerifyHits& hits, + const NativeHead* head, int max_t, std::string& err); + + /// One window: `tokens[0..T)` at positions pos0.., the pool served per layer; `out[t]` = argmax after token t. + /// The PLE rows are gathered here from `ss.ple_prev` and the tokens. Captures the T-token graph on first use. + bool run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool, void* user, int32_t* out, std::string& err); + + /// Keep the first `n_keep` (1..T) tokens of the last window; advances `ss.ple_prev` by them. + bool commit(int n_keep, std::string& err); + + /// Token t's residual after the last layer, (hc, n_embd) on the device, valid until the next `run`. + const float* final_R(int t) const; + const float* final_R_all() const { return R_; } + + /// The GPU plan the pool writes each layer (VRAM hits + the PCIe share of the misses); give it to the + /// dispatch (`ExpertDispatch::plan`) before the first `run`. + GpuPlanSink* plan_sink() { return &sink_; } + /// Plan v0.3 P6: split the window into two token groups and pipeline the CPU experts of one with the GPU work + /// of the other (default on). Set before the first `run`. + void set_split(bool on) { split_ = on; } + /// Plan v0.3 P6: how the PCIe share of the misses reaches the GPU: 0 = DMA into staging (the copy engine works + /// beside the CPU; best when the CPU is compute-bound, the i-quants), 1 = the grouped kernel reads the mapped + /// arena directly, 2 = a copy kernel stages it inside the graph (no API calls on the pool's thread; best when + /// the CPU is RAM-bound, Q2_0). Set before the first `run`. + void set_pcie_mode(int mode) { sink_.pcie_mode = mode; } + + double ms_wait = 0, ms_pool = 0, ms_host = 0, ms_commit = 0; + int64_t windows = 0; + +private: + bool capture(int T, std::string& err); + bool capture_commit(std::string& err); + bool record_window(int T, cudaStream_t cs, std::string& err); + + const WeightTable* wt_ = nullptr; + const ModelGeometry* g_ = nullptr; + SessionState* ss_ = nullptr; + VerifyHits hits_; + const NativeHead* head_ = nullptr; + int max_t_ = 0; + int last_t_ = 0; + int64_t last_pos0_ = 0; + int32_t last_tokens_[8] = {}; + int64_t n_vocab_ = 0; + cudaStream_t cs_ = nullptr; + cudaGraphExec_t exec_[9] = {}; + cudaGraphExec_t commit_exec_ = nullptr; + + // mapped staging (host pointer, device alias) + int32_t* h_tok_ = nullptr; int32_t* m_tok_ = nullptr; // T + int32_t* h_step_ = nullptr; int32_t* m_step_ = nullptr; // T * kStepCount + int32_t* h_pos_ = nullptr; int32_t* m_pos_ = nullptr; // T * n_head + int32_t* h_commit_ = nullptr; int32_t* m_commit_ = nullptr; // [n_keep, n_keep-1, pos_0 .. pos_{T-1}] + float* h_ple_ = nullptr; float* m_ple_ = nullptr; // T * n_embd + int32_t* h_out_ = nullptr; int32_t* m_out_ = nullptr; // T argmax ids + float* h_x_ = nullptr; float* m_x_ = nullptr; // doorbell payload: T * n_embd + int32_t* h_ids_ = nullptr; int32_t* m_ids_ = nullptr; // T * k + float* h_w_ = nullptr; float* m_w_ = nullptr; // T * k + uint32_t* h_seq_ = nullptr; uint32_t* m_seq_ = nullptr; + uint32_t* h_flag_ = nullptr; uint32_t* m_flag_ = nullptr; + uint32_t* h_flagA_ = nullptr; uint32_t* m_flagA_ = nullptr; // the GPU plan is in place + uint32_t* h_flagB_ = nullptr; uint32_t* m_flagB_ = nullptr; // the PCIe share's DMA copies have landed + cudaStream_t copy_ = nullptr; // the copy engine's stream (DMA of missed experts) + struct FlagSet { uint32_t* flag; uint32_t value; }; + FlagSet flag_sets_[2 * 64 * 2] = {}; // host-function arguments, one per (layer, group) + static void fetch_dma(void* ctx, const uint8_t* const* src, int n, size_t bytes); + static void raise_flag(uint32_t* flag, uint32_t value); + int32_t* h_plan_ = nullptr; int32_t* m_plan_ = nullptr; // counts | start | dst | tok | ptr (as int32 pairs) + int64_t plan_i32_ = 0; // int32 words in the plan block + GpuPlanSink sink_; + uint32_t cur_layer_ = 0; + static void publish_plan(void* ctx); + void set_plan_slot(int grp); + bool split_ = false; // opt-in (--spec-split): exact but slower, see the overlap study + int groups_[9] = {}; + float* h_ymiss_ = nullptr; float* m_ymiss_ = nullptr; // T * k * n_embd + + // device + void* arena_ = nullptr; + int32_t *tok_ = nullptr, *step_ = nullptr, *pos_ = nullptr, *commit_ = nullptr; + float *ple_ = nullptr, *emb_ = nullptr, *R_ = nullptr, *mixed_ = nullptr, *bo_ = nullptr; + float *inj_ = nullptr, *inj2_ = nullptr, *lo_ = nullptr, *rs_ = nullptr, *xn_ = nullptr; + uint8_t* xq_ = nullptr; // T columns of q8_1 + float *qkv_L_ = nullptr, *h_L_ = nullptr, *gate_L_ = nullptr, *beta_L_ = nullptr; // per GDN layer + float *z_ = nullptr, *y_ = nullptr, *y_dummy_ = nullptr; + float *qfull_ = nullptr, *qcur_ = nullptr, *kcur_ = nullptr, *vcur_ = nullptr, *idx_raw_L_ = nullptr; + float *qidx_ = nullptr, *scores_ = nullptr, *attn_ = nullptr, *attn32_ = nullptr, *attn_scratch_ = nullptr; + float* tail_snap_ = nullptr; // per QSA layer + int32_t* sel_ = nullptr; + float *logits_ = nullptr, *w_ = nullptr, *shared_ = nullptr, *parts_ = nullptr, *hit_out_ = nullptr; + int32_t *ids_ = nullptr, *hit_slot_ = nullptr, *hit_dst_ = nullptr, *hit_count_ = nullptr; + int32_t* plan_ = nullptr; // device copy of the plan block + uint8_t* staging_ = nullptr; // VRAM slots for the PCIe share of the misses + static constexpr int64_t kStagingBlobs = 16; + uint8_t* hit_xq_ = nullptr; + uint8_t* nat_xq_ = nullptr; // plan v0.3 P6: q8_1 activations for a native pack's grouped experts + float* hit_xs_ = nullptr; + void* hit_scratch_ = nullptr; + float *head_mixed_ = nullptr, *head_inj_ = nullptr, *head_logits_ = nullptr; + uint16_t* sh_bf16_ = nullptr; + float *sh_gate_ = nullptr, *sh_up_ = nullptr, *sh_g_ = nullptr; + float* hist_snap_ = nullptr; // T * NG_HIST * NG_HC_DIM + int64_t cap_ = 0, max_blocks_ = 0, attn_scratch_floats_ = 0; +}; + +} // namespace strata::core diff --git a/include/strata/core/weights.hpp b/include/strata/core/weights.hpp new file mode 100644 index 00000000..8d8e1ec5 --- /dev/null +++ b/include/strata/core/weights.hpp @@ -0,0 +1,135 @@ +// include/strata/core/weights.hpp - the dense weights, loaded into VRAM in ENGINE form. +// +// The pack is the ARTIFACT's business; this is the ENGINE's. The differences are deliberate, and they run in +// BOTH directions: +// +// * SHRINKING. A tensor whose source type is BF16 is stored in the pack promoted to 32 bits, because that is +// lossless and it keeps the canonical decoder simple, but a running engine re-rounds it to 16 - which costs +// nothing at all, because the value CAME from 16 bits. Per `tools/pack_budget.py` that saving is the +// difference between the dense weights fitting in VRAM beside the expert cache and not fitting at all. +// +// * GROWING. 90 of the 303 quantized tensors carry fp16 SCALES in the pack (all 58 Q2_0 expert tensors, plus +// Q4_0, Q5_0, Q8_0 and IQ4_NL) because that is what the source block holds. Every kernel here takes +// `const float* scales`, and fp16 is a subset of f32, so this loader WIDENS them - exactly, changing no +// value - for 11,407,360 B. See `docs/pack-format.md` §2.4; assuming 4 bytes per scale instead of reading +// `scales_fp16` is what made the plane arithmetic disagree with the pack for a whole round. +// +// pack 5.305 GiB dense + 0.444 GiB embd +// engine 4.067 GiB dense + 0.444 GiB embd + 10.88 MiB widened scales = 4.522 GiB +// +// WHAT THE LOADER DOES NOT DO: it does not know what any tensor MEANS. It reads a flat index +// (`/index.txt`, written by `tools/pack_index.py`), copies each tensor into one arena at a recorded +// offset - as a list of PLANE SEGMENTS, because a scale plane can change width and the rest cannot - and hands +// back a name -> WeightRef map. Geometry, forms and semantics belong to the caller. +// +// WHY AN INDEX FILE AND NOT A JSON PARSER: a JSON parser in the engine would be a new, unaudited component +// whose failure mode is a wrong byte offset - which decodes to a plausible weight and produces plausible +// logits. Python wrote the manifest and already parses it correctly. +#pragma once + +#include +#include +#include +#include +#include + +namespace strata::core { + +/// What the loader had to DO for a tensor, decided by `tools/pack_index.py` and written into the index +/// rather than inferred here. The four cases are distinguishable in the manifest only by combining +/// `source_type` with `values_fp16`, and getting that combination wrong yields a plausible tensor. +enum class WeightKind : int { + Verbatim = 0, ///< quantized planes, or F16 already stored as 2 bytes: copy straight through + Bf16InF32 = 1, ///< the pack holds the bf16 value promoted to f32: take the HIGH 16 BITS (exact) + F32 = 2, ///< copy 4 bytes per element + F16InF32 = 3, ///< an f16 value promoted to f32: a real f32->f16 conversion, not a truncation +}; + +struct WeightRef { + const void* data = nullptr; ///< device pointer inside the arena + uint64_t bytes = 0; + int64_t ne0 = 0; ///< the manifest's own shape, ne0 the CONTIGUOUS axis + int64_t ne1 = 0; + int64_t elements = 0; + WeightKind kind = WeightKind::Verbatim; + /// The canonical-form attributes, valid only for a quantized VERBATIM tensor (`code_bits != 0`). + int code_bits = 0; + int code_bias = 0; + int group_elems = 0; + bool codebook_iq4nl = false; + bool has_offset = false; + + /// THE THREE PLANE SIZES, IN ENGINE FORM, in the order they sit inside `data`. Recorded by the loader + /// from what the index TOLD it rather than re-derived by each caller: the one time this layout was + /// re-derived independently it assumed 4-byte scales for tensors whose pack scales are fp16, and the + /// only reason that was not a silent wrong answer is that the re-derivation checked itself and refused. + /// Two places computing one layout is two places to disagree. + uint64_t codes_bytes = 0; + uint64_t scales_bytes = 0; ///< always 4 B per scale in the arena, whatever the pack held + uint64_t offset_bytes = 0; ///< 0 when the form has no offset plane + + /// WHERE THIS TENSOR CAME FROM, so a caller can check the loader's work against the pack's own bytes + /// instead of against the loader's opinion of them. Without `src_off` a test can only compare two + /// derived numbers; with it, the widening of an fp16 scale plane can be verified value by value against + /// `.bin` - and the pack files are named, so `file_id` says which one to open. + uint64_t src_off = 0; + uint64_t src_bytes = 0; + int file_id = 0; + bool scales_fp16 = false; ///< the PACK's width, before the loader widened it + + /// WHICH ACTIVATION THIS WEIGHT WANTS - 0 for Q8_0, 1 for Q8_K. Decided by ools/pack_index.py from + /// source_type and carried here, because **the S-form CANNOT express it**: Q5_0 and Q5_K are both 8-bit + /// with bias -16, and IQ4_NL and IQ4_XS differ in nothing the S-form holds. docs/activation-contract.md. + int act_kind = 0; + bool wants_q8k() const { return act_kind == 1; } + + bool quantized() const { return code_bits != 0; } + + // Optional native GGUF projection, owned by NativeDense. All references in + // one table share its scratch and must execute on one ordered session stream. + const void* native_data = nullptr; + void* native_q8_1 = nullptr; + int native_type = -1; + /// Plan v0.3 P1: false when the loader SKIPPED this tensor's canonical bytes because another form serves it + /// (native GGUF projections, the native head). The metadata above stays valid; `data` is null. + bool resident = true; +}; + +struct LoadReport { + size_t tensors = 0; + uint64_t arena_bytes = 0; + size_t re_rounded = 0; ///< tensors written as 16 bits out of a 32-bit container + uint64_t bytes_saved = 0; ///< what those conversions saved against the pack + double read_ms = 0.0; + double upload_ms = 0.0; +}; + +class WeightTable { +public: + /// The arena size the index asks for, readable WITHOUT loading anything - so a caller can size its + /// `DeviceArena` before committing to the load, and a plan that cannot fit is refused at startup rather + /// than halfway through a 5 GB upload. + /// With `skip`, the size of the compacted arena that holds every tensor EXCEPT the named ones. + static bool pool_bytes(const std::string& pack_dir, uint64_t& out, std::string& err, + const std::set* skip = nullptr); + + /// Load every tensor in `/index.txt` into `arena_base`. + /// + /// `arena_bytes` MUST be at least `pool_bytes`; the loader checks rather than trusting the caller, + /// because the failure mode otherwise is a device write past the end of the arena. + /// With `skip`, the named tensors are not read: their rows keep their metadata with `data == nullptr` and + /// `resident == false`, and the other tensors are packed into the compacted arena `pool_bytes` sized. + bool load(const std::string& pack_dir, void* arena_base, uint64_t arena_bytes, std::string& err, + const std::set* skip = nullptr); + + const WeightRef* find(const std::string& name) const; + const std::map& all() const { return table_; } + const LoadReport& report() const { return report_; } + +private: + friend class NativeDense; + std::map table_; + LoadReport report_; +}; + +} // namespace strata::core diff --git a/include/strata/kernels/bf16_bits.hpp b/include/strata/kernels/bf16_bits.hpp new file mode 100644 index 00000000..8c72feb8 --- /dev/null +++ b/include/strata/kernels/bf16_bits.hpp @@ -0,0 +1,46 @@ +// include/strata/kernels/bf16_bits.hpp - the bf16 conversions, written out, as bits. +// +// bf16 is the TOP HALF of an f32, so `f32_from_bf16` is a shift and needs no rounding at all. The forward +// direction does: ggml's `ggml_compute_fp32_to_bf16` adds `((i >> 16) & 1) + 0x7FFF` and truncates 16 bits. +// +// **THE `& 1` IS ON BIT 16**, the lowest KEPT bit, which is what makes an exact tie round to EVEN. Using bit +// 15 there is a plausible mistake: it differs only on exact ties, i.e. almost never on real data, which is +// exactly why it would survive any amount of end-to-end testing. `ref/quant.py` documents the same rule for +// the same reason. +// +// WHY THIS IS SEPARATE FROM `f16_bits.hpp`: fp16 and bf16 are not two spellings of one idea. fp16 has 5 +// exponent bits and 10 mantissa bits; bf16 has 8 and 7. A conversion routine written for one and reused for +// the other is wrong in a way that produces plausible numbers - bf16's 2^-9 relative precision looks like a +// small fp16 error and is not small at all next to fp16's 2^-11. +// +// `gr.cu` carried a private copy of `f32_to_bf16_bits` until this header existed. It now includes this. +#pragma once + +#include +#include + +#if defined(__CUDACC__) +#define STRATA_BF16_HD __host__ __device__ +#else +#define STRATA_BF16_HD +#endif + +namespace strata::kernels { + +/// Round-to-nearest-even f32 -> bf16, returned as raw bits in the LOW half of the uint16. +STRATA_BF16_HD inline uint16_t bf16_from_f32(float f) { + uint32_t i; + std::memcpy(&i, &f, 4); + i = (i + ((i >> 16) & 1u) + 0x7FFFu) & 0xFFFF0000u; + return (uint16_t) (i >> 16); +} + +/// bf16 bits -> f32. Exact, and no rounding: the low 16 bits of an f32 are simply zero. +STRATA_BF16_HD inline float f32_from_bf16(uint16_t h) { + const uint32_t i = (uint32_t) h << 16; + float f; + std::memcpy(&f, &i, 4); + return f; +} + +} // namespace strata::kernels diff --git a/include/strata/kernels/bf16_gemv.hpp b/include/strata/kernels/bf16_gemv.hpp new file mode 100644 index 00000000..6f3619d9 --- /dev/null +++ b/include/strata/kernels/bf16_gemv.hpp @@ -0,0 +1,48 @@ +// include/strata/kernels/bf16_gemv.hpp - the GEMV for a BF16 weight, P2.S5. +// +// WHY THIS EXISTS WHEN `s_gemv` ALREADY DOES. `s_gemv` decodes the S-family canonical forms (S2/S4/S8) from +// `codes` + `scales`, and a BF16 weight is not one of them - it has no codes and no scales, it is a plain +// 16-bit matrix. Six tensors per layer are in that state: +// +// ssm_alpha.weight, ssm_beta.weight GDN, [n_embd, 48] +// indexer.q_proj.weight, indexer.k_proj.weight QSA, [n_embd, 512] and [n_embd, 128] +// ple_value.weight layer 1, [n_embd, n_embd] +// ffn_gate_inp.weight handled inside `router_top10` +// +// The original entry points take BF16 activation bits for the historical CPU-derived contract. The explicit +// FP32 entry point below follows the pinned CUDA single-token MMVF contract. See docs/activation-contract.md +// for why activation precision depends on backend and batch geometry rather than weight type alone. +#pragma once + +#include + +namespace strata::kernels { + +/// `y[o] = sum_i f32(x[i]) * f32(w[o*n_in + i])`, with both sides bf16-valued so every product is exact in +/// f32 and only the summation order differs. +/// +/// THE WEIGHT LAYOUT IS THE MANIFEST'S: shape [n_in, n_out] with ne0 = n_in CONTIGUOUS, so row `o` is a +/// contiguous run of `n_in` bf16 values. That is the same convention `s_gemv` uses, and it is what +/// `strata::core::WeightRef::ne0/ne1` already report. +void bf16_gemv(const uint16_t* x, const uint16_t* w, float* y, int64_t n_in, int64_t n_out, void* stream); + +/// The row-split variant, for the same reason `s_gemv_split` exists: with one thread per output row the +/// parallelism IS the output width, and `ssm_alpha` has 48 of them over 48 SMs. One warp per row, lanes +/// striding the reduction axis - which is also what makes the access pattern coalesced in this layout. +/// +/// NOT bit-identical to `bf16_gemv`: the partial sums are added in a different order. Both are kept and both +/// are tested, because the naive one is the reference the split one is checked against. +void bf16_gemv_split(const uint16_t* x, const uint16_t* w, float* y, int64_t n_in, int64_t n_out, + int threads_per_row, void* stream); + +/// Single-token, contiguous 2D BF16-weight/F32-activation MMVF from the pinned llama.cpp CUDA path. +/// Preserves pair accumulation, adaptive block size and warp-XOR reduction order. This is a separate +/// opt-in entry point; the legacy BF16-activation kernels above retain their behavior. +/// `w[o*n_in+i]` is BF16 bits; x/y are F32. n_in must be positive and even, n_out positive, both <= INT_MAX. +/// x must be 8-byte aligned, w 4-byte aligned, and y 4-byte aligned. Throws on invalid geometry/pointers. +/// The device buffers must not overlap; allocation bounds remain the caller's responsibility. +/// No allocations or synchronization, including when stream is null (the CUDA default stream). +void bf16_gemv_fp32_mmvf(const float* x, const uint16_t* w, float* y, + int64_t n_in, int64_t n_out, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/cpu/expert.hpp b/include/strata/kernels/cpu/expert.hpp new file mode 100644 index 00000000..ab05db84 --- /dev/null +++ b/include/strata/kernels/cpu/expert.hpp @@ -0,0 +1,179 @@ +// include/strata/kernels/cpu/expert.hpp - P2.S3: the CPU expert path. +// +// This is the Q2_0 expert kernel promoted out of `bench/micro/cpu_s2.cpp`, which is the instrument that +// measured `c` (Memory/LEDGER.md L9/L10: 15.03 ms for one token's full routed set on 6 cores, DRAM-bound at +// 44.14 GB/s) and whose P0.T2 parity passes at 1.461e-06. +// +// IT IS THE SAME CODE, NOT A COPY. Keeping a second implementation next to a validated one is how a project +// ends up with two answers and no way to say which is right, so `cpu_s2.cpp` is expected to become a thin +// benchmark over these functions. (Until it is, the two exist side by side and this header says so.) +// +// WHAT MAKES IT FAST, and each of the three was paid for once: +// +// * `vpdpbusd` (AVX512-VNNI) does four int8 MACs per instruction. The scalar loop it replaces ran at +// 9.89 GB/s on one core; VNNI is the reason the 6-core figure is 42.55 GB/s. +// * `vpmultishiftqb` (AVX512-VBMI) unpacks the 2-bit codes eight at a time. Its ARGUMENT ORDER is +// (control, data) and was determined empirically - the Intel guide states the opposite, and following the +// guide produced 43 of 64 wrong codes. +// * The row accumulator stays a FLOAT VECTOR and is reduced ONCE at the end of the row. Reducing per +// 64-weight block cost more than the dot products themselves: it made the kernel compute-bound at +// 21 GB/s instead of DRAM-bound at 32. +// +// THE CONTRACT THAT MATTERS, because collapsing it is invisible and cannot match ggml's logits: one Q2_0 +// WEIGHT block is QK=64 weights with one fp16 scale, while one ACTIVATION chunk is QKA=32 elements with +// its OWN scale. So a weight block spans TWO activation chunks with two different scales. Using one scale +// per weight block is the natural-looking mistake. +#pragma once + +#include +#include + +namespace strata::kernels::cpu { + +// ---- geometry, all of it fixed by the artifact (tools/verify_q2_0_geometry.py: 202/202 tensors at QK=64) ---- +inline constexpr int H = 2560; // n_embd +inline constexpr int FF = 640; // expert intermediate width +inline constexpr int NE = 512; // routed experts per layer +inline constexpr int QK = 64; // QK2_0: weights per fp16 scale +inline constexpr int QKA = 32; // QK8_1/QK8_0: ACTIVATION elements per scale +inline constexpr int BB = 18; // bytes per 64-weight Q2_0 block: 2 fp16-scale + 16 codes +inline constexpr int MAXC = H / QKA; // 80 activation chunks across the widest reduction +inline constexpr int ROW_GU = H * 2 / 8; // 640 B of codes per gate/up row +inline constexpr int ROW_D = FF * 2 / 8; // 160 B of codes per down row +inline constexpr int SC_GU = H / QK; // 40 weight blocks per gate/up row +inline constexpr int SC_D = FF / QK; // 10 weight blocks per down row +inline constexpr size_t BLOB = 3ull * (H * FF * BB / QK); // 1,382,400 + +// ---- the blob's internal layout (one expert) ---- +inline constexpr size_t O_GU_CODES = 0; +inline constexpr size_t O_D_CODES = 2ull * FF * ROW_GU; +inline constexpr size_t O_GU_SCALES = O_D_CODES + 1ull * H * ROW_D; +inline constexpr size_t O_D_SCALES = O_GU_SCALES + 2ull * FF * SC_GU * 2; + +/// One activation in Strata's planar storage: `QKA` elements per chunk. +/// The default legacy quantizer uses FP32 scales and half-away rounding. It is +/// not the pinned ggml CPU Q8_0 contract; expert_set_oracle_q8_0 selects that +/// experimental contract (FP16-rounded scales and x86 nearest-even codes). +/// +/// The historical function name `act_quant_q8_1` does not describe a native +/// ggml block layout. One 64-weight block always spans two 32-element chunks. +/// +/// `hx[k] = scale[k] * sum[k]` is the weight-independent correction the Q2_0 identity needs +/// (`sum (c-1) d_w xhat d_x = d_w (d_x*sum(c*xhat) - d_x*sum(xhat))`), precomputed once per layer rather than +/// once per row: 640 rows would otherwise recompute it 640 times. +struct ActQ { + alignas(64) int8_t q[H]; + float scale[MAXC]; + int32_t sum[MAXC]; + float hx[MAXC]; + int nchunks; +}; + +/// Per-worker scratch. Owned by the caller and passed in, so the token path performs NO allocations +/// (P2.T10) - the kernel itself allocates nothing. +struct ExpertScratch { + ActQ a1; + ActQ a2; + alignas(64) float ff[FF]; +}; + +/// Runtime CPU feature check. The kernel uses AVX512-VNNI + AVX512-VBMI + AVX512VL, and code compiled with +/// `/arch:AVX512` can emit AVX-512 anywhere in its translation unit, so a machine without them must be +/// REFUSED rather than silently run. `s2_expert_scalar` is the fallback and exists for tests. +struct CpuFeatures { + bool avx512f = false; + bool avx512bw = false; + bool avx512vl = false; + bool avx512_vnni = false; + bool avx512_vbmi = false; + bool usable() const { return avx512f && avx512bw && avx512vl && avx512_vnni && avx512_vbmi; } + /// A one-line description of what is missing, or "ok". + const char* reason() const; +}; +CpuFeatures cpu_features(); + +/// Exits with a clear message if the CPU cannot run `s2_expert_vnni`. Called once at startup, so a user on an +/// older CPU learns why at second zero instead of seeing an illegal instruction at token 4000. +void cpu_require_expert_support(); + +/// Select the pinned x86 Q8_0 activation contract at BOTH expert quantization +/// boundaries and the pinned generic CPU Q2_0 dot reduction in all projections. +/// Default false preserves the legacy quantizer and eight-lane FP32 reduction. +/// Configure before starting worker threads and do not change during a request. +/// GPU expert-cache parity is not established for this mode; the driver must +/// reject combining it with that cache. No allocation occurs in either mode. +void expert_set_oracle_q8_0(bool enabled); + +/// `x` -> `a` in the configured contract. The function name is historical; +/// `n` must be a multiple of QKA and at most H. No allocation occurs. +void act_quant_q8_1(const float* x, int n, ActQ& a); + +/// One expert: `out(H) = down( swiglu( gate(x), up(x) ) )`, all three projections Q2_0, +/// with the configured activation and dot contracts at both quantization boundaries. +/// +/// `blob` is one 1,382,400-byte expert in the pack's layout; `x` is the H-wide input; `out` is H-wide. +void s2_expert_vnni(const uint8_t* blob, const float* x, float* out, ExpertScratch& ws); + +/// The same, with the activation ALREADY quantized in the configured contract. +/// Workers may share this read-only activation; each must own its scratch/output. +/// +/// This split exists for the pool. A layer runs TEN experts against ONE activation, so quantizing inside +/// `s2_expert_vnni` would do the same 2560-element conversion ten times per layer per token - 480 redundant +/// conversions per token. P2.S3's work item is `(blob, x-hat quantized, block sums, weight, out)`, and this +/// is the entry point that matches it. +void s2_expert_vnni_q(const uint8_t* blob, const ActQ& a1, float* out, ExpertScratch& ws); + +/// Plan v0.3 P6: ONE expert applied to up to MAXT tokens that were all routed to it (a speculative verify +/// window routes ~1.5 tokens to each distinct expert). The 1.38 MB blob is read and its 2-bit codes unpacked +/// ONCE per block for all tokens; only the VNNI dot and the accumulation run per token. Every token's output +/// is bitwise equal to `s2_expert_vnni_q` on that token (same operation order per token). Legacy activation +/// contract only; with `expert_set_oracle_q8_0(true)` it falls back to one `s2_expert_vnni_q` per token. +inline constexpr int MAXT = 8; +/// Plan v0.3 P4: the expert in row ranges, so several threads can share one expert. `s2_expert_gu_rows` writes +/// ff[r] = silu(gate_r . x) * (up_r . x) for r in [r0, r1) (of FF); `s2_expert_down_rows` writes out[r] for r in +/// [r0, r1) (of H) from the intermediate's quantized image. Together with `act_quant_q8_1(ff, FF, a2)` in +/// between they are exactly `s2_expert_vnni_q`, row for row (same `row_dot`), so the result is bitwise the same. +bool expert_oracle_q8_0_enabled(); +void s2_expert_gu_rows(const uint8_t* blob, const ActQ& a1, float* ff, int r0, int r1); +void s2_expert_down_rows(const uint8_t* blob, const ActQ& a2, float* out, int r0, int r1); + +/// Plan v0.3 P6: the row-range kernels for up to MAXT tokens routed to the same expert (legacy contract only). +/// Each token's rows are bitwise the single-token `s2_expert_gu_rows` / `s2_expert_down_rows`. +void s2_expert_gu_rows_multi(const uint8_t* blob, const ActQ* const* a1, int n_tokens, float* const* ff, int r0, + int r1); +void s2_expert_down_rows_multi(const uint8_t* blob, const ActQ* const* a2, int n_tokens, float* const* out, int r0, + int r1); + +struct ExpertScratchMulti { + ActQ a2[MAXT]; + alignas(64) float ff[MAXT][FF]; + ExpertScratch single; // the oracle-contract fallback +}; +void s2_expert_vnni_multi(const uint8_t* blob, const ActQ* const* a1, int n_tokens, float* const* out, + ExpertScratchMulti& ws); + +/// The scalar transcription of ggml's formula - NOT a second opinion, but the ORACLE the VNNI path is checked +/// against, and the only path available on a CPU without AVX-512. +/// +/// `quant_acts` makes the oracle consume the SAME INT8 activation values the VNNI path uses, **at BOTH +/// stages**. With it OFF the oracle is exact-FP32-activation and the gap between them IS the activation +/// contract; with it ON the two differ only by FP32 evaluation order, so a small gap there proves the kernel +/// itself correct. That separation is the whole point of P0.T2 and it is why this parameter exists rather +/// than two functions. +/// +/// CORRECTION TO THE ORIGINAL: `bench/micro/cpu_s2.cpp`'s comment says "at both stages" but its code +/// quantizes only the intermediate, leaving the gate/up projections on raw f32 `x`. That is invisible when +/// the caller passes an already-quantized input - which the engine does - and shows up as a ~1.2% gap when it +/// does not, which reads as a kernel bug and is not one. This version quantizes both. +/// Plan v0.3 P6: rows [r0, r1) of a Q2_0 matrix in the GGUF block layout (18 bytes per 64 weights, `row_bytes` +/// per row, `nblocks` blocks each) against `nt` activations: out[t][r]. +void q2_0_gguf_rows_multi(const uint8_t* w, size_t row_bytes, int nblocks, const ActQ* const* a, int nt, + float* const* out, int r0, int r1); +/// The same two for CPUs without AVX-512 (src/kernels/cpu/q2_avx2.cpp, compiled for AVX2 only). +void q2_0_gguf_rows_multi_avx2(const uint8_t* w, size_t row_bytes, int nblocks, const ActQ* const* a, int nt, + float* const* out, int r0, int r1); +void act_quant_q8_1_avx2(const float* x, int n, ActQ& a); + +void s2_expert_scalar(const uint8_t* blob, const float* x, float* out, bool quant_acts); + +} // namespace strata::kernels::cpu diff --git a/include/strata/kernels/cpu/expert_layout.hpp b/include/strata/kernels/cpu/expert_layout.hpp new file mode 100644 index 00000000..d4b20cb0 --- /dev/null +++ b/include/strata/kernels/cpu/expert_layout.hpp @@ -0,0 +1,51 @@ +// include/strata/kernels/cpu/expert_layout.hpp - plan v0.3 P6: where each routed expert lives in experts.bin. +// +// A Q2_0 pack (tools/strata_pack.py) has one blob size for every layer, `BLOB`, in the Strata expert form. A +// native pack (tools/iq_pack.py, the IQ2_XS / IQ3_XXS files) keeps each expert's raw GGUF slices, so the blob +// size and the formats change from layer to layer; `native_experts.txt` says how. Everything that touches an +// expert blob - the arena, the VRAM tier, the prompt path, the CPU pool, the GPU window - asks this table. +#pragma once + +#include "strata/kernels/cpu/expert.hpp" +#include "strata/kernels/cpu/native_expert.hpp" + +#include +#include +#include + +namespace strata::kernels::cpu { + +struct ExpertLayout { + bool native = false; + int64_t n_layers = 0, n_expert = NE; + std::vector fmt; ///< per layer (native packs) + std::vector offset, bytes; ///< per layer: where its 512 blobs start, bytes per blob + /// Plan v0.3 P6: per layer, the absolute offsets of the gate / up / down tensors in the model's shard 1, so + /// the arena can be filled from the GGUF itself when the pack has no experts.bin (3 x n_layers, 0 = unknown). + std::vector gguf_off; + uint64_t max_blob = BLOB; + uint64_t total = 0; ///< experts.bin size + + uint64_t blob_bytes(int64_t layer) const { return native ? bytes[(size_t) layer] : (uint64_t) BLOB; } + uint64_t layer_offset(int64_t layer) const { + return native ? offset[(size_t) layer] : (uint64_t) layer * (uint64_t) n_expert * (uint64_t) BLOB; + } + uint64_t blob_offset(int64_t layer, int64_t expert) const { + return layer_offset(layer) + (uint64_t) expert * blob_bytes(layer); + } +}; + +/// Plan v0.3 P6: whether this CPU (and its OS) runs the AVX-512 kernels (F, BW, VL, VNNI, VBMI). Probed in a +/// file compiled without AVX-512, so asking is safe everywhere; STRATA_FORCE_AVX2=1 answers no (for tests). +bool cpu_avx512_ok(); +/// Q2_0 GGUF rows / activation quantizer on the kernels this CPU has. +void q2_rows_any(const uint8_t* w, size_t row_bytes, int nblocks, const ActQ* const* a, int nt, float* const* out, + int r0, int r1); +void act_quant_any(const float* x, int n, ActQ& a); + +/// The process-wide layout (canonical Q2_0 until `expert_layout_load` finds a native pack). +const ExpertLayout& expert_layout(); +/// Reads `/native_experts.txt` when it exists (a native pack), else sets the canonical layout. +bool expert_layout_load(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, std::string& err); + +} // namespace strata::kernels::cpu diff --git a/include/strata/kernels/cpu/iq_avx512.hpp b/include/strata/kernels/cpu/iq_avx512.hpp new file mode 100644 index 00000000..a9a71c46 --- /dev/null +++ b/include/strata/kernels/cpu/iq_avx512.hpp @@ -0,0 +1,18 @@ +// include/strata/kernels/cpu/iq_avx512.hpp - plan v0.3 P6: AVX-512 multi-token dot products for the i-quant +// expert formats (IQ2_XXS, IQ2_XS, IQ2_S, IQ3_XXS, IQ3_S) against Q8_K activations (ggml's block_q8_K). +#pragma once + +#include +#include + +namespace strata::kernels::cpu { + +bool iq512_supported(int ggml_type) noexcept; +/// ff[t][r] = silu(gate_r . a[t]) * (up_r . a[t]), rows [r0, r1); gate rows at blob, up rows at blob + up_off. +void iq512_gu_rows(int ggml_type, const uint8_t* blob, size_t gu_row, size_t up_off, int n, const void* const* act, + int nt, float* const* ff, int r0, int r1); +/// out[t][r] = w_r . a[t], rows [r0, r1). +void iq512_rows(int ggml_type, const uint8_t* w, size_t row_bytes, int n, const void* const* act, int nt, + float* const* out, int r0, int r1); + +} // namespace strata::kernels::cpu diff --git a/include/strata/kernels/cpu/native_expert.hpp b/include/strata/kernels/cpu/native_expert.hpp new file mode 100644 index 00000000..9b807427 --- /dev/null +++ b/include/strata/kernels/cpu/native_expert.hpp @@ -0,0 +1,49 @@ +// include/strata/kernels/cpu/native_expert.hpp - plan v0.3 P6: one routed expert in its GGUF form on the CPU. +// +// The IQ2_XS / IQ3_XXS model files keep their experts in i-quant formats (IQ1_M, IQ2_XXS, IQ2_XS, IQ2_S, IQ3_XXS, +// IQ3_S gate/up; Q2_0 or IQ4_NL down) whose values cannot be re-expressed in the Q2_0 pack form. A native expert +// blob is the three GGUF slices back to back, [gate rows | up rows | down rows], and the arithmetic is ggml-cpu's +// own (`ggml_get_type_traits_cpu`): the activation is quantized with the weight type's `vec_dot_type` and each row +// is one `vec_dot`, exactly what llama.cpp's CPU backend computes for the same tensor. +#pragma once + +#include +#include +#include + +namespace strata::kernels::cpu { + +/// Bytes of the largest quantized activation any native layer uses (2560 values as Q8_K: 10 x 292). +inline constexpr size_t kNativeActBytes = 4096; +/// Bytes of the largest quantized down activation (640 values as Q8_0: 20 x 34, or Q8_K 3 x 292). +inline constexpr size_t kNativeHBytes = 1024; + +/// One layer's native expert geometry. +struct NativeFmt { + int gu_type = -1, d_type = -1; ///< ggml types of gate/up and of down + int gu_act = -1, d_act = -1; ///< their vec_dot_type (the activation formats) + int64_t n_embd = 0, n_ff = 0; + size_t gu_row = 0, d_row = 0; ///< bytes per weight row + size_t up_off = 0, down_off = 0; ///< inside the blob + size_t bytes = 0; ///< the whole blob + size_t act_bytes = 0, h_bytes = 0; ///< quantized activation sizes (n_embd of gu_act, n_ff of d_act) +}; + +/// Whether this build has the ggml-cpu path. +bool native_experts_available() noexcept; +/// Fills `f` for a layer; false (with a reason) when ggml-cpu has no dot product for a type. +bool native_fmt(int gu_type, int d_type, int64_t n_embd, int64_t n_ff, NativeFmt& f, std::string& err); + +/// x (n_embd floats) -> the gate/up activation (act_bytes). +void native_quant_act(const NativeFmt& f, const float* x, void* dst); +/// h (n_ff floats) -> the down activation (h_bytes). +void native_quant_h(const NativeFmt& f, const float* h, void* dst); + +/// ff[t][r] = silu(gate_r . a[t]) * (up_r . a[t]) for rows r in [r0, r1), `nt` tokens. +void native_gu_rows(const NativeFmt& f, const uint8_t* blob, const void* const* act, int nt, float* const* ff, + int r0, int r1); +/// out[t][r] = down_r . hq[t] for rows r in [r0, r1). +void native_down_rows(const NativeFmt& f, const uint8_t* blob, const void* const* hq, int nt, float* const* out, + int r0, int r1); + +} // namespace strata::kernels::cpu diff --git a/include/strata/kernels/cpu/pool.hpp b/include/strata/kernels/cpu/pool.hpp new file mode 100644 index 00000000..7c1a9d2c --- /dev/null +++ b/include/strata/kernels/cpu/pool.hpp @@ -0,0 +1,195 @@ +// include/strata/kernels/cpu/pool.hpp - P2.S3: the CPU expert pool. +// +// A layer runs TEN experts against ONE activation, and the expert kernel is DRAM-bound (Memory/LEDGER.md L9: +// 42.55 GB/s on 6 cores, tracking core count almost exactly). So the pool's job is not to be clever - it is +// to keep every physical core reading expert bytes for the whole layer, and to be cheap enough that ten +// dispatches per layer cost less than one expert. +// +// WHY A FLAT BATCH AND NOT A RING. P2.S3 describes a "lock-free SPMC ring", which is what you need when +// jobs arrive while others are still being computed. Here they cannot: the host must SUM all ten outputs +// before the next layer starts, so a layer is a barrier by construction and the queue never holds more than +// one batch. A ring would add a wrap-around to get wrong and buy nothing. What is kept from the phase is +// the part that matters: `head`/`done` are single fetch_add counters, one claim per worker, no lock. +// +// THE COMPLETION PROTOCOL, because this is where a pool usually goes wrong. `run()` waits for `done == n` +// AND for every worker to PARK. Waiting only for `done` is not enough: a worker can still be inside the +// drain loop after its last `done` increment, and the host resetting `head` underneath it would let that +// worker claim a job from the NEXT batch before the next batch has been published. The +// `done`-then-`parked` pair makes the handover unambiguous, and the second wait costs a few hundred cycles +// against a layer that takes milliseconds. +#pragma once + +#include "strata/kernels/cpu/expert.hpp" +#include "strata/kernels/cpu/native_expert.hpp" + +#include +#include +#include +#include + +namespace strata::kernels::cpu { + +/// One expert evaluation. `act` is SHARED and read-only across the whole batch - that sharing is the point +/// of `s2_expert_vnni_q` and it is what saves 480 redundant activation conversions per token. +struct ExpertJob { + const uint8_t* blob = nullptr; ///< one 1,382,400-byte expert + const ActQ* act = nullptr; ///< the layer's quantized activation, shared + float* out = nullptr; ///< H floats, written by exactly one worker + float weight = 1.0f; ///< the router weight; applied by the HOST, not here + int slot = -1; ///< the job's index, for diagnostics +}; + +/// Plan v0.3 P6: one expert for the `nt` tokens of a verify window that were routed to it. Every token's output +/// is bitwise the single-token job's. +struct ExpertJobMulti { + const uint8_t* blob = nullptr; + int nt = 0; + const ActQ* act[MAXT] = {}; + float* out[MAXT] = {}; + /// Plan v0.3 P6: a native pack's activations (the layer's `vec_dot_type`), one per token. + const void* nact[MAXT] = {}; +}; + +/// One logical processor per PHYSICAL core, so a worker is never scheduled onto an SMT sibling of another +/// worker. On the 6-core/12-thread machine this project measures on, `hardware_concurrency()/2` workers on +/// logical processors 0..5 would put every worker on a sibling pair and halve the useful bandwidth - which is +/// exactly the kind of error that shows up as "the CPU path is slower than the model says" with no clue why. +/// +/// `skip_first` drops the first core, which P2.S3 reserves for the host loop. +std::vector physical_cores(bool skip_first); + +/// **THE RESERVATION IS A FICTION UNLESS THE HOST IS ACTUALLY PUT THERE.** +/// +/// `physical_cores(true)` keeps the workers off the first physical core so that the host loop can spin on +/// `cudaEventQuery` without stealing a worker's cycles. Nothing in the pool can enforce the other half of +/// that, so this is it: the host loop calls this on entry and restores on exit. +/// +/// MEASURED, and this is why it exists: the pool runs at **36.32 GB/s on 5 workers with nothing else running** +/// - exactly 5/6 of L9's 44.14 on 6 - and at **26.9 GB/s inside the host loop**, where the unpinned spinning +/// host is free to land on a worker's core or its SMT sibling. That 1.35x is not the kernel. +/// +/// Returns the PREVIOUS affinity mask, or -1 if the platform refused; pass it to `restore_thread_affinity`. +long long pin_current_thread(int core); +void restore_thread_affinity(long long previous); + +class ExpertPool { +public: + /// `n_workers <= 0` means "every physical core except the first". Workers are pinned to physical cores + /// (minus core 0 by default) and each owns one `ExpertScratch`, so nothing in the token path allocates. + /// + /// **`host_works` PUTS THE HOST THREAD INTO THE DRAIN (R2.2's FIRST HALF).** + /// + /// The pool reserves core 0 for the host loop so the doorbell spin cannot steal a worker's cycles - but + /// during `run()` the host does not spin, it waits, so core 0 is idle for the whole drain. Measured on the + /// 6-core machine this project targets: the engine's pool drains at **33.7 GB/s** (663.6 MB of expert + /// blobs in 19.71 ms/token) where the same kernel on 5 workers should reach 5/6 x 44.14 = 36.8 and the + /// machine measures 44.14 GB/s on all six. So the sixth core is being paid for and not used. + /// + /// With `host_works`, `run()` claims jobs itself instead of spinning on `done_`, and the pool is six + /// threads on six cores. `false` is the A/B arm and exists so the change is measurable rather than + /// asserted - the counter it moves is `pool phases ... drain`, which is host-side and needs no profiler. + explicit ExpertPool(int n_workers = 0, bool pin = true, bool host_works = true); + ~ExpertPool(); + ExpertPool(const ExpertPool&) = delete; + ExpertPool& operator=(const ExpertPool&) = delete; + + int workers() const { return n_; } + /// Whether the host thread also drains. Reported at startup, because "the engine adapts to the machine it + /// is on" is only true if the engine says which adaptation it took. + bool host_works() const { return host_works_; } + + /// Publish `n` jobs, then block until every one has been claimed AND every worker has parked. + /// `jobs` must outlive the call (it does, and the workers never touch it afterwards). + void run(ExpertJob* jobs, int n); + + /// Plan v0.3 P4: the same outputs as `run`, bitwise, with every expert split by rows across all threads + /// (gate/up rows, then the intermediate's quantization, then down rows). With fewer experts than threads - + /// the case once the VRAM tier takes half of them - `run` leaves cores idle and each expert streams at one + /// core's bandwidth; this streams every expert at all of them. At most `kMaxSplit` experts. + void run_split(ExpertJob* jobs, int n); + static constexpr int kMaxSplit = 16; + /// Plan v0.3 P6: `run_split` for multi-token jobs (at most `kMaxSplitMulti`); the rows of each expert are + /// read once for all of its tokens. + void run_split_multi(ExpertJobMulti* jobs, int n); + /// Plan v0.3 P6: the same for a native pack's layer (ggml-cpu arithmetic, `nact` activations). + void run_split_multi_native(const NativeFmt& f, ExpertJobMulti* jobs, int n); + static constexpr int kMaxSplitMulti = 96; + /// run_split_multi's phases, accumulated ms: gate/up rows, the intermediate quantization, down rows. + double ms_multi_gu = 0, ms_multi_q = 0, ms_multi_down = 0; + int64_t multi_bytes = 0; + + /// Total `_mm_pause` iterations spent waiting, over all workers, is no longer counted - see the note on the + /// atomics below. It was a LOCKED read-modify-write in the park loop, so measuring the contention added to + /// it. + long long pauses() const { return 0; } + + /// **WHERE `run()` SPENDS ITS TIME, in milliseconds accumulated over its lifetime.** Three phases per + /// layer - wait for every worker to be parked, wait for the drain, wait for them to re-park - and until now + /// all three were reported as one number. Without the split there is no way to tell a pool that is slow at + /// the WORK from one that is slow at the SYNCHRONISATION, and those need opposite fixes: the first is a + /// kernel problem and the second is a barrier problem. + /// + /// Only the host thread touches these, in `run()`, so they need no atomics. + void phase_ms(double& wait_park, double& drain, double& repark) const { + wait_park = ms_wait_park_; + drain = ms_drain_; + repark = ms_repark_; + } + +private: + void worker(int id); + void drain(int id, ExpertScratch& scratch); + void run_phase(int mode, int n_tasks); + + int n_ = 0; + bool host_works_ = true; + ExpertJob* jobs_ = nullptr; + int njobs_ = 0; + /// The host's own scratch when `host_works_`. A separate object rather than a share of `scratch_[i]`, + /// because a worker may own any index and the two must not be able to collide. + ExpertScratch host_scratch_; + // `run()`'s three phases, accumulated. Host-thread only; see `phase_ms`. + double ms_wait_park_ = 0.0; + double ms_drain_ = 0.0; + double ms_repark_ = 0.0; + // ---- EACH ATOMIC GETS ITS OWN CACHE LINE, AND THE SPIN COUNTER IS GONE. (Review finding C3.) + // + // These were six adjacent atomics, which put `head_`, `done_`, `parked_` and `epoch_` on ONE cache line - + // the four that workers and the host actually contend on, invalidating each other on every access. + // + // Worse, the parked spin did `pauses_.fetch_add(1)` on EVERY iteration: a LOCKED read-modify-write, five + // workers against one line, at roughly one iteration per `_mm_pause`. So the line the host must WRITE to + // publish work (`epoch_`) and READ to confirm the workers are parked (`parked_`) was being hammered by the + // very threads waiting for it. That is contention the pool imposes on itself. + // + // The counter was diagnostic only - `pauses()` was read in one place, to print a number nothing branched on + // - so it is deleted rather than amortised. `alignas(64)` then stops the remaining four sharing. + alignas(64) std::atomic head_{0}; + alignas(64) std::atomic done_{0}; + alignas(64) std::atomic parked_{0}; + alignas(64) std::atomic epoch_{0}; + alignas(64) std::atomic stop_{false}; + std::vector threads_; + std::vector scratch_; // one per worker: no allocation, no false sharing of the hot data + // run_split state: mode 0 = whole experts, 1 = gate/up row parts, 2 = down row parts + int mode_ = 0; + int parts_a_ = 1, parts_b_ = 1; + struct SplitBuf { + alignas(64) float ff[FF]; + ActQ a2; + }; + std::vector split_; + // run_split_multi state: mode 3 = gate/up row parts, 4 = down row parts + ExpertJobMulti* mjobs_ = nullptr; + int64_t mrows_ = 0; // rows of the current multi phase across all its experts (n * FF, then n * H) + int mtasks_ = 1; // equal row ranges the phase is cut into + struct SplitBufMulti { + alignas(64) float ff[MAXT][FF]; + ActQ a2[MAXT]; + alignas(64) uint8_t hq[MAXT][kNativeHBytes]; // plan v0.3 P6: native down activations + }; + const NativeFmt* nfmt_ = nullptr; + std::vector split_multi_; +}; + +} // namespace strata::kernels::cpu diff --git a/include/strata/kernels/dequant_bf16.hpp b/include/strata/kernels/dequant_bf16.hpp new file mode 100644 index 00000000..5a8ed088 --- /dev/null +++ b/include/strata/kernels/dequant_bf16.hpp @@ -0,0 +1,27 @@ +// include/strata/kernels/dequant_bf16.hpp - plan v0.3 P5: native GGUF blocks -> BF16 on the device, for the +// tensor-core GEMMs of batched prompt processing. +// +// One thread per 32-element group, transcribed from the reference `dequantize_row_*` functions of the pinned +// llama.cpp (`ggml/src/ggml-quants.c`, MIT): Q4_0 (2), Q5_0 (6), Q8_0 (8), Q3_K (11), Q4_K (12), Q5_K (13), Q6_K (14), +// IQ4_NL (20), IQ4_XS (23) and Q2_0 (42). A row-major (n_rows, n_cols) tensor of blocks becomes a row-major BF16 +// matrix; `n_cols` must be a multiple of the type's block size. +#pragma once + +#include + +namespace strata::kernels { + +bool dequant_bf16_supported(int ggml_type) noexcept; + +/// `rows` rows of `cols` values starting at block row `row0` (so a caller can dequantize a slice of a tensor). +void dequant_bf16(int ggml_type, const void* blocks, int64_t row0, int64_t rows, int64_t cols, uint16_t* out, + void* stream); + +/// The same into FP16 bits (the prompt path's quantized-weight GEMMs: Q2_0 values are exact in FP16). +void dequant_f16(int ggml_type, const void* blocks, int64_t row0, int64_t rows, int64_t cols, uint16_t* out, + void* stream); + +/// The same into FP32 (tests and small tensors). +void dequant_f32(int ggml_type, const void* blocks, int64_t row0, int64_t rows, int64_t cols, float* out, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/dequant_s2.hpp b/include/strata/kernels/dequant_s2.hpp new file mode 100644 index 00000000..2603f48d --- /dev/null +++ b/include/strata/kernels/dequant_s2.hpp @@ -0,0 +1,18 @@ +// include/strata/kernels/dequant_s2.hpp - the S2 decode, host-callable. +// +// S2 is Q2_0's canonical form and 31.64 GiB of the artifact is Q2_0, so this is the hottest decode in the +// engine. Plane layout is docs/pack-format.md's: codes packed 4 per byte LSB-first (element i at byte i/4, +// bits (i%4)*2) and one FP32 scale per group of 64. Decode is `(code - 1) * scale` with the subtraction in +// the INTEGER domain, which is what makes it bit-exact (docs/pack-format.md §3.1). +#pragma once + +#include + +namespace strata::kernels { + +// Decode `n_blocks` S2 blocks. `codes` is n_blocks*16 bytes, `scales` n_blocks floats, `out` n_blocks*64 +// floats. All device pointers. Synchronises before returning, so a caller can read `out` immediately - +// correctness first; overlap is a Phase 3 concern. +void dequant_s2(const uint8_t* codes, const float* scales, float* out, int64_t n_blocks); + +} // namespace strata::kernels diff --git a/include/strata/kernels/elementwise.hpp b/include/strata/kernels/elementwise.hpp new file mode 100644 index 00000000..65b9022c --- /dev/null +++ b/include/strata/kernels/elementwise.hpp @@ -0,0 +1,116 @@ +// include/strata/kernels/elementwise.hpp - the small ops a LAYER needs between its GEMVs, P2.S5. +// +// These are not interesting kernels and that is why they are in one file with one parity test: they are the +// glue in the per-layer graph, and glue that is written inline at each call site is glue with several +// slightly different float conventions. Three of them exist because a projection returns a DIFFERENT +// quantity from the one the next kernel wants: +// +// * `gdn_gate` turns (alpha, dt, ssm_a) into the recurrence's `gate`. `ref/gdn.py` L265: +// `gate = softplus(w_alpha . x + dt) * ssm_a`, with `softplus(x) = log1p(exp(x))` and the large-x branch +// `ggml_compute_softplus_f32` uses. `ssm_a` is NEGATIVE, which is what puts `exp(gate)` in (0,1). +// * `scale_inplace` applies the delta net's `1/sqrt(S)` to q. It is a separate op because +// `ref/gdn.py` says the two llama.cpp paths apply it in DIFFERENT places, so the caller has to own the +// choice rather than have it buried in a kernel. +// * `f32_to_f16_bulk` is what lets the f32 arithmetic of the mixer feed the fp16-activation GEMVs. +// `docs/activation-contract.md` is explicit that this is NOT the contract for a quantized weight - it is +// the bridge between two stages that both already exist, and the contract decision is a separate one. +// +// `silu_inplace` is here because `shared_expert` has its own copy inline and `gdn` needs one for the conv +// output; a third copy would be two too many. +#pragma once + +#include + +namespace strata::kernels { + +/// Decode one canonical embedding row already selected by the caller. Codes are +/// packed low bits first (2, 4 or 8 bits); scales/offsets are FP32 per group. +/// The optional offset defaults to +0. Multiplication and addition round separately, +/// matching the former CPU decoder. Launches asynchronously on the supplied stream. +/// The caller validates positive, divisible dimensions and row/plane bounds. +void embedding_gather(const uint8_t* codes, const float* scales, const float* offsets, + int64_t n, int code_bits, int code_bias, int group_elems, + float* out, void* stream); + +/// `y[t][h] = softplus(alpha[t][h] + dt[h]) * ssm_a[h]`, one thread per (t, h). In place is not possible: +/// `alpha` is read and `gate` is written, and they are different buffers by construction. +/// +/// `h_v` is the number of v heads (48) and `n_tokens` the leading dimension; the real call is (1, 48). +void gdn_gate(const float* alpha, const float* dt, const float* ssm_a, float* gate, int64_t n_tokens, + int64_t h_v, void* stream); + +/// `x[i] *= s`, in place. +void scale_inplace(float* x, int64_t n, float s, void* stream); +/// `dst[i] += src[i]`. **ADDED FOR R4's HIT/MISS SPLIT, WHERE THE TWO HALVES COME FROM DIFFERENT +/// ENGINES.** The CPU writes the misses into `parts` and the GPU writes the resident experts into a +/// buffer of their own, so that the GPU's work can run CONCURRENTLY with the CPU's instead of after it - +/// and the two are summed here, on the stream, immediately before `moe_combine` reads the result. +/// +/// The alternative - having the hit kernel write straight into `parts` - costs the whole overlap, because +/// the copy of the misses into `parts` cannot happen until the pool has produced them, and the kernel is +/// then ordered behind that copy. Measured: the drain fell 18.2 -> 10.2 ms and the token did not move. +void add_inplace(float* dst, const float* src, int64_t n, void* stream); + +/// `y[i] = f16(x[i])`, round-to-nearest-even, using the shared conversion in `f16_bits.hpp`. +void f32_to_f16_bulk(const float* x, uint16_t* y, int64_t n, void* stream); + +/// `y[i] = bf16(x[i])`, for the weights whose contract is a bf16 activation - the BF16 `hc_*`, `ssm_alpha`, +/// `ssm_beta`, `indexer.*` and `ple_value` tensors. +/// +/// A SEPARATE ENTRY POINT FROM `f32_to_f16_bulk` and not a flag on it, because the two conversions are not +/// variants of one idea: fp16 has 5 exponent bits and 10 mantissa bits, bf16 has 8 and 7. A caller that +/// picked the wrong one would get a plausible tensor at the wrong precision - 2^-11 against 2^-9, a factor of +/// four - and nothing downstream could tell which was intended. +void f32_to_bf16_bulk(const float* x, uint16_t* y, int64_t n, void* stream); + +/// `x[i] = x[i] / (1 + exp(-x[i]))`, in place. +void silu_inplace(float* x, int64_t n, void* stream); + +/// `build_norm`: `y[r][c] = x[r][c] / sqrt(MEAN_c(x[r]^2) + eps) * w[c]`, over the LAST axis. +/// +/// QSA's norm, used on `attn_q` (24x256), `attn_k` (2x256) and `indexer.q_proj` (4x128). `w` may be null. +/// +/// **IT IS NOT `gdn_l2_norm` AND THE DIFFERENCE IS A FACTOR OF sqrt(cols) = 16 HERE.** `gdn_l2_norm` divides by +/// `sqrt(SUM(x^2) + eps)` and applies no weight; this divides by `sqrt(MEAN(x^2) + eps)` and multiplies by `w`. +/// Two kernels whose names both say "norm" and whose bodies differ in one `/ cols` is exactly the pair that +/// gets swapped, and swapping them produces a well-scaled, plausible tensor - so they are separate entry +/// points and the parity test asserts the two readings are OBSERVABLE apart rather than trusting the names. +/// +/// There is NO `(1 + w)` folding: `ref/qsa.py::rms_norm` is `y * w`, and `build_norm` in the source is +/// `ggml_rms_norm` followed by a plain multiply. Some norms in this artifact DO fold (the GDN path records +/// which); this one does not, and the same `sqrt(cols)`-sized argument applies to getting it wrong. +void rms_norm_weighted(float* x, const float* w, int64_t rows, int64_t cols, float eps, void* stream); + +/// *seq = v in MAPPED PINNED memory - THE DOORBELL. A KERNEL and not cudaEventRecord, because round 199 +/// found that an event record inside a capture is SILENTLY DROPPED while a kernel is captured normally, and +/// round 209 measured the host seeing this one's result 0.050 ms into a 39.8 ms graph. +/// +/// It lives here because the caller that needs it - moe_layer - is a HOST translation unit, where +/// __global__ and <<<>>> do not exist. +/// *seq += 1 - AN INCREMENT, NOT A STORE OF A CALLER-SUPPLIED VALUE. A value passed in is evaluated on the +/// HOST at CAPTURE time, so a captured graph would ring the same number on every replay. Reading and +/// incrementing in memory is what makes the count advance under cudaGraphLaunch. +void doorbell_ring(uint32_t* d_seq, void* stream); + +/// Plan v0.3 P3 token graph: one thread spins until the host-written `*d_flag` equals the ring value `*d_seq` +/// (both mapped pinned memory), then fences so everything the host wrote before the flag is visible to the +/// nodes that follow. Captured once per layer between `pre[l]` and the parts copy; it is what lets one graph +/// hold the whole token (bench/micro/device_wait.cu: 5.2 us per handoff, no driver call). +void doorbell_wait(const uint32_t* d_flag, const uint32_t* d_seq, void* stream); + +/// Plan v0.3 P3 token graph: copy `n` floats from MAPPED pinned host memory (`src` is its device pointer) into +/// device memory with a kernel, so the handoff stays on the compute queue (a memcpy node is a copy-engine +/// operation, which WDDM submits separately and which measured 67 flushes per token). +void copy_from_mapped(float* dst, const float* src, int64_t n, void* stream); + +/// Plan v0.3 P3: the doorbell's payload and its ring in ONE kernel. Copies `x` (n floats), `ids` and `weights` +/// (k each) into the mapped host regions, fences, and increments the mapped sequence number - replacing three +/// device-to-host memcpy nodes (copy-engine operations in the middle of the layer chain) and the ring kernel. +void doorbell_publish(const float* x, const int32_t* ids, const float* weights, int64_t n, int64_t k, float* x_out, + int32_t* ids_out, float* weights_out, uint32_t* d_seq, void* stream); + +/// Plan v0.3 P3: copy `n` int32 from mapped pinned host memory into device memory with a kernel (the QSA +/// per-token step and positions), instead of a host-to-device memcpy node in the middle of a layer. +void copy_i32_from_mapped(int32_t* dst, const int32_t* src, int64_t n, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/f16_bits.hpp b/include/strata/kernels/f16_bits.hpp new file mode 100644 index 00000000..4f3e8fa6 --- /dev/null +++ b/include/strata/kernels/f16_bits.hpp @@ -0,0 +1,96 @@ +// include/strata/kernels/f16_bits.hpp - the fp16 conversions, written out, as bits. +// +// WHY THIS IS NOT `__float2half`. Round 193 found `__float2half` producing 0x2600 for a value whose correct +// fp16 encoding is 0x26DB, and 0x3C01 for a value that is exactly 1.0 - i.e. wrong bits, not a rounding +// difference. The cause was never isolated (it is either `__float2half` itself or `__half_as_ushort`), so +// `src/kernels/cuda/quantize_act.cu` replaced it with the version below, which then came out byte-exact over +// all 20,480 blocks of the fixture. `shared_expert.cu` carries the same function privately for the same +// reason. Three copies of a converter whose failure mode is silent wrong bits is two copies too many, so the +// copy lives here and the kernel and its TEST both include it - with the test checking it against vectors +// generated by numpy's float16 (an oracle that shares no code with this), which is what neither private copy +// had. +// +// The rounding is ROUND-TO-NEAREST-EVEN, which is what numpy's `.astype(np.float16)` does and what IEEE-754 +// specifies for the default mode. The three interesting regimes are all handled explicitly and all three are +// covered by the test's oracle vectors: +// +// * normal - exponent fits, mantissa is rounded from 13 surviving bits with a sticky remainder +// * subnormal - the implicit 1 is restored and the result is shifted right, so the rounding point MOVES +// * overflow - exp >= 31 saturates to inf (0x7C00) and keeps the sign; a NaN input stays a NaN +// +// `f32_from_f16` deliberately does NOT round: it is used to compare against a reference that holds the same +// bits, so it only has to be exact. +#pragma once + +#include +#include + +#if defined(__CUDACC__) +#define STRATA_HD __host__ __device__ +#else +#define STRATA_HD +#endif + +namespace strata::kernels { + +/// Round-to-nearest-even f32 -> fp16, returned as raw bits. +STRATA_HD inline uint16_t f16_from_f32(float f) { + uint32_t x; + std::memcpy(&x, &f, 4); + const uint32_t sign = (x >> 16) & 0x8000u; + const uint32_t rawexp = (x >> 23) & 0xFFu; + int exp = (int) rawexp - 127 + 15; + uint32_t man = x & 0x7FFFFFu; + // TWO different things reach here and they must not be conflated: an f32 inf/NaN (raw exponent 255) stays + // an inf/NaN, and a FINITE value too large for fp16 (1e30) SATURATES TO INF. The version of this function + // in quantize_act.cu and shared_expert.cu tests `if (exp >= 31) return sign | 0x7C00 | (man ? 0x200 : 0)` + // and therefore returns a NaN for every overflowing finite value - which their own fixtures cannot see, + // because all of their values are O(1). Round 198's numpy oracle caught it here on the first run (1e30, + // 65536, 1e45: numpy says inf, the conflated test said NaN). + if (rawexp == 0xFFu) return (uint16_t) (sign | 0x7C00u | (man ? 0x200u : 0u)); // inf, or a quiet/NaN + if (exp >= 31) return (uint16_t) (sign | 0x7C00u); // finite overflow -> inf + if (exp <= 0) { + if (exp < -10) return (uint16_t) sign; // underflows to a signed zero + man |= 0x800000u; // restore the implicit 1 + const uint32_t sh = (uint32_t) (14 - exp); + uint32_t h = (man >> sh) & 0x3FFu; + const uint32_t rem = man & ((1u << sh) - 1u); + if (rem > (1u << (sh - 1)) || (rem == (1u << (sh - 1)) && (h & 1u))) ++h; + return (uint16_t) (sign | h); + } + uint16_t h = (uint16_t) (sign | ((uint32_t) exp << 10) | (man >> 13)); + const uint32_t rem = man & 0x1FFFu; + if (rem > 0x1000u || (rem == 0x1000u && (h & 1u))) ++h; // the carry may bump the exponent + return h; +} + +/// Exact fp16 bits -> f32. No rounding, no exceptions: every one of the 65,536 inputs maps to a value f32 +/// represents exactly, including the subnormals (which is why the `ex == 0` case cannot be skipped). +STRATA_HD inline float f32_from_f16(uint16_t h) { + const uint32_t sign = (uint32_t) (h & 0x8000u) << 16; + const uint32_t ex = (h >> 10) & 0x1Fu, man = h & 0x3FFu; + uint32_t out; + if (ex == 0) { + // Subnormal or zero. The value is `man * 2^-24`, so after `s` left shifts the leading 1 reaches bit + // 10 and the value is `(1 + frac) * 2^(-14-s)`: the f32 exponent field is 113 - s and `frac` is the low + // ten bits. Flushing these to zero - which the two private copies of this function in the tree do - + // maps 0x0001 to 0.0 instead of 2^-24, a 100% relative error on any KL-quant scale that lands here. + if (man == 0) { + out = sign; + } else { + uint32_t m = man; + int s = 0; + while ((m & 0x400u) == 0) { m <<= 1; ++s; } + out = sign | ((uint32_t) (113 - s) << 23) | ((m & 0x3FFu) << 13); + } + } else if (ex == 31) { + out = sign | 0x7F800000u | (man << 13); + } else { + out = sign | ((ex - 15 + 127) << 23) | (man << 13); + } + float f; + std::memcpy(&f, &out, 4); + return f; +} + +} // namespace strata::kernels diff --git a/include/strata/kernels/fused_gdn.hpp b/include/strata/kernels/fused_gdn.hpp new file mode 100644 index 00000000..0cf7c66c --- /dev/null +++ b/include/strata/kernels/fused_gdn.hpp @@ -0,0 +1,35 @@ +// include/strata/kernels/fused_gdn.hpp - plan v0.3 P3: the gated delta-net step and its output norm in ONE kernel. +// +// The native step (after llama.cpp's gated_delta_net.cu) gives each warp one (head, column) and strides its 32 +// lanes over the 128 ROWS of the state, which is stored `[row][head][col]`: every lane's load is 24.5 KB from its +// neighbour's, so each 32 B sector delivers 4 useful bytes - 47.6 us per layer for 6.3 MB of state traffic. Here a +// block owns one whole head, its 512 threads are (row group of 32, column), and a row's 128 columns are one +// contiguous 512 B load. With the head in one block, the per-head RMS norm, the norm weight and the sigmoid(z) +// gate of `gdn_out_norm` run in the same kernel. +// +// S <- g S + k (beta (v - g S^T k))^T (g = exp(gate[head]), per value head; q/k shared by h_v / h_k heads) +// o = (S^T q) / sqrt(128) +// y = rmsnorm(o) * gamma * sigmoid(z) +#pragma once + +#include + +namespace strata::kernels { + +/// The 4-tap causal conv + SiLU over all `channels` (history is `[channel][3]`, updated), then the L2 norm of +/// each of the first `qk_heads` 128-channel heads (q then k), eps as the native `l2_norm` (x / sqrt(sum + eps)). +/// One block per 128-channel head; replaces conv_silu + two l2_norm launches. +void fused_gdn_conv_l2(float* history, const float* qkv, const float* conv_w, float* h, int channels, int qk_heads, + float eps, void* stream); + +/// alpha and beta, both `(h_v, n_embd)` BF16 against the FP32 activation, with their epilogues: +/// gate = softplus(alpha + dt) * ssm_a, beta = sigmoid(beta). One warp per row; replaces two MMVF launches and +/// two elementwise kernels. +void fused_gdn_ab(const float* x, const uint16_t* w_alpha, const uint16_t* w_beta, const float* dt, const float* ssm_a, + float* gate, float* beta, int n_embd, int h_v, void* stream); + +void fused_gdn_step_norm(float* state, const float* q, const float* k, const float* v, const float* gate, + const float* beta, const float* z, const float* gamma, float eps, float* y, int h_k, int h_v, + void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/fused_gr.hpp b/include/strata/kernels/fused_gr.hpp new file mode 100644 index 00000000..e3136a8f --- /dev/null +++ b/include/strata/kernels/fused_gr.hpp @@ -0,0 +1,51 @@ +// include/strata/kernels/fused_gr.hpp - plan v0.3 P3: the hyper-connection read in TWO kernels, with the +// previous half's write folded in. +// +// The native path spends six kernels per `gr_read` (norm, down MMVF, silu, up MMVF, gate+mean, inject MMVF) and +// one per `gr_write`, 96 + 96 times per token, and its up projection (10240 rows of 320) runs one 160-thread +// block per row: 20.6 us for 6.5 MB. Here: +// +// fused_gr_down : R' = R + bo_prev * 2 sigmoid(inj_prev / hc) (only when `apply`, computed on the fly) +// rs[c] = rsqrt(mean(R'[c]^2) + eps), xn = R' * w_norm * rs +// lo[k] = silu((w_down[k] . xn) / hc) k < hc_lr +// inject[c] = w_inject[c] . xn when w_inject is given +// fused_gr_up : R <- R' in place for this block's columns (when `apply`) +// mixed[d] = mean_c xn[c,d] * sigmoid(w_up[c*n_embd + d] . lo) +// +// FP32 activations and BF16 weights, like the native MMVF contract; the summation order differs from it (G-C +// judges the result). Geometry is the artifact's: n_embd 2560, hc 4, hc_lr 320. `inj_prev` and `inject_out` +// must be different buffers (every block reads the former while one block writes the latter). +#pragma once + +#include + +namespace strata::kernels { + +struct FusedGrArgs { + const float* R = nullptr; ///< (hc, n_embd), read by `down`; `up` updates it in place when apply + float* R_out = nullptr; ///< == R for the in-place update + bool apply = false; ///< fold the previous half's gr_write + const float* bo_prev = nullptr; ///< that half's block output, n_embd + const float* inj_prev = nullptr; ///< that half's injection, hc + const float* w_norm = nullptr; ///< (hc * n_embd) f32 + const uint16_t* w_down = nullptr; ///< bf16 [hc_lr][hc*n_embd] + const uint16_t* w_up = nullptr; ///< bf16 [hc*n_embd][hc_lr] + const uint16_t* w_inject = nullptr;///< bf16 [hc][hc*n_embd], or null (the final mixer) + float eps = 1e-6f; + float* lo = nullptr; ///< workspace, hc_lr floats + float* rs = nullptr; ///< workspace, hc floats + float* inject_out = nullptr; ///< hc floats (when w_inject) + float* mixed = nullptr; ///< n_embd +}; + +bool fused_gr_supported(int64_t n_embd, int64_t hc, int64_t hc_lr); +void fused_gr_read(const FusedGrArgs& a, void* stream); + +/// Plan v0.3 P6: the same read for up to 8 tokens that share the weights (a verify window): the weights are read +/// once for all of them. `a[t]` is token t's arguments (its own R, pending write, lo, rs, inject, mixed; the four +/// weight pointers and eps must be the same for every t); `xn_scratch` is n_tok * hc * n_embd floats. Every +/// token's outputs are bitwise `fused_gr_read(a[t])`. +constexpr int kFusedGrMaxT = 8; +void fused_gr_read_multi(const FusedGrArgs* a, int n_tok, float* xn_scratch, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/gdn.hpp b/include/strata/kernels/gdn.hpp new file mode 100644 index 00000000..898e2a85 --- /dev/null +++ b/include/strata/kernels/gdn.hpp @@ -0,0 +1,97 @@ +// include/strata/kernels/gdn.hpp - the gated delta-net (GDN) layer's non-projection parts, P2.S2. +// +// Transcribed from `ref/gdn.py`, which is itself transcribed from `build_layer_attn_linear` and +// `build_delta_net_autoregressive`. The projections (`wqkv`, `wqkv_gate`, `ssm_out`) are the existing +// `s_gemv`/`s2_gemv` kernels; what lives here is everything BETWEEN them. +// +// Geometry of the real artifact (48 layers are GDN, 12 are QSA): +// +// S = state_size = 128 head_k_dim == head_v_dim +// h_k = 16 k/q heads h_v = 48 v heads (idx[h] = h % h_k) +// C = 2*128*16 + 128*48 = 10240 channels d_conv = 4 +// +// STATE LAYOUT. This is the one place the kernels deliberately differ from the reference, and the reason is +// coalescing. `ref/gdn.py` carries the state as (S, S, H_v) = (i, j, h); here it is **(S, H_v, S)** = +// (i, h, j), so that for a fixed i a warp walking consecutive j reads CONTIGUOUS memory. The state is a +// runtime buffer, not a weight, so this costs nothing to choose - but it must be said, because the two +// layouts index the same three numbers and a mismatch is silent. +// +// WHAT MAKES THE RECURRENCE PARALLEL, and it is worth stating because it is not obvious: +// +// dec = exp(gate) (h_v,) +// st = state * dec +// sk[h,j] = sum_i st[i,j,h] * k[idx[h]][i] +// d[h,j] = (v[h,j] - sk[h,j]) * beta[h] +// st[i,j,h] += k[idx[h]][i] * d[h,j] +// o[h,j] = sum_i st[i,j,h] * q[idx[h]][i] +// +// Every line touches ONLY the (j, h) column. Nothing couples one column to another, so one thread can own a +// column for the whole step and no barrier is needed anywhere in the recurrence. (The decay must still be +// applied BEFORE the rank-1 update - `ref/gdn.py` PROPERTY 5 pins that, and the test re-pins it here.) +#pragma once + +#include + +namespace strata::kernels { + +/// Geometry of one GDN layer. The real artifact: S = 128, h_k = 16, h_v = 48. +struct GdnShapes { + int64_t S = 0; ///< head dim, and the state's contracted axis + int64_t h_k = 0; ///< key/query heads (q and k are (h_k, S)) + int64_t h_v = 0; ///< value heads (v, z, o are (h_v, S)); h_v must be a multiple of h_k +}; + +/// One step of the delta-rule recurrence, IN PLACE on `state`. +/// +/// state (S, h_v, S) f32, j fastest, updated in place +/// q, k (h_k, S) already L2-normalised, and q ALREADY SCALED by 1/sqrt(S) - the two llama.cpp paths +/// apply that scale in different places, so the caller owns it +/// v (h_v, S) +/// gate (h_v,) the PRE-exp value; `dec = exp(gate)` is formed here +/// beta (h_v,) +/// o (h_v, S) +/// +/// The head pairing is `h % h_k` (MODULO), not `h / (h_v/h_k)` (INTERLEAVE). `ref/gdn.py` PROPERTY 8 and 11 +/// pin this and the test re-pins it, because both conventions produce well-formed output. +void gdn_step(float* state, const float* q, const float* k, const float* v, const float* gate, + const float* beta, float* o, const GdnShapes& s, void* stream); + +/// `ggml_ssm_conv` with the carried state prepended. Updates `conv_state` in place. +/// +/// conv_state (d_conv-1, channels) the previous d_conv-1 input columns, OLDEST FIRST +/// x (channels,) the new column +/// kW (channels, d_conv) the weight in GGML-NATIVE layout: `kW[c*d_conv + i]` is tap i of +/// channel c. The manifest gives `ssm_conv1d.weight` ne = [4, 10240], +/// i.e. ne0 = d_conv is the FAST axis, which is this and NOT the +/// reference's row-major (d_conv, channels). +/// out (channels,) +/// +/// out[c] = sum_{i +#include + +namespace strata::kernels { + +/// Select the activation precision for subsequent gr_read launches (default: false/BF16). +/// Configure before creating/capturing a session, and do not mutate concurrently with gr_read. +/// A captured graph retains the kernel variants chosen at capture; changing this flag cannot alter it. +/// This changes activation precision only; the existing FP32 reduction trees remain unchanged. +void gr_set_fp32_activations(bool enabled); + +/// Select pinned single-token BF16/F32 MMVF projections for subsequent gr_read launches (default false). +/// This implies FP32 activations regardless of gr_set_fp32_activations. Configure before session capture; +/// a captured graph retains its selected kernels. Requires even hc*n_embd and hc_lr, as MMVF reads pairs. +/// This also selects pinned weighted F32 RMSNorm and nonlinear/combine arithmetic. +/// Layer reads use fused HC pre; the final mixer (null w_inject) retains its unfused graph order. +/// Subsequent gr_write calls select pinned sigmoid/scatter arithmetic under the same setting. +void gr_set_native_mmvf(bool enabled); + +/// Geometry of the hyper-connection. The real model: n_embd = 2560, hc = 4, hc_lr = 320. +struct GrShapes { + int64_t n_embd = 0; ///< width of one residual stream + int64_t hc = 0; ///< number of streams in the stack + int64_t hc_lr = 0; ///< rank of the down/up bottleneck +}; + +/// Scratch for `gr_read`. EXPLICIT and caller-owned, because P2.T10 requires ZERO token-path allocations: +/// a hidden `cudaMalloc` on first use would satisfy every test here and fail the memory test in P2.S10. +/// +/// **IT MUST BE DEVICE MEMORY.** `gr_workspace_init` takes a raw pointer and does no checking, so handing it +/// a host buffer produces device kernels writing to a truncated host address - an illegal access whose +/// reported fault address is the low 32 bits of a host pointer, which reads as a wild pointer rather than as +/// the mistake it is. That cost one round in `gr_parity`; +/// +/// xn hc*n_embd floats the unrounded activated streams (the gated mean needs the unrounded value) +/// xq hc*n_embd uint16 bf16(xn), used by the default activation contract +/// lq hc_lr uint16 bf16(lo), used by the default activation contract +/// gated hc*n_embd floats xn * sigmoid(gate), before the mean over streams +/// lo hc_lr floats SiLU bottleneck, used by the FP32 activation contract +/// +/// `cudaMalloc(gr_workspace_bytes(s))`, then `gr_workspace_init(s, ptr, ws)` to carve it. +struct GrWorkspace { + float* xn = nullptr; + uint16_t* xq = nullptr; + uint16_t* lq = nullptr; + float* gated = nullptr; + float* lo = nullptr; + /// Set by `gr_workspace_init`. `gr_read` CHECKS it, so an under-sized buffer is a loud failure at the + /// first call instead of silent corruption - which is what a mis-ordered region table used to produce. + size_t bytes = 0; +}; + +/// Carves `base` (which must be DEVICE memory, or null to size only) into `out` and returns the bytes needed. +/// 16-byte aligned between regions. +size_t gr_workspace_init(const GrShapes& s, void* base, GrWorkspace& out); +inline size_t gr_workspace_bytes(const GrShapes& s) { GrWorkspace w; return gr_workspace_init(s, nullptr, w); } + +/// `build_hc_mix`. ONE token: `R` is (hc, n_embd) with n_embd fastest, i.e. stream c starts at c*n_embd. +/// +/// w_norm (hc*n_embd) per-stream RMSNorm gamma, STORED AS (1 + w) by the converter +/// w_down (hc_lr, hc*n_embd) MANIFEST LAYOUT - row k is contiguous, `w_down[k*hc*n_embd + i]` +/// w_up (hc*n_embd, hc_lr) MANIFEST LAYOUT - row i is contiguous, `w_up[i*hc_lr + k]` +/// w_inject (hc, hc*n_embd) null for the FINAL mixer, which has no write-back to gate +/// +/// mixed (n_embd) = mean over streams of xn * sigmoid(bf16(lo) @ w_up.T) +/// inject (hc) = bf16(xn) @ w_inject.T, or untouched when w_inject is null +/// +/// `lo = silu((bf16(xn) @ w_down.T) / hc)` - the division by hc is INSIDE the silu, which is easy to read +/// past and changes the result materially. +/// With gr_set_fp32_activations(true), replace bf16(xn) and bf16(lo) by xn and lo respectively. +/// +/// **NO PERMUTATION: both matrices are taken exactly as the manifest stores them**, so the loader does nothing +/// to them. Both are coalesced because each projection assigns ONE WARP PER OUTPUT ROW and strides the +/// reduction axis across its 32 lanes: for a fixed output, consecutive lanes touch consecutive addresses in +/// either orientation. An earlier version assigned one THREAD per row, which is coalesced with itself and 32 +/// transactions away from its warp neighbours - see the history below. +/// +/// **HISTORY, because this kernel was the engine's worst and the reason is instructive.** The first version +/// was launched `<<<1, 256>>>` with `xn`/`xq` in shared memory, and at the real geometry it cost **2.67 ms per +/// call, 96 calls per token = 262 ms/token, about 3.8 tok/s, 134x off the 643 GB/s memory floor.** The cause +/// was isolated by elimination: NOT memory latency (four independent accumulator chains made it 17% SLOWER), +/// NOT the SM clock (`nvidia-smi` reported 2917 MHz and 99% utilisation throughout), and only partly +/// coalescing (transposing one of the two matrices gave 31% and transposing the other gave nothing). It was +/// ISSUE THROUGHPUT - ~8.4M iterations of 6-8 instructions issued by ONE of 48 SMs. +/// +/// This version splits the work the way the shapes want: `hc` blocks for the per-stream norm, one warp per +/// row for each of the two projections, and one block-tile for the mean. The transposes are gone because the +/// warp-per-row mapping makes them unnecessary. +void gr_read(const float* R, const float* w_norm, const uint16_t* w_down, const uint16_t* w_up, + const uint16_t* w_inject, float eps, const GrShapes& s, const GrWorkspace& ws, float* mixed, + float* inject, void* stream); + +/// `build_hc_combine`. `R_out[i] = R[i] + block_out[d] * w[c]` with `w[c] = 2*sigmoid(inject[c]/hc)`. +/// +/// The `2*sigmoid` is what CENTRES the gate on 1, so a zero injection is a plain residual add - the source +/// comment says so outright and `gr_write`'s test asserts it. Note the block output is added to EVERY stream +/// identically; only the weight is per-stream. +void gr_write(const float* R, const float* block_out, const float* inject, const GrShapes& s, float* R_out, + void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/iq_kernels.hpp b/include/strata/kernels/iq_kernels.hpp new file mode 100644 index 00000000..020d78d5 --- /dev/null +++ b/include/strata/kernels/iq_kernels.hpp @@ -0,0 +1,57 @@ +// include/strata/kernels/iq_kernels.hpp - the i-quant formats (IQ1_M, IQ2_XXS, IQ2_XS, IQ2_S, IQ3_XXS, IQ3_S, +// IQ4_NL) and Q2_0 on the GPU for the IQ2_XS / IQ3_XXS model files. +// +// The block layouts, codebook grids and dot products are llama.cpp's (ggml-common.h, ggml-cuda/vecdotq.cuh, +// ggml-cuda/dequantize.cuh; MIT, see third_party/ggml/LICENSE and VERSION.txt), so a weight means exactly what it +// means in llama.cpp. Activations are q8_1 (32 values, fp16 scale and fp16 sum), the llama.cpp CUDA contract. +#pragma once + +#include +#include + +namespace strata::kernels { + +/// ggml type ids handled here. +bool iq_supported(int ggml_type) noexcept; +/// Bytes of one row of `n` values of `ggml_type` (n a multiple of the type's block). +size_t iq_row_bytes(int ggml_type, int64_t n) noexcept; + +/// q8_1 blocks for `n_rows` rows of `n_cols` floats (n_cols a multiple of 32): y is n_rows * n_cols/32 blocks. +void quantize_q8_1_rows(const float* x, int64_t n_rows, int64_t n_cols, void* y, void* stream); + +/// y[c][r] = W[r] . x[c] for `ncols` columns of q8_1 activations (x stride n_in/32 blocks per column). +void iq_mmvq(int ggml_type, const void* w, const void* x_q8_1, float* y, int n_in, int n_out, int ncols, void* stream); + +/// Dequantize `n` contiguous values (n a multiple of 256) to fp16 / fp32. +void iq_dequant_f16(int ggml_type, const void* src, int64_t n, uint16_t* dst, void* stream); +void iq_dequant_f32(int ggml_type, const void* src, int64_t n, float* dst, void* stream); +/// Rows `tokens[0..n_tok)` (device ids) of a GGUF embedding table (`row_bytes` per row; the table may be mapped +/// host memory) dequantized to fp32, `n_embd` per row (a multiple of 256). +void iq_embed_rows(int ggml_type, const void* table, size_t row_bytes, const int32_t* tokens, int64_t n_tok, + int64_t n_embd, float* out, void* stream); +/// One expert's gate and up matrices (n_ff rows of n_embd each) into the interleaved fp16 layout the prompt path +/// uses: row 2r = gate row r, row 2r+1 = up row r. +void iq_dequant_gu_f16(int ggml_type, const void* gate, const void* up, int64_t n_ff, int64_t n_embd, uint16_t* dst, + void* stream); + +/// The layout of one native expert blob: [gate rows | up rows | down rows], raw GGUF blocks. +struct NativeExpertLayout { + int gu_type = -1, d_type = -1; + int64_t n_embd = 0, n_ff = 0; + size_t gu_row = 0, d_row = 0; // bytes per row + size_t up_off = 0, down_off = 0; // byte offsets inside the blob + size_t bytes = 0; // the whole blob +}; +NativeExpertLayout native_expert_layout(int gu_type, int d_type, int64_t n_embd, int64_t n_ff); + +/// Bytes of scratch `native_expert_grouped` needs for `cap_entries` entries. +size_t native_expert_scratch_bytes(int64_t cap_entries, int64_t n_ff); + +/// Grouped experts in the native format: group g's blob at device address grp_ptr[g]; its entries +/// [grp_start[g], grp_start[g+1]) read token ent_tok[e]'s q8_1 activation (n_embd/32 blocks per token in x_q8_1) +/// and write row ent_dst[e] of `out` (n_embd floats). Counts are read on the device. +void native_expert_grouped(const NativeExpertLayout& L, const unsigned long long* grp_ptr, const int32_t* grp_start, + const int32_t* n_groups, const int32_t* ent_dst, const int32_t* ent_tok, int64_t cap_groups, + int64_t cap_entries, const void* x_q8_1, void* scratch, float* out, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/kv_q8.hpp b/include/strata/kernels/kv_q8.hpp new file mode 100644 index 00000000..f6d5045e --- /dev/null +++ b/include/strata/kernels/kv_q8.hpp @@ -0,0 +1,38 @@ +// include/strata/kernels/kv_q8.hpp - plan v0.3 P7: 8-bit KV storage for the QSA layers. +// +// The FP16 KV pools cost 2 x 12,288 bytes per token over the 12 QSA layers; with the FP32 pooled indexer keys the +// engine allocates 29 KB per token, 7.65 GB at 262K (bench/results/2026-09-23-p7-int-audit), which cannot fit +// 12 GB. This stores K and V as int8 codes with one FP16 scale per 64 values (per cell, per KV head): +// scale = fp16(max|x| / 127), code = clamp(rint(x / scale), -127, 127), x' = fp16(code * scale) +// Per layer and token: 2 KV heads x 256 values x (K, V) = 1,024 values -> 1,024 B of codes + 32 B of scales, +// instead of 2,048 B in FP16. Over the 12 QSA layers: 12,672 B/token instead of 24,576 (3.3 GB at 262K, not 6.4). +// +// Only STORAGE changes: `kv_gather_q8_step` dequantizes the selected cells into the same FP16 scratch the +// attention kernels already read, so `qsa_attend` and the native attention adapter are untouched. Layout mirrors +// the FP16 pools: codes `[page][kv_head][page_size][head_dim]`, scales `[page][kv_head][page_size][head_dim/64]`. +#pragma once + +#include "strata/kernels/qsa.hpp" + +#include + +namespace strata::kernels { + +inline constexpr int KV_Q8_GROUP = 64; + +/// Bytes per cell (one token, one layer): K and V codes plus their scales. +inline uint64_t kv_q8_bytes_per_cell(const QsaShapes& s) { + return (uint64_t) s.n_head_kv * s.head_dim * 2 + (uint64_t) s.n_head_kv * (s.head_dim / KV_Q8_GROUP) * 2 * 2; +} + +/// Append the cell at step[kStepPos] (graph-capturable: position and page come from device memory). +void kv_append_q8_step(int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale, const int32_t* page_table, + const int32_t* step, const float* kcur, const float* vcur, const QsaShapes& s, void* stream); + +/// Gather step[kStepWidth] cells named by `ids` into FP16 scratch `[id][kv_head][head_dim]`; the grid is sized by +/// `max_ids` (capacity), the kernel reads the real count from `step`. +void kv_gather_q8_step(const int8_t* k_q, const int8_t* v_q, const uint16_t* k_scale, const uint16_t* v_scale, + const int32_t* page_table, const int32_t* ids, const int32_t* step, int64_t max_ids, + const QsaShapes& s, uint16_t* k_scratch, uint16_t* v_scratch, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/mrope.hpp b/include/strata/kernels/mrope.hpp new file mode 100644 index 00000000..165f3edb --- /dev/null +++ b/include/strata/kernels/mrope.hpp @@ -0,0 +1,32 @@ +// strata/kernels/mrope.hpp - multimodal rotary positions (the vision path). +// +// qwen4exp rotates with interleaved M-RoPE (llama.cpp LLAMA_ROPE_TYPE_IMROPE, rope.dimension_sections 11/11/10/0): +// each of the 32 rotated pairs takes its position from one of three streams, time t, height h and width w. Text +// has t = h = w, which is why every rope kernel here takes one position per row. An image does not: its tokens +// share t and differ in h and w (mtmd: t = p, h = p + y, w = p + x), and the text after it continues at +// p + max(nx, ny), not at its cell index. +// +// The table: nullptr (the default) keeps every kernel exactly as before - the position a caller passes is the +// rotary position. Set, it is a DEVICE int32 [cells][3] (t, h, w) and the position a caller passes is read as a +// CELL index; pair i rotates by tab[cell * 3 + mrope_sector(i)]. Every caller in the engine passes cell indices +// (pos_base is 0 everywhere), so the table covers the prompt path, the verify window and the MTP drafter at once. +// The pointer must be set before any CUDA graph is captured (kernels take it as an argument); the contents may +// change between requests. +#pragma once + +#include + +namespace strata::kernels { + +void mrope_table_set(const int32_t* device_table); +const int32_t* mrope_table(); + +#if defined(__CUDACC__) +/// ggml rope_multi, is_imrope, sections {11, 11, 10, 0}: sector = pair % 32; sector % 3 == 1 -> h (sector < 33), +/// == 2 -> w (sector < 30), == 0 -> t (sector < 33). For pairs 0..31 all three bounds hold, so it is pair % 3. +__device__ __forceinline__ int mrope_pos(const int32_t* tab, int pos, int pair) { + return tab ? __ldg(tab + (size_t) pos * 3 + pair % 3) : pos; +} +#endif + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_flash_attn.hpp b/include/strata/kernels/native_flash_attn.hpp new file mode 100644 index 00000000..b517156e --- /dev/null +++ b/include/strata/kernels/native_flash_attn.hpp @@ -0,0 +1,36 @@ +#pragma once +#include "strata/kernels/qsa.hpp" + +namespace strata::kernels { + +enum NativeFlashAttnStatus : int32_t { + kNativeFlashAttnSuccess = 0, + kNativeFlashAttnUnsupportedStep = 1, +}; + +// Explicitly selected short-context adapter for pinned CUDA F16/F16 vector +// FlashAttention. Geometry is exactly Q24x256, KV2x256, one query/sequence, +// scale=1/16, no ALiBi/softcap/sink. No allocation, synchronization, or KV rewrite. +// q/output: contiguous [24,256] F32. k/v: existing gathered [capacity,2,256] +// F16 buffers, with only step[kStepWidth] initialized rows required. Optional +// mask: 256 F16 additive logits, broadcast over heads; at least one valid key +// must be unmasked. Finite live q/k/v and finite or -inf live mask entries are +// preconditions; unused k/v/mask padding may contain arbitrary bytes. +// Padding is synthesized as zero K/V and -inf mask without reading it. +// +// capacity >=256; max_context in [1,256] is the HOST session limit. The device +// step must satisfy pos+1==n_kv==width in [1,max_context] and n_bid==n_kv/4. +// An invalid step writes status=UnsupportedStep and NaN output, without reading +// q/k/v/mask. status is one caller-owned device int32, overwritten on every call; +// integration MUST inspect it at its normal completion boundary. Host context +// limits must prevent unsupported steps before decoding. This is not a 20K path. +// +// All buffers (including optional mask and status) must have disjoint spans, +// with four-byte alignment for F32/step/status and two-byte alignment for F16. +// A nonnull explicit stream is mandatory. All pointers remain live for replay. +void native_flash_attn_short_step(const float* q, const uint16_t* k, const uint16_t* v, + const int32_t* step, int64_t capacity, int max_context, + const QsaShapes& shapes, float* output, int32_t* status, + const uint16_t* mask, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_gdn.hpp b/include/strata/kernels/native_gdn.hpp new file mode 100644 index 00000000..feebefe9 --- /dev/null +++ b/include/strata/kernels/native_gdn.hpp @@ -0,0 +1,22 @@ +#pragma once +#include "strata/kernels/gdn.hpp" + +namespace strata::kernels { + +// Configure before creating/capturing a session. Existing captures retain their +// selected kernel, so changing the flag does not change an existing graph. +void native_gdn_set_enabled(bool enabled); +bool native_gdn_enabled(); + +// Pinned CUDA gated_delta_net recurrence for one token, S=128 and h_v % h_k=0. +// q/k are normalized F32 inputs; q MUST NOT be pre-scaled by 1/sqrt(S). +// Scaling is applied to the completed readout, as in the pinned fused operator. +// State retains Strata's layout: state[(i*h_v+h)*S+j]. The caller owns it and +// output; both must be disjoint from each other and from every read-only input. +// All spans must be aligned to four bytes. A nonnull ordered stream is required. +// No allocation, wait, or change to normalization/gate preprocessing occurs. +void native_gdn_step(float* state, const float* q, const float* k, const float* v, + const float* gate, const float* beta, float* output, + const GdnShapes& shapes, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_gdn_preprocess.hpp b/include/strata/kernels/native_gdn_preprocess.hpp new file mode 100644 index 00000000..58334f42 --- /dev/null +++ b/include/strata/kernels/native_gdn_preprocess.hpp @@ -0,0 +1,37 @@ +#pragma once +#include + +namespace strata::kernels { + +// Optional pinned CUDA GDN preprocessing. The layer selects these functions with +// native_gdn_enabled(); direct callers explicitly select them by calling them. +// Require a nonnull ordered stream, finite inputs, and four-byte aligned spans. +// No function allocates or synchronizes. All pointers are caller-owned. + +// Four-tap convolution, then FP32 fast-math SiLU. History is [channels,3], oldest +// first; weights are [channels,4]. Both outputs are required, mutually disjoint, +// and disjoint from input/weights/history. History must not alias other spans. +void native_gdn_conv_silu(float* history, const float* input, const float* weights, + float* raw_output, float* silu_output, int64_t channels, + int64_t d_conv, void* stream); + +// In-place [rows,128]: scale(rms_norm(x,epsilon/128),1/sqrt(128)). This includes +// the L2 normalization scale, but NOT the recurrence's readout scale. +// Epsilon is finite/nonnegative; epsilon=0 additionally requires nonzero rows. +void native_gdn_l2_norm(float* input, int64_t rows, int64_t cols, float epsilon, void* stream); + +// In-place sigmoid over per-head beta; one token. +void native_gdn_beta_gate(float* beta, int64_t heads, void* stream); + +// One token: gate[h] = softplus(alpha[h]+dt[h])*ssm_a[h], using the pinned CUDA +// log(1+exp(x)) expression and threshold 20. Output is disjoint from inputs. +void native_gdn_gate(const float* alpha, const float* dt, const float* ssm_a, + float* gate, int64_t heads, void* stream); + +// [heads,128]: rms_norm(output,epsilon)*gamma*sigmoid(z). Gamma contains only +// 128 floats and broadcasts across heads. Destination is disjoint from inputs. +void native_gdn_out_norm(const float* output, const float* z, const float* gamma, + float* destination, int64_t heads, int64_t cols, + float epsilon, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_gr_norm.hpp b/include/strata/kernels/native_gr_norm.hpp new file mode 100644 index 00000000..b8bb4ed1 --- /dev/null +++ b/include/strata/kernels/native_gr_norm.hpp @@ -0,0 +1,18 @@ +#pragma once + +namespace strata::kernels { + +// Pinned llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d CUDA weighted F32 +// RMSNorm for contiguous [n_cols, n_rows] data and equally shaped F32 gamma. +// Every row has independent norm statistics. Output is (scale * input) * gamma. +// Uses 256 threads below n_cols=1024, otherwise 1024, with the pinned XOR tree. +// Compile the implementation with --use_fast_math; no FP64, allocation, or wait. +// All buffers are caller-owned, nonoverlapping, and at least four-byte aligned. +// Require positive dimensions and finite nonnegative epsilon. Inputs/gamma and +// intermediate sums must be finite; epsilon=0 additionally requires nonzero rows. +// Enqueues on the supplied CUDA stream. A null handle explicitly selects CUDA's +// default stream, preserving gr_read's existing API; this function never syncs. +void native_gr_rms_norm_weighted(const float* input, const float* gamma, float* output, + int n_cols, int n_rows, float epsilon, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_gr_postops.hpp b/include/strata/kernels/native_gr_postops.hpp new file mode 100644 index 00000000..790b883c --- /dev/null +++ b/include/strata/kernels/native_gr_postops.hpp @@ -0,0 +1,28 @@ +#pragma once + +namespace strata::kernels { + +// Pinned llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d contiguous F32 +// single-token GR post-operations. Explicit stream, no allocation or wait; null +// selects the default stream. Positive dimensions with n*hc <= INT_MAX, finite +// values/intermediates, and four-byte alignment are required. +// Buffers must not overlap except the explicitly in-place arguments below. +// Implementation compiles separately with --use_fast_math. + +// In-place pinned SCALE(1/hc), then SiLU, applied to the raw down projection. +void native_gr_down_silu(float* lo, int hc_lr, int hc, void* stream); + +// xn and raw gate contain [n_embd,hc]. gate is overwritten with xn*sigmoid(gate). +// Layer path: pinned DSV4_HC_PRE_GATED ordered FMA accumulation, then scale. +// Head path (fused_layer=false): individually rounded products, ordered adds +// starting from stream zero, then SCALE. This retains the oracle's il=-1 graph. +void native_gr_pre_gated(const float* xn, float* gate, float* mixed, + int n_embd, int hc, bool fused_layer, void* stream); + +// inject[hc] -> SCALE(1/hc), sigmoid, SCALE(2), identity DSV4_HC_POST. +// residual/output contain [n_embd,hc], block_out has n_embd elements. +// output may equal residual exactly; all other overlaps are invalid. +void native_gr_post(const float* residual, const float* block_out, const float* inject, + float* output, int n_embd, int hc, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_mmvq.hpp b/include/strata/kernels/native_mmvq.hpp new file mode 100644 index 00000000..9f69dd94 --- /dev/null +++ b/include/strata/kernels/native_mmvq.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include + +namespace strata::kernels { + +// Native GGUF Q2_0, Q4_0, Q5_0, Q8_0, Q3_K, Q4_K, Q5_K, Q6_K, IQ4_NL +// and IQ4_XS / CUDA Q8_1 adapters, pinned to llama.cpp +// 3cf03257f219afbe7334045ff7c6a06ac68c627d, sm_120 generic MMVQ. +// All pointers are device pointers, at least 4-byte aligned, with no overlap. +// All calls enqueue on the explicit non-null CUDA stream; no allocation or wait. +// The translation unit must use --use_fast_math, as the pinned CUDA oracle does. +// +// Shapes use GGUF order: n_in is the contiguous reduction dimension, n_out is +// the weight row count, and ncols is the activation column/token count, 1..8 +// (plan v0.3 P3). Columns are contiguous: activation column j is x + j * n_in +// (its Q8_1 blocks at j * n_in / 32) and output column j is y + j * n_out. See +// native_mmvq_set_multi_exact for how ncols > 1 relates to ncols == 1. +// n_in must be a positive multiple of 32 for quantization/Q4_0/Q5_0/Q8_0/IQ4_NL, +// 64 for Q2_0, or 256 for the other formats. n_out must be positive. Weights remain +// unmodified row-major GGUF blocks: Q3_K=110, Q4_K=144, Q5_K=176, Q6_K=210 and +// IQ4_XS=136 bytes per 256 elements; Q2_0 is 18 bytes per 64 elements. +// Q4_0/IQ4_NL=18, Q5_0=22, Q8_0=34 bytes per 32 elements. +// Q8_1 scratch has 36 bytes per 32 elements, with no extra row padding here. +// Input floats must be finite, and their block scales/sums representable in FP16. +std::size_t native_q8_1_bytes(int n_in, int ncols = 1); + +// Layout for ncols > 1. false: llama.cpp's generic multi-column table (upstream), equal to ncols == 1 to +// float rounding, speed not yet measured. true (default): the ncols == 1 layout, every column bitwise equal to a +// single-column call. Set before +// graph capture; captured graphs keep the kernels they captured. +void native_mmvq_set_multi_exact(bool exact); +bool native_mmvq_multi_exact(); + +// One quantization may serve multiple weight matrices sharing the same input. +// Q8_1 stores FP16 scale and FP16 warp sum of the ORIGINAL float inputs; it does +// not reconstruct that sum from the quantized integers. +void native_quantize_q8_1(const float* x, void* x_q8_1, int n_in, int ncols, + void* stream); + +void native_q5_k_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +// Convenience composition: caller owns scratch sized by native_q8_1_bytes. +void native_q5_k_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_q2_0_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_q2_0_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_q3_k_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_q3_k_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_iq4_xs_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_iq4_xs_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_q4_k_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_q4_k_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_q6_k_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_q6_k_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_q4_0_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_q4_0_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_q5_0_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_q5_0_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_q8_0_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_q8_0_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +void native_iq4_nl_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +void native_iq4_nl_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream); + +// Storage/dispatch helpers take stable GGML type IDs, avoiding a ggml runtime +// dependency in the engine: Q4_0=2, Q5_0=6, Q8_0=8, Q3_K=11, Q4_K=12, Q5_K=13, +// Q6_K=14, IQ4_NL=20, IQ4_XS=23, Q2_0=42. Unsupported IDs throw in the byte-count +// and launch helpers; only the +// capability query returns false. +bool native_mmvq_supported(int ggml_type) noexcept; +std::size_t native_mmvq_weight_bytes(int ggml_type, int n_in, int n_out); +void native_mmvq(int ggml_type, const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_moe.hpp b/include/strata/kernels/native_moe.hpp new file mode 100644 index 00000000..0c48d6ae --- /dev/null +++ b/include/strata/kernels/native_moe.hpp @@ -0,0 +1,18 @@ +#pragma once +#include + +namespace strata::kernels { +// Configure before constructing/capturing sessions. Existing captures keep their +// selected kernels; changing this flag does not rewrite an existing graph. +void native_moe_combine_set_enabled(bool enabled); +bool native_moe_combine_enabled(); + +// Pinned CUDA weighted-reduction contract for one token, k in [1, 15]. +// The backend fuses k=2..15; k=1 is its ordinary multiply/contiguous/add path. +// parts: k contiguous F32 rows of n_embd; weights: k F32 values. The first +// product rounds to F32, following products accumulate with FMA in expert order, +// and optional shared is added once afterward. Shared is not router weighted. +// Requires a nonnull ordered stream and disjoint output. No allocation or sync. +void native_moe_combine(const float* parts, const float* weights, const float* shared, + float* output, int64_t n_embd, int64_t k, void* stream); +} diff --git a/include/strata/kernels/native_ple_postops.hpp b/include/strata/kernels/native_ple_postops.hpp new file mode 100644 index 00000000..9e947878 --- /dev/null +++ b/include/strata/kernels/native_ple_postops.hpp @@ -0,0 +1,30 @@ +#pragma once +#include "strata/kernels/ple.hpp" + +namespace strata::kernels { + +// Fixed qwen4exp single-token geometry: embd=2560, streams=4, history=9, +// convolution kernel=4/dilation=3. All buffers are caller-owned and capture-safe. +struct NativePlePostopsBuffers { + float* key; // 10240, normalized projected key + float* query; // 10240, temporary normalized hidden; may equal normalized + float* gate; // 4 + float* gated; // 10240 + float* normalized; // 10240, append this to history AFTER this function + float* conv; // 10240 + float* result; // 10240, may equal hidden exactly +}; + +// Matches pinned llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d CUDA +// build_ple AFTER the two projections. Only norm_key/query/conv and conv1d_f16 +// fields of weights are used. Gamma is full [2560,4]; history is row-fastest +// [9,10240], and F16 taps are [4,10240]. Inputs must be finite. +// Require an explicit stream, nonnull aligned pointers and disjoint writable +// spans, with only the two exact aliases documented above allowed. No allocation +// or synchronization; retain every buffer through any captured graph execution. +void native_ple_postops(const float* projected_key, const float* hidden, + const float* value, const float* history, + const PleWeights& weights, const NativePlePostopsBuffers& buffers, + void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_qsa.hpp b/include/strata/kernels/native_qsa.hpp new file mode 100644 index 00000000..c56eb4d2 --- /dev/null +++ b/include/strata/kernels/native_qsa.hpp @@ -0,0 +1,30 @@ +#pragma once + +namespace strata::kernels { + +// Set before session construction/capture; existing CUDA graphs retain their +// selected kernels. Disabled by default. Integration chooses the call sites. +void native_qsa_set_enabled(bool enabled); +bool native_qsa_enabled(); + +// Pinned single-token CUDA F32 arithmetic from llama.cpp +// 3cf03257f219afbe7334045ff7c6a06ac68c627d. No allocation or synchronization; +// both functions require a nonnull caller-owned stream and four-byte alignment. +// Positive dimensions with n_cols*n_rows (or head_dim*n_head) <= INT_MAX. +// Finite inputs and finite intermediate sums/products are caller preconditions. + +// Contiguous input/output [n_cols,n_rows], gamma[n_cols] broadcast over rows. +// Each row has independent RMS statistics. Exact output==input is supported; +// otherwise all three spans must be disjoint. Epsilon must be finite and >= 0; +// epsilon==0 additionally requires a nonzero sum of squares in every row. +// Pinned 256-thread (width<1024) or 1024-thread XOR reduction, (scale*x)*gamma. +void native_qsa_rms_norm_weighted(const float* input, const float* gamma, float* output, + int n_cols, int n_rows, float epsilon, void* stream); + +// attn/output [head_dim,n_head]; q_full [2*head_dim,n_head], each row is +// [query channels, gate channels]. Output is attn*sigmoid(second-half gate). +// Exact output==attn is supported; all other spans must be disjoint. +void native_qsa_gate_apply(const float* attn, const float* q_full, float* output, + int n_head, int head_dim, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_qsa_indexer.hpp b/include/strata/kernels/native_qsa_indexer.hpp new file mode 100644 index 00000000..a79fd47c --- /dev/null +++ b/include/strata/kernels/native_qsa_indexer.hpp @@ -0,0 +1,35 @@ +#pragma once +#include "strata/kernels/qsa.hpp" + +namespace strata::kernels { + +// Configure before session construction/capture. Disabled independently of the +// native QSA norm/gate switch; existing captures retain their selected kernels. +void native_qsa_indexer_set_enabled(bool enabled); +bool native_qsa_indexer_enabled(); + +// Pinned F16 raw-key cache / F32 pooling / weighted RMSNorm / text IMRoPE. +// Fixed single-sequence geometry: idx_dim=128, idx_block=4, n_rot=64. +// Uses the existing buffers: tail[3,128] stores half-rounded raw values expanded +// to F32; dead[128] is the repeated-cell0 spare key; pooled[max_cells/4+1,128] +// contains completed blocks followed by the spare; block_pos[1] changes only on +// completion and records that block's first absolute position. +// +// relative_pos_device is a cell index, advancing 0..max_cells-1 (use kStepPos, +// not the absolute RoPE position vector). The sequence is text, +// contiguous, with nonnegative pos_base divisible by four. Clear state before +// starting a new sequence. Completed block b rotates at pos_base+4*b; the spare +// always rotates at zero, including nonzero sequence bases, as in the oracle. +// +// Explicit nonnull stream; no allocation or synchronization. All spans must be +// aligned and disjoint, remain valid through replay, and contain finite values; +// raw values must round to finite F16 and epsilon must be finite and positive. +// max_cells is a positive fixed capacity; pos_base+max_cells must fit int32. +// Out-of-range device positions are ignored defensively, not host-validated. +// This adapter does not alter score accumulation, top-k, or finite 1e9 tail bias. +void native_qsa_indexer_append(const float* raw, const int32_t* relative_pos_device, + int32_t pos_base, const float* gamma, float epsilon, + const QsaIndexerBuffers& buffers, const QsaShapes& shapes, + int64_t max_cells, float freq_base, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_qsa_score.hpp b/include/strata/kernels/native_qsa_score.hpp new file mode 100644 index 00000000..9427a77e --- /dev/null +++ b/include/strata/kernels/native_qsa_score.hpp @@ -0,0 +1,31 @@ +#pragma once +#include "strata/kernels/qsa.hpp" + +namespace strata::kernels { + +// Configure before capture; independent of native key pooling and norm/gate. +void native_qsa_score_set_enabled(bool enabled); +bool native_qsa_score_enabled(); + +// SM120, single contiguous text sequence: F32 pooled[128,max_blocks] and +// query[128,4], four-cell blocks, top-k budget2048. Matches the pinned CUDA MMF +// dispatch with the model's n_kv padded to256 (n_blocks a multiple of64), even +// when only a partial32-row tile is live. This is not the unpadded odd-row +// cuBLAS contract. MMA consumes the same raw F32 register bits as the oracle. +// +// step[kStepCount] is device memory, refreshed before each replay. max_blocks +// must equal max_cells/4+1. Completed pooled rows[0,n_bid) and the spare row +// [n_bid] must be initialized. Writes cell_scores[0,n_kv) only. Applies ReLU +// separately per head, ordered F32 head addition, optional F32 block bias, +// then finite1e9 F32 bias for the incomplete tail; full blocks also receive +// the reference's +0 bias. Does not alter selection, ties, or selected order. +// +// All spans are aligned, disjoint, persistent through replay; inputs finite. +// Explicit nonnull stream. No allocation, host count reads, or synchronization. +// Invalid device counts suppress writes defensively; callers own count validity. +void native_qsa_score(const float* pooled, const float* query, const float* block_bias, + const QsaShapes& shapes, const int32_t* step, + int64_t max_blocks, int64_t max_cells, + float* cell_scores, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/native_rope.hpp b/include/strata/kernels/native_rope.hpp new file mode 100644 index 00000000..24bffe61 --- /dev/null +++ b/include/strata/kernels/native_rope.hpp @@ -0,0 +1,14 @@ +#pragma once + +namespace strata::kernels { +void native_rope_set_enabled(bool enabled); +bool native_rope_enabled(); + +// Pinned CUDA text-only IMRoPE: F32 rows, 64 rotated channels, no YaRN/frequency +// factors, equal text positions in all four IMRoPE sections. Each device position +// must be nonnegative. The position buffer remains live through graph replay. +// Supports head_dim 128/256 and exact x==out; partial overlap is rejected. +// Explicit stream required. No allocation or synchronization. +void native_rope_apply(const float* x, float* out, int rows, int head_dim, + int n_rot, float freq_base, const int* positions, void* stream); +} diff --git a/include/strata/kernels/native_router.hpp b/include/strata/kernels/native_router.hpp new file mode 100644 index 00000000..4b3dccda --- /dev/null +++ b/include/strata/kernels/native_router.hpp @@ -0,0 +1,17 @@ +#pragma once +#include + +namespace strata::kernels { +// Configure before session capture. Existing graphs retain their selected kernels. +// This experiment is off by default and does not modify the legacy router. +void native_router_set_enabled(bool enabled); +bool native_router_enabled(); + +// Pinned CUDA topk-moe contract for ONE token, 512 experts, top 10, softmax, +// no selection bias, lower normalization clamp 2^-14, and scale 1. +// Reads 512 finite F32 logits; writes 10 I32 IDs and 10 F32 weights. Equal +// computed probabilities select the lower expert index. All spans must be +// four-byte aligned and outputs disjoint from each other and the input. +// Requires a nonnull ordered CUDA stream. No allocation or synchronization. +void native_router_top10(const float* logits, int32_t* ids, float* weights, void* stream); +} diff --git a/include/strata/kernels/ngram.hpp b/include/strata/kernels/ngram.hpp new file mode 100644 index 00000000..d15491de --- /dev/null +++ b/include/strata/kernels/ngram.hpp @@ -0,0 +1,194 @@ +// include/strata/kernels/ngram.hpp - P2.S4: the PLE n-gram hash and the per-layer token table. +// +// Two halves that share nothing but the row indices they exchange: +// +// * `ngram_rows` - the HOST hash. Sixteen row indices per token from the last three token ids, computed +// with 64-bit multiply/xor and no tensor op at all. `qwen4exp.cpp` L1092-1124, where the source says +// outright that it is host-side because "ggml has no int64 and no xor". +// * `PleTable` - the row gather. `per_layer_token_embd.weight` is IQ4_NL and is NOT IN THE PACK: it is +// 51.2e9 elements (28.8 GB) in the ORIGINAL second GGUF shard, and it is the only tensor this engine +// reads from the GGUF rather than from the canonical pack. +// +// THE HASH IS THE ONE PLACE IN THIS MODEL WHERE A WRONG BIT IS SILENTLY PLAUSIBLE. Every one of its +// properties has a rival reading that produces a valid index in range: +// +// * XOR, not sum. Two equal tokens with equal multipliers cancel to zero under XOR and double under sum. +// * `%` vocab_size, not `& (vocab_size-1)`. The vocab sizes are 20000003, 20000023, 20000033, ... - not +// powers of two, so a mask would be a different function. +// * the EOS cut propagates FORWARD: an EOS at any position replaces that position AND every older one. +// * a missing predecessor is `LLAMA_TOKEN_NULL = -1`, tested as `t < 0`. Token id 0 is a REAL token. +// * the token's OWN eos does not cut its own context - only the predecessors are examined. +// +// All of those are asserted observable in `ple_parity.cpp` rather than trusted. +#pragma once + +#include +#include + +namespace strata::kernels { + +// ---- geometry, every value confirmed against the artifact (docs/gguf-metadata.txt) ---- +inline constexpr int NG_N_EMBD = 2560; // n_embd +inline constexpr int PLE_HEAD_DIM = 160; // qwen4exp.embedding_length_per_layer_input +inline constexpr int NGRAM_SIZE = 3; // qwen4exp.ple.ngram_size +inline constexpr int HEADS_PER_NGRAM = 8; // qwen4exp.ple.heads_per_ngram +inline constexpr int PLE_N_HEADS = (NGRAM_SIZE - 1) * HEADS_PER_NGRAM; // 16 +inline constexpr int PLE_CONV_KERNEL = 4; // qwen4exp.ple.conv_kernel +inline constexpr int32_t PLE_EOS_TOKEN_ID = 248044; +inline constexpr int NG_HC = 4; // hyper_connection.count +inline constexpr int NG_HC_DIM = NG_HC * NG_N_EMBD; // 10240 +inline constexpr int NG_HIST = (PLE_CONV_KERNEL - 1) * NGRAM_SIZE; // 9 +inline constexpr int32_t TOKEN_NULL = -1; // LLAMA_TOKEN_NULL +inline constexpr float NG_RMS_EPS = 1e-6f; + +// The table: [160, 320001536] IQ4_NL. ne0 = 160 is the FAST axis, so one row is 160 contiguous elements = +// 5 blocks of 32 at 18 bytes = 90 bytes. The head-slowest flatten then makes 16 rows exactly n_embd = 2560. +inline constexpr uint64_t PLE_TABLE_ROWS = 320001536ull; +inline constexpr int PLE_ROW_BYTES = (PLE_HEAD_DIM / 32) * 18; // 90 + +/// The artifact's own hash constants, transcribed from `docs/gguf-dump-shard1.txt`: +/// +/// layer_multipliers = [23703573157769, 20109073645365, 8052911324071] +/// head_vocab_sizes = [20000003, 20000023, ...] 16 entries, NOT an arithmetic sequence +/// head_offsets = [0, 20000003, 40000026, ...] the running sum of head_vocab_sizes +/// +/// `ref/ngram.py::selftest` calls its own copies "synthetic but STRUCTURALLY REAL"; they are in fact +/// `20000003 + 10*i` for the vocab sizes, which the artifact's are not (20000003, 20000023, 20000033 - the +/// differences are 20, 10, ...). A selftest is free to use whatever constants it likes, but a PARITY test +/// against the artifact must use these, and the difference is recorded because it is exactly the kind of +/// thing that gets copied from the reference into an engine. +struct PleConsts { + uint64_t mult[NGRAM_SIZE]; + uint64_t vocab[PLE_N_HEADS]; + uint64_t offset[PLE_N_HEADS]; +}; +PleConsts ple_artifact_consts(); + +/// `mixed_n = (ctx[0]*m[0]) ^ (ctx[1]*m[1]) ^ ... ^ (ctx[n-1]*m[n-1])` in uint64. +/// +/// Exposed separately so a test can assert the XOR STRUCTURE directly rather than only its consequences. +uint64_t ngram_mixed(const int64_t* ctx, const uint64_t* mult, int n); + +/// The sixteen row indices per token. `qwen4exp.cpp` L1092-1124. +/// +/// tokens (n_tokens,) the ubatch's own tokens +/// prev (n_tokens, 2) predecessors OLDEST FIRST, `TOKEN_NULL` where there is none +/// out (n_tokens, 16) row indices into the PLE table +/// +/// The window is built once per token and then only its PREFIXES are used, which is what makes the bigram +/// heads (0..7) and the trigram heads (8..15) agree about the recent history. +void ngram_rows(const int32_t* tokens, const int32_t* prev, int n_tokens, const PleConsts& c, uint32_t* out); + +/// The IQ4_NL codebook: `{-127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113}`, +/// which is `kvalues_iq4nl` from `ggml-common.h` L1121 and also the manifest's `codebooks.IQ4NL`. +/// +/// There is NO `-8` in this version of ggml. Some releases of `dequantize_row_iq4_nl` subtract 8 from the +/// codebook value; at 3cf03257 they do not, and the difference would shift every one of the 51.2e9 elements. +int iq4nl_code(int code); + +/// One 90-byte row -> 160 floats. The canonical rule `value = cb[code] * scale + offset` with offset 0, +/// because IQ4_NL has no min term (`has_offset` is false in the manifest). +/// +/// THE NIBBLE ORDER IS SPLIT-HALF, NOT INTERLEAVED, and that is the trap here. `dequantize_row_iq4_nl` is +/// y[j] = d * kvalues[qs[j] & 0xf]; +/// y[j + QK4_NL/2] = d * kvalues[qs[j] >> 4]; +/// so byte j carries elements j and j+16 - NOT 2j and 2j+1, which is the natural reading and which produces +/// a perfectly plausible embedding of the wrong 160 values. +void iq4nl_dequant_row(const uint8_t* row, float* out160); + +/// How the table's rows are read (plan v0.3 P2). `Direct` is the default: unbuffered 4 KiB reads from the SSD, +/// so the table never occupies RAM or the OS file cache. `Mmap` is the earlier memory-mapped path, kept as the +/// A/B arm; it returns the same bytes. +/// +/// NEVER KEEP THE SHARD MAPPED WHILE READING IT DIRECT: a live section on the same file serializes the unbuffered +/// reads (311 -> 1,575 us per token, bench/results/2026-09-23-p2-ssd-direct). Direct mode drops its own mapping +/// after the header parse; nothing else in the process may hold one. +enum class PleIo { Direct, Mmap }; + +struct PleIoOptions { + PleIo mode = PleIo::Direct; + uint32_t max_inflight = 64; ///< outstanding SSD reads (decode needs 16; prefill chunks use more) + uint64_t cache_rows = 1u << 20; ///< bounded row cache: 1,048,576 rows x 90 B ~ 95 MB; 0 disables + bool io_thread = true; ///< reads submitted by a worker thread, not the caller +}; + +/// The PLE table. Held by pointer-to-impl so this header does not drag `` into every +/// translation unit that wants the hash. +class PleTable { +public: + PleTable(); + ~PleTable(); + PleTable(const PleTable&) = delete; + PleTable& operator=(const PleTable&) = delete; + + /// Open with an explicit I/O mode. The two-argument `open` below is the default (Direct). + bool open(const std::string& gguf_path, std::string& err, const PleIoOptions& io); + + /// The split the plan asks for: `issue` as soon as the token id is known, `collect` just before layer 1 + /// needs the rows. `gather` is `issue` followed by `collect`. In Mmap mode `issue` only prefetches. + bool issue(const uint32_t* rows16); + bool collect(float* out2560, std::string& err); + /// Plan v0.3 P5: the rows of `n_tokens` tokens (16 each, `rows` token-major) into `out` (2560 floats per token), + /// as ONE reader request - page dedupe and sort across the whole batch, the reader's full queue depth. Not + /// while a single-token `issue` is pending. The mapped mode gathers row by row. + bool gather_batch(const uint32_t* rows, size_t n_tokens, float* out, std::string& err); + + /// Fault injection (Direct mode): every row read completes no earlier than `us` after issue. + void set_injected_delay_us(double us); + + /// Maps the ORIGINAL second GGUF shard read-only. The tensor's data does NOT start at file offset 0: + /// the manifest's `shard2_tensor.offset` is relative to the GGUF's DATA SECTION, and the header before it + /// is 192 bytes. Reading at 0 would silently decode the header plus 192 bytes of shifted rows - still + /// plausible IQ4_NL, which is why the reader takes the offset from a real GGUF parse (`GgufFile`, the + /// project's validated reader) and checks that the tensor exactly fills the rest of the file. + bool open(const std::string& gguf_path, std::string& err); + void close(); + bool is_open() const; + uint64_t rows() const; + + /// 16 row indices -> 2560 floats. The gathered rows are flattened HEAD-SLOWEST: row h's 160 values + /// occupy `out[h*160, (h+1)*160)`, which is what `ggml_get_rows` does and what makes the result a plain + /// n_embd-wide vector for the block that consumes it. + void gather(const uint32_t* rows16, float* out2560) const; + + /// One row -> 160 floats, for diagnostics and for the parity test's oracle comparison. + void read_row(uint32_t row, float* out160) const; + + /// Bytes actually touched since `open`, for the "how often is it not ready" measurement P2.S4 asks for. + uint64_t bytes_read() const; + + /// One line of reader statistics (Direct mode), e.g. for --stats. Empty in Mmap mode. + std::string io_report() const; + PleIo mode() const; + +private: + struct Impl; + Impl* impl_ = nullptr; +}; + +// ================================ THE PLE GATHER'S PREFETCH, AND ITS A/B ARM ================================ +// +// `gather` issues its sixteen row reads as sixteen SEPARATE page faults into a 26.8 GB mapping, which measured +// **2.10-2.61 ms per token** and is second only to the layer loop among the token's avoidable terms. The table +// is 90 B per row, so the sixteen rows are 1,440 B - 0.5 MB/s, which is latency and not bandwidth, and it is +// because the sixteen pages are taken one at a time. `PrefetchVirtualMemory` issues them in one call. +// +// **THIS SWITCH EXISTS SO THE CLAIM CAN BE MEASURED RATHER THAN ASSERTED.** Cross-build comparisons in this +// project have repeatedly turned out to be machine drift - `LAYERS` alone moves 48.9 -> 52.3 between runs of +// one binary - so the two arms are alternated inside one session. Off is `--no-ple-prefetch`. +void ple_prefetch_enable(bool on); +bool ple_prefetch_enabled(); + +// ---- NOTE ON `madvise(MADV_RANDOM)` ------------------------------------------------------------------ +// P2.S4 asks for `madvise(MADV_RANDOM)` on the mapping, and on Linux that is exactly right: a token gathers +// 16 rows scattered over 28.8 GB, so read-ahead is pure waste and would evict useful pages. +// +// THERE IS NO LINUX, SO THERE IS NO MADV_RANDOM HERE. Windows has no equivalent of the advice - the closest +// is `FILE_FLAG_RANDOM_ACCESS` at CreateFile time, which suppresses the cache-manager's read-ahead for the +// whole handle, and `PrefetchVirtualMemory` for the positive case. `GgufFile` opens with neither, so this +// build gets the DEFAULT sequential read-ahead on a random access pattern. Saying so is the point: a +// comment claiming MADV_RANDOM here would be a comment about code that does not run. On Linux the advice +// belongs in `GgufFile::open`, which this module does not own. +// ------------------------------------------------------------------------------------------------------ + +} // namespace strata::kernels diff --git a/include/strata/kernels/ple.hpp b/include/strata/kernels/ple.hpp new file mode 100644 index 00000000..fabad838 --- /dev/null +++ b/include/strata/kernels/ple.hpp @@ -0,0 +1,119 @@ +// include/strata/kernels/ple.hpp - P2.S4: the PLE block's GPU half (`build_ple`, qwen4exp.cpp L1206-1293). +// +// One token, sixteen gathered rows of 160 -> a residual-stack update: +// +// key = grouped_norm(ple_key @ emb) hc_dim, per-stream RMS then a [n_embd,hc] gamma +// query = grouped_norm(hidden, ple_norm_query) +// value = ple_value @ emb n_embd +// s[c] = sum_d(key[c]*query[c]) / sqrt(n_embd) +// gate[c] = sigmoid(sign(s) * sqrt(max(|s|, 1e-6))) signed square root, then sigmoid +// gated[c] = value * gate[c] value broadcast over the hc streams +// normalized= grouped_norm(gated, ple_norm_conv) <-- THE CONV INPUT +// conv = sum_k kW[k][c] * padded[hist + t - (kern-1-k)*dil][c] +// out = hidden + gated + silu(conv) +// +// **`normalized` IS THE CONV INPUT, AND `ref/ngram.py` SAYS OTHERWISE.** Its `ple_block` docstring calls +// `terms` "the padded gated values", but the source pads `normalized` - the grouped_norm of the gated values, +// not the gated values themselves - and `ple_layer_xcheck.cpp` L107-112 builds it that way from ggml. The +// reference computes `normalized` and then ignores it, passing `terms` in from the caller, so its own code +// does not contradict its docstring and nothing catches it. This header takes the HISTORY OF NORMALIZED +// ROWS, which is what the state actually is. +// +// WHERE THE WEIGHTS LIVE, and the two different layouts in play. `ple_conv1d.weight` has manifest shape +// [4, 10240] with ne0 = 4 fast, so ggml's flat index is `k + 4*c`; `ref/ngram.py`'s `kernel` is a (4, 10240) +// numpy array whose flat index is `k*10240 + c`. They are transposes, and the source's own view +// (`ggml_view_2d(tWc, 1, hc_dim, nb[1], k*nb[0])`) confirms which is meant. `ple_key` and `ple_value` are +// [2560, n] with ne0 = 2560 fast, which IS the reference's row-major orientation. +#pragma once + +#include + +namespace strata::kernels { + +/// Opt in to pinned CUDA BF16/F32 MMVF for the PLE value projection only (default false). +/// Configure before session capture; captured graphs retain their selected projection kernels. +void ple_set_native_bf16(bool enabled); + +/// Opt in to pinned CUDA postprojection norms, gate, convolution and residual +/// arithmetic (default false). Set before capture; existing graphs keep their +/// selected kernels. Independent of the key/value projection options. +void ple_set_native_postops(bool enabled); +bool ple_native_postops_enabled(); + +/// Weights of the single PLE layer (`ple.layers = [1]`). `blk.1.*` in the pack. +/// +/// key_codes / key_scales `ple_key.weight` S2, [n_embd, hc_dim], code_bias -1, group 64. +/// The pack stores `scales_fp16: true`, and every S-form kernel in this project +/// (including `s2_gemv_q8`) takes scales as F32 - so the LOADER widens them once, +/// as it already must for the experts. Recorded because "the manifest says fp16" +/// and "the kernel wants float" is a silent 2x-read if nobody says which wins. +/// value_bf16 `ple_value.weight` BF16 as raw bits, [n_embd, n_embd] +/// norm_key/query/conv F32 [hc_dim], NO (1+w) folding - `ref/gdn.py` records the same for `ssm_norm` +/// conv1d_f16 `ple_conv1d.weight` F16, GGML-NATIVE `kW[k + kern*c]` +struct PleWeights { + const uint8_t* key_codes = nullptr; // hc_dim * (n_embd/4) bytes + const float* key_scales = nullptr; // hc_dim * (n_embd/64) floats + const uint16_t* value_bf16 = nullptr; // n_embd * n_embd + const float* norm_key = nullptr; // hc_dim + const float* norm_query = nullptr; // hc_dim + const float* norm_conv = nullptr; // hc_dim + const uint16_t* conv1d_f16 = nullptr; // PLE_CONV_KERNEL * hc_dim, flat index k + 4*c + + /// Optional unchanged GGUF Q2_0 [2560,10240] key projection. Nonnull data + /// requires type 42 and caller-owned native_q8_1_bytes(2560) device scratch, + /// disjoint from the PLE workspace, inputs, weights and exported outputs. + /// Use an explicit ordered stream and retain both buffers through graph + /// execution. Null data preserves the canonical key projection. + const void* key_native_data = nullptr; + int key_native_type = -1; + void* key_native_q8_1 = nullptr; + /// Plan v0.3 P6: the IQ model files keep `ple_key` in BF16 ([n_embd, hc_dim], `w[o*n_embd+i]`); when set it + /// replaces both key paths above. + const uint16_t* key_bf16 = nullptr; +}; + +/// Everything the block produces, in the order `ple_layer_xcheck`'s oracle writes it. Any pointer may be +/// null; the caller that only wants the result passes nothing else. Having the intermediates is what makes +/// the parity test able to say WHICH stage diverged instead of only that the sum did. +/// Every nonnull output region, including result, must be disjoint from the block's entire scratch region. +/// result may alias hidden. The block rejects scratch/output overlap before launching any work. +struct PleOut { + float* key = nullptr; // hc_dim + float* value = nullptr; // n_embd + float* gate = nullptr; // hc + float* gated = nullptr; // hc_dim + float* normalized = nullptr; // hc_dim + float* conv = nullptr; // hc_dim + float* result = nullptr; // hc_dim +}; + +/// One token. `emb` is the 2560-wide gathered n-gram embedding, `hidden` the hc_dim residual stack, and +/// `hist_rows` the NG_HIST previous NORMALIZED rows, oldest first (the caller owns that cache). +/// +/// `hist_rows` IS ROW-FASTEST: `hist_rows[row + NG_HIST*channel]`. That is not a choice - it is +/// `ggml_reshape_3d(state, d_conv-1, conv_channels, n_seqs)`, so ne0 = the history row varies fastest, and +/// `ple_layer_xcheck.cpp` L79-83 records the transposed reading failing ggml's own assert. +/// +/// The block does NOT write back to the history: the caller keeps ownership of its state and this stays a +/// pure function of its inputs, which is what makes the chunked-versus-single-shot property testable. +/// Bytes the caller must hand `ple_block` as its workspace: five `hc_dim` buffers (key, query, norm, +/// gated, conv), one `n_embd` (value) and one `hc` (gate), plus the Q8 activation image and the BF16 +/// embedding copy. **THE WORKSPACE IS THE CALLER'S BECAUSE THIS FUNCTION IS CAPTURED**: it used to +/// `cudaMalloc` three times and `cudaStreamSynchronize` once per call, and inside a graph both are errors - +/// `ple_block: scratch: operation not permitted when stream is capturing`. Same defect, same fix as +/// `shared_expert`. +uint64_t ple_block_scratch_bytes(); + +void ple_block(const float* emb, const float* hidden, const float* hist_rows, const PleWeights& w, + PleOut& out, void* scratch, void* stream); + +/// Advance the row-fastest normalized history by one token: hist[r,c] = old_hist[r+1,c], then append +/// normalized[c] at row NG_HIST-1. One thread owns each channel, making the in-place shift well-defined. +/// hist and normalized must be nonnull, disjoint device regions of NG_HIST*NG_HC_DIM and NG_HC_DIM floats. +/// Async on stream, allocation-free and capture-safe. Throws on null or overlapping regions. +void ple_history_advance(float* hist, const float* normalized, void* stream); + +/// Whether the CUDA path is available. The host hash and the table read work without it; the block does not. +bool ple_block_available(); + +} // namespace strata::kernels diff --git a/include/strata/kernels/qsa.hpp b/include/strata/kernels/qsa.hpp new file mode 100644 index 00000000..d2c0ea91 --- /dev/null +++ b/include/strata/kernels/qsa.hpp @@ -0,0 +1,360 @@ +// include/strata/kernels/qsa.hpp - the QSA (full-attention) layer's cache, indexer and attention, P2.S2. +// +// Spec of record: `ref/qsa.py` (whose own selftest carries eleven PROPERTY checks) and `ref/model.py::_qsa` / +// `_indexer` / `_attend`, transcribed from `build_layer_attn` / `build_attn_qsa` in the source. The +// projections are NOT here: `attn_q`, `attn_k`, `attn_v`, `attn_output`, `indexer.q_proj` and +// `indexer.k_proj` are the existing `s_gemv`/`s2_gemv` kernels, exactly as `gdn_step` leaves them. What lives +// here is everything BETWEEN the projections: +// +// kv_append the new token's K and V into the paged cache +// indexer_key_append the raw indexer tail, and the pooling that fires on block completion +// qsa_index per-CELL indexer scores: Relu per head, sum over heads, + bias, mapped by cell_block +// topk_512 the selection: the `width` largest cells, ties by ascending index, returned ascending +// kv_gather selected cells through the PAGE TABLE into a contiguous scratch +// qsa_attend softmax GQA over the scratch +// qsa_gate_apply `attn * sigmoid(gate)` and the round to the fp16 activation `attn_output` consumes +// +// DECODE ONLY, and that is a deliberate scope statement rather than an omission. `ref/qsa.py::indexer_scores` +// has no mask parameter and says why: "The causal part of the mask is already satisfied: every cached cell +// precedes the query at decode." The plan's `qsa_index` bullet asks for a "block-causal mask", and at decode +// its entire content is the TAIL -> SPARE SLOT mapping (`cell_block`), which is implemented here and tested. +// The per-query form - a query at position i may only see blocks with `block_start + r - 1 <= i`, plus the tail +// tokens - is a PREFILL property: `phases/phase-5-prefill-and-context-reuse.md` L73 builds it as a +// [chunk x blocks] GEMM with per-row top-k. Adding a mask flag here that decode cannot exercise would be a +// parameter with no observable behaviour, and this project has already been bitten by checks that cannot see +// the thing they claim to check. +// +// ============================================================================================ +// **THIS WHOLE API IS NOT YET CAPTURABLE, AND IT IS NOT OBVIOUS WHICH PARTS ARE NOT.** +// +// A CUDA graph bakes kernel ARGUMENTS in at capture time. Any argument that CHANGES PER TOKEN is therefore +// frozen at its first-token value on replay - silently, because the graph still runs, still produces finite +// output, and still looks correct. Auditing this header for that property (round 211-212) found FIVE entry +// points, not one: +// +// kv_append int64_t pos every token the new cell's position +// qsa_index int64_t n_bid every token completed blocks +// qsa_index int64_t n_kv every token cached cells +// topk_512 int64_t n_kv every token cached cells +// kv_gather int64_t n_ids every token = qsa_selection_width(n_kv, s) +// qsa_attend int64_t n_ids every token as above +// indexer_key_append int64_t pos FIXED now takes `const int32_t* pos_dev` +// +// WHAT A CAPTURED LAYER WOULD ACTUALLY DO, and it is worse than a crash: `kv_append` would write EVERY token to +// position 0, `qsa_index` and `topk_512` would score exactly one cell, `kv_gather` would gather one cell, and +// attention would be over a single cell for the whole sequence. Finite, plausible, and indistinguishable from +// "the model has no context" - a symptom that would be blamed on the KV cache, the selection, or the weights. +// +// THE FIX IS ONE DEVICE BUFFER, NOT SIX SIGNATURES. Every one of these values is a pure function of the +// current position and the geometry, so they belong together in a small device-resident step state that is +// written once per token: +// +// step[0] = pos step[1] = n_kv = pos+1 +// step[2] = n_bid step[3] = width = qsa_selection_width(n_kv, s) +// +// Each kernel reads the entries it needs, every argument list becomes constant across tokens, and the whole +// QSA path captures. Writing it as six separate pointer parameters would leave six places to forget and no +// single thing to check. +// ============================================================================================ +// +// ---- LAYOUTS. Every one of these is a runtime buffer and not a weight, so the layout is a free choice - but +// it must be WRITTEN DOWN, because the alternatives index the same numbers and a mix-up is silent. +// +// K/V CACHE: FP16, paged, `[page][kv_head][page_size][head_dim]` with head_dim fastest. The plan says "FP16 +// first" and fp16 is also what makes 32K of context fit: 12 layers x 2 heads x 256 dims x 2 (K and V) x 2 B x +// 32768 = 805 MiB, against 1.6 GiB in f32. The page granule is coarse (the shape's `page_size`) for the +// reason `Memory/Qwen-5070-engine-campaign/src/plans/paged-kv-cache.md` L146-170 gives: this engine serves a +// handful of sequences and not hundreds, so fine granularity buys nothing and costs per-token indirection. The +// physical row for logical cell `t` and head `h` is +// +// page = table[t / page_size]; (page * n_head_kv + h) * page_size + (t % page_size) +// +// i.e. ONE HEAD's worth of consecutive cells is contiguous within a page. That is the property attention +// wants; the alternative (cell-major inside the page) makes a cell's two heads contiguous instead, which is +// the property `kv_append` wants and nowhere near as valuable. **Nothing outside this header may assume the +// mapping**: `qsa_attend` reads a gathered scratch and never a pool, and the test runs the same fixture at +// page_size 1, 4 and 512 with a permuted page table and requires the attention output to be IDENTICAL, so the +// granule cannot leak into the result. +// +// INDEXER KEYS: FP32, not fp16 and not the plan's INT8. These carry no tolerance anywhere in the contract: +// `docs/capture-format.md` L127 makes `indexer_ids` an EXACT key, because a different selected cell is a +// different attention input. The key is a pooled MEAN of four raw keys followed by a norm and a rotation, so +// an fp16 store perturbs every score by ~2^-11 relative, and the selection boundary is exactly where that +// matters. The cost of fp32 is 512 B per block per layer = 128 B/token/layer = 1.5 KiB/token over the 12 QSA +// layers, i.e. 50 MiB at 32K: cheap next to a correctness contract. The plan's INT8 indexer key is a Phase 3 +// optimisation and it has to be justified by a measured selection-flip rate against this baseline. +// +// The RAW tail is fp32 for the same reason and because it is only `idx_block - 1` rows. +#pragma once + +#include + +namespace strata::kernels { + +/// Geometry of one QSA layer. The real artifact: n_head 24, n_head_kv 2, head_dim 256, n_rot 64, +/// idx_n_head 4, idx_dim 128, idx_block 4, idx_top_k 2048, freq_base 1e7, rms_eps 1e-6. +struct QsaShapes { + int64_t n_head = 0; ///< query heads (24). n_head must be a multiple of n_head_kv + int64_t n_head_kv = 0; ///< key/value heads (2). The q -> kv map is `q / (n_head/n_head_kv)`, NOT `q % kv` + int64_t head_dim = 0; ///< 256, and must be a multiple of 4 + int64_t n_rot = 0; ///< 64 rotated dims of head_dim (and of idx_dim), NEOX pairs + int64_t idx_n_head = 0; ///< indexer query heads (4) + int64_t idx_dim = 0; ///< indexer key width (128) + int64_t idx_block = 0; ///< r = 4: the indexer pools one key per 4 cells + int64_t idx_top_k = 0; ///< 2048 cells of selection budget + int64_t page_size = 0; ///< KV page granule in CELLS; 1 is a legal unpaged configuration +}; + +/// The real artifact's geometry, so no caller transcribes it by hand. +inline QsaShapes qsa_real_shapes() { + QsaShapes s; + s.n_head = 24; s.n_head_kv = 2; s.head_dim = 256; s.n_rot = 64; + s.idx_n_head = 4; s.idx_dim = 128; s.idx_block = 4; s.idx_top_k = 2048; + s.page_size = 512; + return s; +} + +/// The one legal RMSNorm epsilon for this artifact (`attention.layer_norm_rms_epsilon`). +inline float qsa_rms_eps() { return 1e-6f; } +/// `rope.freq_base`, no rope.scaling keys, so freq_scale = 1 and no YaRN. +inline double qsa_freq_base() { return 1e7; } + +/// The selection width: `min(n_kv, idx_top_k + idx_block - 1)`. +/// +/// The `r - 1 = 3` is not slack, it is the incomplete tail: the budget is whole blocks PLUS whatever cells do +/// not fill one. So the plan's "selection skipped when context <= 2,048" is conservative by two - the source's +/// own bound is 2,051, and `ref/qsa.py` L292-294 records the disagreement. Below the bound the selection is +/// the IDENTITY, which `topk_512` is required to reproduce exactly rather than approximately. +inline int64_t qsa_selection_width(int64_t n_kv, const QsaShapes& s) { + const int64_t w = s.idx_top_k + s.idx_block - 1; + return n_kv < w ? n_kv : w; +} + +/// THE PER-TOKEN VALUES EVERY QSA KERNEL NEEDS, IN DEVICE MEMORY. +/// +/// This exists so the QSA layer can be a CUDA graph. A graph bakes kernel ARGUMENTS in at capture time, so any +/// per-token argument is frozen at its first-token value on replay - silently, because the graph still runs and +/// still produces finite output. Six entry points used to take one of these as a host scalar; every one of +/// them now reads it from here instead, which makes every argument list constant across tokens. +/// +/// WHAT IT PREVENTS, concretely: with `pos` frozen, `kv_append` writes every token to cell 0, `qsa_index` and +/// `topk_512` score one cell, `kv_gather` gathers one cell, and attention runs over a single cell for the whole +/// sequence. Finite, plausible, and indistinguishable from "the model does not use its context". +/// +/// The four entries are NOT independent - `n_kv = pos+1` and `width = qsa_selection_width(n_kv, s)` - which is +/// exactly why they live in one buffer with one writer: `qsa_step_write` computes all four from `pos`, so a +/// caller cannot update one and forget another. +/// +/// It is written by a plain H2D copy of four int32, which IS capturable and re-reads its source at replay +/// (`bench/micro/pinned_capture.cu` case B) - so the same buffer works in a captured graph and in a direct +/// launch. +enum QsaStep : int { + kStepPos = 0, ///< the new cell's sequence position + kStepNKv = 1, ///< pos + 1: how many cells are in the cache + kStepNBid = 2, ///< n_kv / idx_block: how many blocks have COMPLETED + kStepWidth = 3, ///< qsa_selection_width(n_kv, s): how many cells the selection returns + kStepCount = 4, +}; + +/// `step` is (kStepCount,) int32 DEVICE memory. Pointers to it are passed to the entry points below; it is +/// allocated by the caller (it is per-sequence state, not scratch). +/// +/// Returns the bytes one step buffer needs, so a caller sizing an arena does not transcribe `kStepCount`. +inline uint64_t qsa_step_bytes() { return sizeof(int32_t) * (uint64_t) kStepCount; } + +/// Fills a HOST array of `kStepCount` int32 from `pos`, so a caller can upload it in one copy. Keeping the +/// arithmetic here rather than at the call site is the point of the buffer: `n_kv` and `width` are derived, +/// and a caller computing them by hand is a caller who can compute them differently. +void qsa_step_fill(int32_t* host_step, int64_t pos, const QsaShapes& s); + + +/// One token's K and V into the paged FP16 cache, at logical cell `pos`. +/// +/// k_pool, v_pool `[page][kv_head][page_size][head_dim]` fp16, page_size*n_pages cells each +/// page_table (n_pages,) i32: logical page -> physical page. NOT assumed to be the identity +/// kcur, vcur (n_head_kv, head_dim) f32. `kcur` must ALREADY be normed and rotated - V is never rotated +/// +/// The fp32 -> fp16 conversion is `f16_from_f32` (see f16_bits.hpp) and not `__float2half`. +void kv_append(uint16_t* k_pool, uint16_t* v_pool, const int32_t* page_table, int64_t pos, + const float* kcur, const float* vcur, const QsaShapes& s, void* stream); + +/// The selected cells through the page table into a contiguous scratch. +/// +/// ids (n_ids,) i32, logical cells, ASCENDING and unique as `topk_512` returns them +/// k_scratch etc (n_ids, n_head_kv, head_dim) fp16, written in full +/// +/// A cell that is not gathered does not exist for `qsa_attend`, which is how the top-k mask is realised: the +/// source fills the kq mask with -inf and leaves the selected cells at zero, so an unselected cell has weight +/// exactly zero even when it would score highest (`ref/qsa.py` PROPERTY 8 tests precisely that). +void kv_gather(const uint16_t* k_pool, const uint16_t* v_pool, const int32_t* page_table, const int32_t* ids, + int64_t n_ids, const QsaShapes& s, uint16_t* k_scratch, uint16_t* v_scratch, void* stream); + +/// WHERE THE INDEXER'S PER-SEQUENCE STATE LIVES. One set per QSA layer per sequence. +/// +/// tail (idx_block - 1, idx_dim) f32 the RAW tail: cells of the block being filled, slot = pos % idx_block +/// dead (idx_dim,) f32 the spare slot's key, = rms_norm(raw key of cell 0), CONSTANT +/// pooled (max_cells/idx_block + 1, idx_dim) f32 block b at row b, the spare slot at row n_bid +/// block_pos (1,) i32 scratch: the kernel writes the completed block's first-cell +/// position here so the rotation can read it ON THE DEVICE +/// +/// **Every pointer in this struct is a DEVICE pointer**, and so are every other pointer this header's entry +/// points take: `rope_neox_apply` indexes its cos/sin table and its position array on the device, so a host +/// scalar passed as `pos` is an illegal access rather than a convenience. `block_pos` exists for exactly that +/// reason - the rotation position is derived from the host's `pos`, so it has to be handed to the device +/// through memory, which is also the only form a CUDA graph can replay. +/// +/// The spare slot MOVES: it is at row `n_bid`, and `n_bid` grows by one on every block completion. It is also +/// constant in value, because it is derived from cell 0's raw key and `rope_neox(., 0)` is the identity - which +/// is what makes a tail of three keys sufficient. A design that pooled only from the tail could not produce +/// this key at all after the first block. +struct QsaIndexerBuffers { + float* tail = nullptr; + float* dead = nullptr; + float* pooled = nullptr; + int32_t* block_pos = nullptr; +}; + +/// Append one raw indexer key (IDX_DIM wide, fp32, NEVER normed and NEVER rotated - `ref/qsa.py` L201-210) and +/// run the pooling if this cell COMPLETES a block. +/// +/// `pooled[b] = rope_neox(rms_norm(mean(raw[b*r .. b*r+r-1]), w_k_norm), pos of cell b*r)` for b = pos/r on a +/// completion, and the spare slot `pooled[n_bid] = rope_neox(rms_norm(raw[0], w_k_norm), 0)`. The rotation +/// position is the block's FIRST cell, which `ref/qsa.py` PROPERTY 4 pins and the test re-pins: rotating at the +/// block's LAST cell keeps every shape and every magnitude. +/// +/// **`pos_dev` IS A DEVICE POINTER AND THIS FUNCTION IS CAPTURABLE.** It used to take `int64_t pos` as a host +/// scalar, and a CUDA graph bakes kernel ARGUMENTS in at capture time - so a captured call would have replayed +/// the FIRST token's position forever: `slot = pos % idx_block` would never advance, no block would ever +/// complete, the indexer would keep pooling cells 0-3, and the selection would stay pinned to the first block. +/// Not a crash and not a small error - a plausible attention output over the wrong cells. Worse than a stale +/// POINTER, which this project's pitfalls list already warns about, because a stale pointer tends to fault while +/// a stale VALUE looks like a working graph. +/// +/// It could not be fixed by swapping the scalar for a pointer alone: `pos` was ALSO read on the host, to decide +/// whether to run the completion rotation at all and which pooled row to rotate. So the rotation moved INTO +/// the pooling kernel, which is why this now takes `cos_tab`/`sin_tab` and `n_rot`, and why `block_pos` is kept +/// only as a readable record of the rotation position rather than as the input to a second launch. +/// +/// `pos_base` IS THE SEQUENCE'S FIRST CELL'S POSITION, so cell j sits at position `pos_base + j`. The kernel +/// cannot derive it: `QsaCache.pos` is what the reference rotates by, and equating a cell's position with its +/// index is only right for a sequence that starts at 0. The test runs the fixture at `pos_base = 100`, where +/// the two readings are 514% apart. `pos_base` is CONSTANT for a sequence, so unlike `pos_dev` it is safe to +/// bake into a graph. +void indexer_key_append(const float* raw, const int32_t* pos_dev, int32_t pos_base, const float* w_k_norm, + float eps, const QsaIndexerBuffers& b, const QsaShapes& s, const float* cos_tab, + const float* sin_tab, void* stream); + +/// Per-CELL indexer scores, `n_kv = pos + 1` of them, fp32. +/// +/// pooled (n_bid + 1, idx_dim) block keys, the spare slot last, as `indexer_key_append` maintains them +/// q_idx (idx_n_head, idx_dim) ALREADY normed and rotated by the caller +/// bias (n_bid + 1,) or null optional additional per-block bias +/// +/// score[j] = bias[cb] + sum_h Relu(dot(pooled[cb], q_idx[h])), cb = min(j / r, n_bid) +/// score[j] += 1e9f for incomplete-tail cells (j >= (n_kv / r) * r). +/// The tail bias matches llama.cpp's causal decode input and applies even when bias is null. +/// Completed blocks receive no tail bias; the step API derives this from each replay's n_kv. +/// +/// The Relu is PER HEAD and the sum is over heads AFTERWARDS: `relu(sum_h dot_h)` is the rival reading, and it +/// is a different number whenever any head's dot is negative. Flipping one query head's sign can never lower +/// the score, which is the property the test asserts. There is NO softmax and NO 1/sqrt(d) here. +void qsa_index(const float* pooled, int64_t n_bid, const float* q_idx, const float* bias, const QsaShapes& s, + int64_t n_kv, float* cell_scores, void* stream); + +/// The largest `qsa_selection_width(n_kv)` cells of `cell_scores`, ascending, into `ids`. +/// +/// Ties are broken by ASCENDING INDEX. ggml does not specify that, and `ref/qsa.py` is explicit about it so a +/// mismatch is at least diagnosable; the host test compares against `np.lexsort((arange, -scores))`, which is +/// the same rule, implemented independently of the kernel's threshold search. +/// +/// `cap` is the caller's buffer size in ids and must be >= the width; the kernel writes EXACTLY `width` and +/// refuses rather than truncating. The `-1` padding that `docs/capture-format.md` L66-73 describes is the +/// capture writer's job, not this kernel's: the padded width is a property of the run, not of one token. +/// +/// `n_kv` must be <= 32768 (`kTopkMaxCells`): the selection runs in ONE block, which is what makes the +/// ascending tie rule deterministic without a second pass over global memory. Phase 3 replaces this with a +/// radix select and Phase 7 raises the cap; until then a longer context is refused loudly. +inline constexpr int64_t kTopkMaxCells = 32768; + +void topk_512(const float* cell_scores, int64_t n_kv, const QsaShapes& s, int64_t cap, int32_t* ids, + void* stream); + +/// Softmax GQA over ONLY the gathered cells. `q` is (n_head, head_dim) f32, already normed and rotated. +/// +/// scores[j] = dot(k[j][q / (n_head/n_head_kv)], q[h]) / sqrt(head_dim) +/// w = softmax_j(scores) +/// attn[h] = sum_j w[j] * v[j][q / (n_head/n_head_kv)] +/// +/// The q -> kv head map is INTEGER DIVISION (`ggml-cpu/ops.cpp` L8729: `iv2 = iq2 / rv2`), not `q % n_head_kv` +/// - with 24 and 2 the two give [0 x12, 1 x12] and [0,1,0,1,...], and both produce a well-formed result. +/// +/// `attn` is (n_head, head_dim) f32 and NOT yet gated; `weights`, when non-null, is (n_head, n_ids) f32, which +/// is what `docs/capture-format.md`'s `L{n}.qsa_scores` records. Produced here rather than recomputed by the +/// caller so the capture cannot drift from the attention it claims to describe. An EMPTY `ids` is legal and +/// gives a zero `attn` (no selected cell has no softmax to take). +void qsa_attend(const float* q, const uint16_t* k_scratch, const uint16_t* v_scratch, int64_t n_ids, + const QsaShapes& s, float* attn, float* weights, void* stream); + +/// `attn *= sigmoid(gate)` in f32, then ONE round to fp16 for the `attn_output` GEMV. +/// +/// q_full (n_head, 2*head_dim) f32 the raw `attn_q` projection, BEFORE any norm +/// gate = q_full[h][head_dim + d] - the SECOND half of each head's 2*head_dim block +/// out (n_head, head_dim) fp16 +/// +/// The split is NOT element-interleaved (`ref/qsa.py` PROPERTY 10 pins that: `q = per_head[:, :head_dim]`, +/// `gate = per_head[:, head_dim:]`), and the rounding happens AFTER the multiply so there is one rounding +/// rather than two. SIGMOID, not SiLU: this artifact's QSA gate is a sigmoid. +void qsa_gate_apply(const float* attn, const float* q_full, const QsaShapes& s, uint16_t* out, void* stream); + +/// THE SAME MULTIPLY WITHOUT THE fp16 ROUND, for the caller whose weight wants Q8_K. +/// +/// `attn_output` is a K-quant (Q4_K/Q5_K/Q6_K in every QSA layer of this artifact) and its `vec_dot_type` is +/// **Q8_K, not fp16** - `docs/activation-contract.md` is explicit, and the measured cost of getting it wrong is +/// 0.66-1.41% per GEMV against a 1e-3 tolerance, which is above what Gate C1 allows. The fp16 form exists +/// because that is what the engine did first; this one exists because that was a gap, and the caller quantizes +/// to Q8_K between the two calls. +/// +/// The arithmetic is IDENTICAL to `qsa_gate_apply`'s up to the last step, and the multiply is in double in both +/// - so switching between them changes exactly one rounding, which is the point. +void qsa_gate_apply_f32(const float* attn, const float* q_full, const QsaShapes& s, float* out, void* stream); + +// ================= THE CAPTURABLE ENTRY POINTS ================= +// +// These take the per-token counts from `step` AND size every launch from a CONSTANT capacity, which is the two +// things a CUDA graph requires. The host-scalar entry points above are thin wrappers over these: they fill a +// module-level step buffer and pass this token's counts as the capacities, so they behave exactly as before and +// stay convenient for a direct launch or a test. +// +// **THE LAYER MUST USE THESE.** The wrappers are correct for a direct launch and WRONG inside a graph: their +// grid and their shared size change per token, and a graph bakes both in. A captured `qsa_index` launched for +// one block leaves every other pooled row unscored; a captured `qsa_attend` sized for one cell reads past its +// shared buffer once the sequence grows. Both failures are silent. +// +// The kernels already guard for the surplus: `qsa_index_kernel` returns for `b > n_bid`, `kv_gather_kernel` +// returns past its device `n_ids`, and `qsa_attend_kernel` handles an empty selection itself. So passing a +// capacity LARGER than this token needs is correct, not merely safe. + +/// `max_blocks` must be at least `(max_cells / idx_block) + 2`. +void qsa_index_step(const float* pooled, const float* q_idx, const float* bias, const QsaShapes& s, + const int32_t* step, int64_t max_blocks, float* cell_scores, void* stream); + +/// `cap` must be `qsa_selection_width(kTopkMaxCells, s)` - the largest width the kernel can ever write. +void topk_512_step(const float* cell_scores, const QsaShapes& s, int64_t cap, const int32_t* step, + int32_t* ids, void* stream); + +/// `max_ids` must be the CAPACITY of `k_scratch`/`v_scratch` in ids. +void kv_gather_step(const uint16_t* k_pool, const uint16_t* v_pool, const int32_t* page_table, + const int32_t* ids, const int32_t* step, int64_t max_ids, const QsaShapes& s, + uint16_t* k_scratch, uint16_t* v_scratch, void* stream); + +/// `max_ids` must be at least the largest selection the geometry can produce, because it sizes the SHARED +/// memory the softmax works in. +void qsa_attend_step(const float* q, const uint16_t* k_scratch, const uint16_t* v_scratch, + const int32_t* step, int64_t max_ids, const QsaShapes& s, float* attn, float* weights, + void* stream); + +/// The cell's position comes from `step`, so this is the form a graph may contain. +void kv_append_step(uint16_t* k_pool, uint16_t* v_pool, const int32_t* page_table, const int32_t* step, + const float* kcur, const float* vcur, const QsaShapes& s, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/qsa_decode_attn.hpp b/include/strata/kernels/qsa_decode_attn.hpp new file mode 100644 index 00000000..2044ac5c --- /dev/null +++ b/include/strata/kernels/qsa_decode_attn.hpp @@ -0,0 +1,44 @@ +// include/strata/kernels/qsa_decode_attn.hpp - plan v0.3 P3/P7: split-K decode attention over the selected cells. +// +// The first attention kernel runs one block per query head (24 blocks on a 48-SM part), reads each key row with one +// thread (uncoalesced) and walks every selected cell serially for the values; at a 4K context (2,051 selected +// cells) that costs ~0.3-0.5 ms per QSA layer. This one reads the KV POOLS directly through the page table and the +// selection ids (no gather copy), in chunks of CHUNK cells per block, and serves all `n_head / n_head_kv` query +// heads that share a KV head from one read of the chunk: +// +// grid (ceil(cap / CHUNK), n_head_kv): s = q.k * scale for 12 heads x CHUNK cells, chunk max m and sum l, +// acc = p . V -> partials +// grid (n_head): merge the chunks with the usual log-sum-exp rescale +// +// FP16 pools or INT8 pools (codes + fp16 scale per 64 values, `kv_q8.hpp`). The grid is sized by the capacity; +// the real count comes from `step[kStepWidth]` as for every other capturable QSA kernel. +#pragma once + +#include "strata/kernels/qsa.hpp" + +#include + +namespace strata::kernels { + +struct QsaAttnPools { + const uint16_t* k_pool = nullptr; ///< fp16 [page][kv_head][page_size][head_dim], or null when int8 + const uint16_t* v_pool = nullptr; + const int8_t* k_q = nullptr; ///< int8 codes, same layout + const int8_t* v_q = nullptr; + const uint16_t* k_scale = nullptr; ///< fp16 [page][kv_head][page_size][head_dim / 64] + const uint16_t* v_scale = nullptr; + const int32_t* page_table = nullptr; +}; + +/// Scratch floats for `cap` selected cells: partial accumulators, maxima and sums. +uint64_t qsa_decode_attn_scratch_floats(int64_t cap, const QsaShapes& s); + +void qsa_decode_attn_step(const float* q, const QsaAttnPools& pools, const int32_t* ids, const int32_t* step, + int64_t cap, const QsaShapes& s, float* scratch, float* attn, void* stream); + +/// Plan v0.3 P5: `n_q` queries at once, each with its own selection: q [n_q, n_head, 256], ids [n_q, cap], steps +/// [n_q, kStepCount], attn [n_q, n_head, 256]; scratch is `n_q` times the single-query size. +void qsa_decode_attn_batch(const float* q, const QsaAttnPools& pools, const int32_t* ids, const int32_t* steps, + int64_t cap, const QsaShapes& s, float* scratch, float* attn, int64_t n_q, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/qsa_select.hpp b/include/strata/kernels/qsa_select.hpp new file mode 100644 index 00000000..d04009d5 --- /dev/null +++ b/include/strata/kernels/qsa_select.hpp @@ -0,0 +1,33 @@ +// include/strata/kernels/qsa_select.hpp - plan v0.3 P5/P7: the QSA indexer's block scores and top-k selection, +// for many queries at once and at long context. +// +// The per-token path scores every pooled block with one FP64 block per row (qsa_index_kernel) and selects with a +// single-block kernel that makes 32 bit-serial passes over every CELL (topk_kernel): ~0.1-0.2 ms per query at 32K, +// once per QSA layer and token - 1-2 ms of a decode token and most of a 32K prompt's time. Here: +// +// qsa_block_scores : one warp per (query, block), FP32; relu per indexer head, summed; block n_bid (the +// incomplete tail) scores the `dead` key and gets +1e9 when it has cells - which is exactly +// what the per-token path reads there, because pooled[n_bid] holds `dead` at that time (in +// a chunk it may already hold a block completed later, so it is not read). +// qsa_block_topk : one block per query; a 4-pass radix select over the query's n_bid + 1 blocks, each +// weighted by its cell count, then the cells emitted in ascending order with ties to the +// lowest index - the same selection as topk_kernel, over a quarter of the elements. +// +// Queries carry their own step record (pos, n_kv, n_bid, width) as everywhere else in QSA. +#pragma once + +#include "strata/kernels/qsa.hpp" + +#include + +namespace strata::kernels { + +/// scores [nq, max_blocks]; q_idx [nq, idx_n_head, idx_dim] (normed and rotated); steps [nq, kStepCount]. +void qsa_block_scores(const float* pooled, const float* dead, const float* q_idx, const int32_t* steps, int64_t nq, + int64_t max_blocks, const QsaShapes& s, float* scores, void* stream); + +/// ids [nq, cap] (cells, ascending); `cap` >= the largest selection width. +void qsa_block_topk(const float* scores, const int32_t* steps, int64_t nq, int64_t max_blocks, int64_t cap, + const QsaShapes& s, int32_t* ids, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/quantize_act.hpp b/include/strata/kernels/quantize_act.hpp new file mode 100644 index 00000000..49d858b2 --- /dev/null +++ b/include/strata/kernels/quantize_act.hpp @@ -0,0 +1,58 @@ +// include/strata/kernels/quantize_act.hpp - activation quantization, host-callable (P2.S2). +// +// `ggml_mul_mat` converts src1 (the ACTIVATION) to the weight's `vec_dot_type` before the dot product. The +// CPU expert path already does this (`bench/micro/cpu_s2.cpp`, int8 + vpdpbusd, parity 1.461e-06); the GPU +// kernels take FP16 activations. Leaving the two inconsistent means the same token computes different +// numbers for different experts, so this is the GPU half of the same rule. +// +// Q2_0 -> Q8_0 is the case that matters most: the routed experts are all Q2_0, which is 31.64 GiB of the 38 +// GiB pack. Q8_K is the OTHER HALF of `VEC_DOT_TYPE` (Q3_K/Q4_K/Q5_K/Q6_K/IQ4_XS) and covers 2.89 GiB of +// dense weights - the attention projections and `ssm_out`, i.e. the numerically sensitive ones. +// +// `docs/activation-contract.md` records the decision that BOTH must exist and be used: an FP16 activation +// against a quantized weight disagrees with the oracle by 0.6-0.9%, which is 6-9x P2.S2's 1e-3 tolerance. +#pragma once + +#include + +namespace strata::kernels { + +// x (n floats) -> ggml's block_q8_0 layout: 34 bytes per 32 elements, { fp16 d ; int8 qs[32] }. +// n must be a multiple of 32. `stream` may be null, in which case the call synchronises. +void quantize_q8_0(const float* x, uint8_t* blocks, int64_t n, void* stream); + +// **R4.2h: THE SAME BLOCKS, PLUS THE CPU's fp32 SCALE - and the CPU's rounding rule with it.** The engine +// computes each routed expert twice (misses on the CPU through `act_quant_q8_1`, hits on the GPU through +// `quantize_q8_0`) and the two disagreed: `ActQ::scale` is fp32 while a `block_q8_0` stores fp16 `d`, which is +// **4.761e-04 relative on 80 of 80 chunks** (`bench/micro/act_quant_parity.cu`). This variant exists so the +// hit path can use the CPU's multiplier. Writes `blocks[n/32 * 34]` and `scales[n/32]`; `scales` must not be +// null. `quantize_q8_0` is unchanged and still matches ggml's bytes, which is what `moe_hit_parity` checks. +void quantize_q8_0_scaled(const float* x, uint8_t* blocks, float* scales, int64_t n, void* stream); + +// The inverse, for round-trip checks: each element becomes `q * d16`. +void dequant_q8_0(const uint8_t* blocks, float* x, int64_t n, void* stream); + +// x (n floats) -> ggml's block_q8_K layout: 292 bytes per 256 elements +// { float d ; int8_t qs[256] ; int16_t bsums[16] } +// n must be a multiple of 256. `stream` may be null, in which case the call synchronises. +// +// This is `quantize_row_q8_K_ref` (ggml-quants.c L2768) transcribed, including the two things that look like +// mistakes and are not: +// +// * the scale is `-127/max`, NOT `-128/max`. The source carries the `-128` version COMMENTED OUT with the +// note that IQ2_XXS needs the change for an awkward AVX implementation. Getting this wrong is a 0.79% +// scaling error - the same order as the quantisation step, so it yields a working-looking activation that +// ggml never sees. It cost round 120. +// * `max` is the SIGNED value at the position of the largest magnitude, so a POSITIVE max gives a NEGATIVE +// iscale and the `+max` element maps to -127 while the `-max` element maps to +127. +// +// The rounding is `nearest_int`, which is round-half-to-EVEN via the 12582912.0f magic number - not +// `roundf`'s half-away-from-zero. They differ only on exact ties, i.e. almost never on real data, which is +// precisely why the wrong one would survive any amount of end-to-end testing; the parity test constructs +// exact ties on purpose. +void quantize_q8_K(const float* x, uint8_t* blocks, int64_t n, void* stream); + +// The inverse: each element becomes `d * qs[j]`, with `d = 1/iscale` stored as f32. +void dequant_q8_K(const uint8_t* blocks, float* x, int64_t n, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/rope.hpp b/include/strata/kernels/rope.hpp new file mode 100644 index 00000000..bf93ced0 --- /dev/null +++ b/include/strata/kernels/rope.hpp @@ -0,0 +1,50 @@ +// include/strata/kernels/rope.hpp - NEOX partial RoPE, host-callable (P2.S2). +// +// Pairing is NEOX: (i, i + n_rot/2), NOT the adjacent pair (2i, 2i+1). The rotation is PARTIAL - only the +// first n_rot of head_dim are touched, 64 of 256 in this artifact - and the tail passes through. +// +// The cos/sin table is built on the HOST in float64 (see src/kernels/cuda/rope.cu for why) and the kernel is +// a pure rotation over it. The table depends on (n_rot, theta, position) and not on the token, so it is worth +// caching; `build_rope_table` fills `max_pos` positions of `n_rot/2` pairs each. +#pragma once + +#include + +#if defined(__CUDACC__) +#define STRATA_ROPE_HD __host__ __device__ +#else +#define STRATA_ROPE_HD +#endif + +namespace strata::kernels { + +/// ONE NEOX PAIR: `(a, b) -> (a*c - b*s, a*s + b*c)`. +/// +/// This exists so the pairing and the sign convention live in ONE place. `rope_neox_apply` walks a whole row +/// with it, and `indexer_key_append`'s pooling kernel uses it to rotate the single pooled row it has just +/// written - two call sites that MUST agree, because the indexer's key is compared against query scores +/// downstream and a mismatched rotation produces a well-formed key pointing the wrong way. +/// +/// It is `__host__ __device__` (the `f16_bits.hpp` pattern) rather than device-only so a host-side reference in +/// a test can call THE SAME function instead of transcribing it. A transcription is exactly how the pairing +/// convention gets written down wrong. +/// +/// In-place is safe: the caller writes both outputs from the two inputs it read, and under NEOX no two pairs +/// share an element (`i` pairs with `i + n_rot/2`, so the halves are disjoint). +STRATA_ROPE_HD inline void rope_neox_pair(float a, float b, float c, float s, float& oa, float& ob) { + oa = a * c - b * s; + ob = a * s + b * c; +} + +void build_rope_table(int n_rot, double theta, int max_pos, float* cos_tab, float* sin_tab); + +// `x` and `out` are (rows, head_dim) and `pos` is (rows,) - one position per row, so a batch of heads at +// different sequence positions is one call. `cos_tab`/`sin_tab` are (max_pos, n_rot/2). `out` may equal `x`. +// +// **`pos` IS A DEVICE ARRAY, NOT A HOST SCALAR**, and that is a graph-capture requirement rather than a style +// choice: a CUDA graph bakes kernel arguments in at capture time, so a host scalar position would replay the +// first token's value forever. See the same note on `indexer_key_append` in `qsa.hpp`. +void rope_neox_apply(const float* x, float* out, int64_t rows, int head_dim, int n_rot, const float* cos_tab, + const float* sin_tab, const int* pos, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/router_top10.hpp b/include/strata/kernels/router_top10.hpp new file mode 100644 index 00000000..2158c1fd --- /dev/null +++ b/include/strata/kernels/router_top10.hpp @@ -0,0 +1,20 @@ +// include/strata/kernels/router_top10.hpp - the MoE router, host-callable (P2.S2). +// +// logits (n_tokens, n_expert) -> the top-k experts per token with renormalised weights. Semantics are +// `ref/moe.py::router`'s: softmax over ALL experts, stable descending argsort (ties break by index), gather, +// then renormalise with ggml's 2**-14 lower clamp. +// +// The BF16 GEMV that PRODUCES the logits is a separate kernel and is not here; this handles the routing only, +// which is why `logits` is taken as an f32 buffer the caller has already filled. +#pragma once + +#include + +namespace strata::kernels { + +// `stream` may be null, in which case the call synchronises before returning. `ids` is (n_tokens, k) int32 and +// `weights` is (n_tokens, k) f32, both device pointers. k must be <= 64. +void router_top10(const float* logits, int n_tokens, int n_expert, int k, int* ids, float* weights, + void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/s2_expert_grouped.hpp b/include/strata/kernels/s2_expert_grouped.hpp new file mode 100644 index 00000000..3778b471 --- /dev/null +++ b/include/strata/kernels/s2_expert_grouped.hpp @@ -0,0 +1,118 @@ +// include/strata/kernels/s2_expert_grouped.hpp - R4's GPU expert tier. +// +// **WHAT THIS IS FOR.** The CPU expert pool is the engine's largest single cost - 663.6 MB of expert bytes per +// token at ~40 GB/s = 16.2 ms on a ~53 ms token - and `test_expert_pool` measures the pipeline hiding +// **1.055 ms of 19.035**. The CPU work is 96% exposed because the residual chain is strictly serial, so the +// only way past it is to stop reading those bytes on the CPU: put experts in VRAM and compute them on the GPU. +// +// **WHY GROUPED, AND WHY THAT WORD IS THE WHOLE DESIGN.** A layer routes ten experts. The obvious +// implementation is a loop over hits calling the per-matrix kernel, and it is wrong by arithmetic: five +// launches per expert at ~6.45 hits and 48 layers is **~1,550 launches per token**, which at a real-stream +// launch cost of ~3.6 us is 5.6 ms - most of the 10.4 ms the tier is supposed to save. So the hits are grouped +// into ONE launch per projection: +// +// 1. `gu_kernel` n_hits x 2 x FF rows - gate and up, together +// 2. `swiglu_kernel` n_hits x FF - silu(gate) * up +// 3. `quantize_q8_0` (the existing one) - the down weight's own activation contract +// 4. `down_kernel` n_hits x H rows +// +// **four launches per layer, not five per expert.** +// +// `h_layer` held-out is **0.0456** - only 4.6% of (layer, token) pairs have all ten experts resident - so this +// cannot be a per-layer grouped kernel with a fixed grid, and the hit list is passed **as data** so the caller +// can keep one captured graph per layer with the maximum grid and a device-side count. +#pragma once + +#include + +namespace strata::kernels { + +/// Bytes of caller-owned scratch `moe_hit_grouped_s2` needs for `n_hits` experts. +/// +/// Two regions: the gate/up activations (`n_hits x 2 x FF` floats) and the quantized intermediate +/// (`n_hits x (FF/32) x 34` bytes). Carved from the caller's arena rather than allocated - a `cudaMalloc` +/// on the token path is forbidden by P2.T10 and illegal during stream capture, and this is called from both. +uint64_t moe_hit_grouped_scratch_bytes(int64_t n_hits, int64_t n_embd, int64_t n_ff); + +/// **ONE GROUPED EXPERT EVALUATION.** For each hit `h` in `[0, n_hits)`, evaluates the expert held in +/// `blob_base + slot_index[h] * blob_bytes` against `x_q8_0` and writes `n_embd` floats to +/// `out + dst_index[h] * n_embd`. +/// +/// * `blob_base` the device slot arena; slot `s` starts at `blob_base + s * blob_bytes` +/// * `slot_index` `n_hits` `int32_t` slot indices on the DEVICE, so the caller can change which experts are +/// resident without recapturing anything +/// * **`dst_index` where each hit's answer goes, also on the device and also `n_hits` long.** +/// +/// **THIS IS NOT A CONVENIENCE - IT IS WHAT LETS THE TWO HALVES MEET.** A layer routes ten +/// experts and only some are resident, so the CPU produces the misses at their ROUTED indices +/// (the router's order is what `moe_combine` weights against) and the GPU produces the hits in +/// whatever order the cache happens to hold them. Writing the hits sequentially would land +/// them on the misses' rows; `dst_index` puts hit `h`'s answer on the row its expert was routed +/// to, so the CPU can zero the hit rows, the GPU can fill them, and `moe_combine` needs no +/// change at all. +/// * `x_q8_0` `(n_embd/32)` `block_q8_0`, 34 bytes each, from `quantize_q8_0` +/// * `scratch` `moe_hit_grouped_scratch_bytes(n_hits, n_embd, n_ff)`, caller-owned +/// * `out` `(k, n_embd)` floats - the SAME buffer the misses were written into +/// * **`x_scales` R4.2h, OPTIONAL, and null keeps the previous behaviour exactly.** When non-null it is +/// `n_embd/32` **fp32** activation scales from `quantize_q8_0_scaled`, and they REPLACE the +/// block's fp16 `d` as the activation multiplier - for the gate/up projection and for the +/// intermediate's own quantization in step 3. +/// +/// **THIS EXISTS BECAUSE A HIT AND A MISS MUST PRODUCE THE SAME NUMBER.** The CPU pool +/// multiplies by the fp32 `ActQ::scale` (`cpu/expert.cpp:92`); this kernel read fp16 `d` out +/// of the `block_q8_0`. `bench/micro/act_quant_parity.cu` measured the gap with both real +/// implementations linked: **80 of 80 chunks differ, max relative 4.761e-04** - 2^-11, pure +/// fp16 rounding - which is why enabling the cache changed the generated tokens. Pass null +/// and this is bit-for-bit the kernel `moe_hit_parity` has always checked at 6.279e-08. +/// +/// **THE BLOB LAYOUT IS `expert.hpp`'s AND IS NOT RESTATED HERE.** Gate/up codes are interleaved by row +/// (`2r` = gate, `2r+1` = up), the down codes follow, and each plane's scales are fp16 - which is the ONE +/// difference from `s_gemv_q8`'s S-forms, whose scales are fp32. That difference is why this is a separate +/// kernel rather than a call into the dense path. +void moe_hit_grouped_s2(const uint8_t* blob_base, const int32_t* slot_index, const int32_t* dst_index, + int64_t n_hits, int64_t blob_bytes, const uint8_t* x_q8_0, void* scratch, float* out, + void* stream, const float* x_scales = nullptr); + +/// Experimental CPU-order implementation. Eight CUDA lanes reproduce the CPU VNNI accumulator lanes, +/// fused multiply-add order, separate correction, and final horizontal reduction. The input must use +/// the CPU quantization contract and fp32 scales. SiLU uses accurate CUDA expf, which can differ from +/// the CPU math library by last bits. End-to-end correctness must be checked against the C1 oracle. +/// Validate both intermediate quantization and final outputs. +/// Optional gate_up_trace receives [all gates | all ups] before SiLU, for parity diagnostics only. +/// Plan v0.3 P4 token graph: the hit path with NO host step. `moe_hit_select` builds this layer's hit list +/// from the routed `ids` and the static residency row (`res_row[e]` = slot or -1) into `slot`/`dst`/`count` +/// (device); `moe_hit_grouped_s2_dev` is `moe_hit_grouped_s2` sized for `cap` hits that reads the real count +/// from `d_count`; `moe_hit_add` adds each hit's row of `hit_out` into `parts` (rows the CPU left at zero). +void moe_hit_select(const int32_t* ids, const int32_t* res_row, int k, int n_expert, int32_t* slot, int32_t* dst, + int32_t* count, void* stream); +void moe_hit_grouped_s2_dev(const uint8_t* blob_base, const int32_t* slot_index, const int32_t* dst_index, + const int32_t* d_count, int64_t cap, int64_t blob_bytes, const uint8_t* x_q8_0, + void* scratch, float* out, void* stream, const float* x_scales); +void moe_hit_add(float* parts, const float* hit_out, const int32_t* dst, const int32_t* count, int64_t cap, + int64_t n_embd, void* stream); +/// Plan v0.3 P6 verify window: `moe_hit_select` over `n` <= 128 routed entries (T tokens x k, flattened), and the +/// hit kernel with one activation PER TOKEN - entry `dst` reads token `dst / k_per_token`'s rows of `x_q8_0` +/// ((n_embd/32)*34 bytes each) and `x_scales` (n_embd/32 floats each). Per hit, bitwise `moe_hit_grouped_s2_dev`. +/// Plan v0.3 P6: routed experts GROUPED. Group g's blob is at device address grp_ptr[g] (a VRAM slot, or a mapped +/// host blob the kernel reads over PCIe); its entries are [grp_start[g], grp_start[g+1]) with ent_tok (the token +/// whose activation row of x_q8_0/x_scales it reads) and ent_dst (its row of `out`). Each row of a blob is read +/// once for all of its entries. Counts are read from device memory; every entry is bitwise the per-entry kernel. +void moe_grouped_s2(const unsigned long long* grp_ptr, const int32_t* grp_start, const int32_t* n_groups, + const int32_t* ent_dst, const int32_t* ent_tok, int64_t cap_groups, int64_t cap_entries, + const uint8_t* x_q8_0, const float* x_scales, void* scratch, float* out, void* stream); +/// Plan v0.3 P6: the groups for `moe_grouped_s2` when every expert id is resident at base + id * blob (the MTP +/// layer's 512 experts): counts[0] groups, counts[1] entries; entry dst = routing index, tok = index / k_per_tok. +void moe_group_resident(const int32_t* ids, int n, int k_per_tok, const uint8_t* base, int64_t blob, + unsigned long long* grp_ptr, int32_t* grp_start, int32_t* counts, int32_t* ent_dst, + int32_t* ent_tok, void* stream); +void moe_hit_select_multi(const int32_t* ids, const int32_t* res_row, int n, int n_expert, int32_t* slot, int32_t* dst, + int32_t* count, void* stream); +void moe_hit_grouped_s2_multi(const uint8_t* blob_base, const int32_t* slot_index, const int32_t* dst_index, + const int32_t* d_count, int64_t cap, int64_t blob_bytes, const uint8_t* x_q8_0, + const float* x_scales, int k_per_token, void* scratch, float* out, void* stream); +void moe_hit_grouped_s2_cpu_order(const uint8_t* blob_base, const int32_t* slot_index, + const int32_t* dst_index, int64_t n_hits, int64_t blob_bytes, + const uint8_t* x_q8_0, void* scratch, float* out, void* stream, + const float* x_scales, float* gate_up_trace = nullptr); + +} // namespace strata::kernels diff --git a/include/strata/kernels/s2_gemv.hpp b/include/strata/kernels/s2_gemv.hpp new file mode 100644 index 00000000..a09b8226 --- /dev/null +++ b/include/strata/kernels/s2_gemv.hpp @@ -0,0 +1,25 @@ +// include/strata/kernels/s2_gemv.hpp - the S2 GEMV, host-callable (P2.S2). +// +// The expert matvec is the hottest thing in the engine: every token runs 48 layers x 10 experts x 3 roles of +// it, against weights that live in DRAM at 31.64 GiB. Layout is `docs/pack-format.md`'s S2: codes packed 4 +// per byte LSB-first, one FP32 scale per 64 elements. GGUF's ne[0] varies fastest, so a tensor is [n_in, +// n_out] with n_in CONTIGUOUS - which is why one thread per output row reads a contiguous run of blocks. +// +// Activations are FP16 per the phase spec ("FP16 activations in"). The OUTPUT here is FP32 rather than FP16: +// the intended FP16 out needs an f32->fp16 conversion, and rather than write one under a parity test that +// would then be testing two new things at once, this step keeps the output width the one that can be checked +// exactly. The FP16 output and its 1e-3 tolerance is the next increment, and this file says so rather than +// leaving a reader to wonder. +#pragma once + +#include + +namespace strata::kernels { + +// y[o] = sum_i fp16(x[i]) * W[i][o], with W in S2 canonical planes. +// `codes` is n_out * (n_in/64) * 16 bytes, `scales` n_out * (n_in/64) floats, `x` n_in uint16 fp16 patterns, +// `y` n_out floats. All device pointers. n_in must be a multiple of 64. +void s2_gemv(const uint16_t* x, const uint8_t* codes, const float* scales, float* y, int64_t n_in, + int64_t n_out); + +} // namespace strata::kernels diff --git a/include/strata/kernels/s2_gemv_q8.hpp b/include/strata/kernels/s2_gemv_q8.hpp new file mode 100644 index 00000000..9c7beb97 --- /dev/null +++ b/include/strata/kernels/s2_gemv_q8.hpp @@ -0,0 +1,22 @@ +// include/strata/kernels/s2_gemv_q8.hpp - the S2 GEMV over Q8_0-QUANTIZED activations. +// +// `ggml_mul_mat` converts src1 to the weight's `vec_dot_type`, which for Q2_0 is Q8_0. The other CUDA kernels +// here take FP16 activations, and the CPU expert path already quantizes to int8 - so this is the missing GPU +// half of the same rule, and without it the two paths disagree on the same token. +// +// CORRECTNESS VERSION: the activation is dequantized inside the loop. The speed win here is `__dp4a` (four +// int8 MACs per instruction) and belongs to Phase 3, once the numerics are settled. +#pragma once + +#include + +namespace strata::kernels { + +// `act` (n_in/32) ggml Q8_0 blocks, 34 bytes each, as `quantize_q8_0` produces +// `codes` n_out * (n_in/4) bytes, one 2-bit code byte per quad +// `scales` n_out * (n_in/64) +// `y` n_out floats +void s2_gemv_q8(const uint8_t* act, const uint8_t* codes, const float* scales, float* y, int64_t n_in, + int64_t n_out, int threads_per_row, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/s_gemv.hpp b/include/strata/kernels/s_gemv.hpp new file mode 100644 index 00000000..340df04e --- /dev/null +++ b/include/strata/kernels/s_gemv.hpp @@ -0,0 +1,133 @@ +// include/strata/kernels/s_gemv.hpp - the S-family GEMV, host-callable (P2.S2). +// +// ONE kernel for S2, S4 and S8, because `docs/pack-format.md` gives them ONE decode: +// +// value = cb[code] * scale + offset +// +// with a per-TYPE codebook, code bias, group size and offset presence. That uniformity is the point of the +// canonical form - 13 source types collapse to three code widths and a handful of attributes - so the kernel +// takes those attributes as ARGUMENTS rather than being specialised per source type. The manifest already +// carries them per tensor (round 163 added the tests that keep it honest). +#pragma once + +#include + +namespace strata::kernels { + +// Which table turns a code into a number. `Affine` is `code + bias`; `Iq4Nl` is the non-linear +// `kvalues_iq4nl`, which no bias can express - the reason the canonical form carries a codebook at all. +enum class Codebook : int { Affine = 0, Iq4Nl = 1 }; + +struct SForm { + int code_bits = 2; // 2, 4 or 8 + int code_bias = -1; // subtracted from the code IN THE INTEGER DOMAIN before the multiply + int group_elems = 64; // elements per scale (and per offset, when present) + Codebook codebook = Codebook::Affine; + bool has_offset = false; // only the K-quants with a min (Q4_K, Q5_K) have one + + /// WHICH ACTIVATION THIS WEIGHT'S `vec_dot_type` IS: 0 = Q8_0, 1 = Q8_K. + /// + /// **IT IS AN ATTRIBUTE OF THE CANONICAL FORM AND IT CANNOT BE DERIVED FROM THE OTHERS.** Q5_0 and Q5_K + /// are both 8-bit with bias -16 (only `has_offset` differs); IQ4_NL and IQ4_XS are both 4-bit with the + /// IQ4NL codebook and no offset, and differ in NOTHING this struct otherwise holds. So it is CARRIED from + /// the manifest's `source_type` rather than reconstructed - see `docs/activation-contract.md` and the + /// `act_kind` column `tools/pack_index.py` writes. + int act_kind = 0; +}; + +// y[o] = sum_i fp16(x[i]) * W[i][o]. GGUF's ne[0] varies fastest, so a tensor is [n_in, n_out] with n_in +// CONTIGUOUS: row o is a contiguous run of n_in/group_elems groups. +// +// `codes` n_out * (n_in / (8/code_bits)) bytes +// `scales` n_out * (n_in / group_elems) floats +// `offset` the same count as scales, or nullptr when `has_offset` is false +// `y` n_out floats +void s_gemv(const uint16_t* x, const uint8_t* codes, const float* scales, const float* offset, float* y, + int64_t n_in, int64_t n_out, const SForm& form); + +// The same computation with the ROW SPLIT ACROSS THREADS: one block of `threads_per_row` threads per output +// row, each accumulating a strided subset, reduced through shared memory. +// +// WHY BOTH EXIST. `s_gemv` gives one thread per output row, so its parallelism is the OUTPUT WIDTH - and the +// expert gate/up shape is [2560 x 640], which is 640 threads over 48 SMs, 13 per SM. Measured, that +// orientation ran 4.6x slower than [640 x 2560] with identical weights (Memory/LEDGER.md L1). This variant +// makes the parallelism independent of the output width. +// +// It is NOT bit-identical to `s_gemv`: the partial sums are added in a different order, so the parity test +// carries a tolerance for exactly this reason. Both are kept and both are tested, because the naive one is +// the reference the split one is checked against. +void s_gemv_split(const uint16_t* x, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, int threads_per_row); + +// S2 ONLY, with the code load amortised over four consecutive elements (they share one byte) and the +// activation load over four halves (one 64-bit word). S2 is 31.64 GiB of the 38 GiB pack, so it is the kernel +// whose instruction count matters. No `offset` - S2 has none, and a parameter that is always null is a +// parameter that will eventually be passed by mistake. +void s2_gemv_quads(const uint16_t* x, const uint8_t* codes, const float* scales, float* y, int64_t n_in, + int64_t n_out, int threads_per_row); + +// S2 with two further instruction reductions: `x` staged in SHARED memory once per block instead of re-read +// from global by every output row, and the code unpack replaced by a 256-entry constant-memory table of +// float4 (one broadcast load per four elements instead of shifts, masks and an int-to-float convert). +// `stage_x` selects the staging so the two can be measured against each other rather than assumed. +void s2_gemv_fast(const uint16_t* x, const uint8_t* codes, const float* scales, float* y, int64_t n_in, + int64_t n_out, int threads_per_row, bool stage_x); + +// The same computation as `s_gemv_split` but launched on `stream` (a cudaStream_t passed as void*, so this +// header stays free of CUDA types) and WITHOUT synchronising. It exists because the architecture's premise - +// that the expert stream overlaps with the compute that consumes it - cannot be measured through an entry +// point that waits for every stream. The synchronous ones remain the default; this is for the loop that has +// to overlap, and for the test that establishes whether overlapping is worth building. +void s_gemv_split_async(const uint16_t* x, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, int threads_per_row, + void* stream); + +/// THE SAME GEMV OVER **Q8_K** ACTIVATIONS, which is the contract for every K-quant and IQ-quant weight. +/// +/// `docs/activation-contract.md` settles this: `ggml_mul_mat` converts the activation to the weight's +/// `vec_dot_type`, and for Q3_K/Q4_K/Q5_K/Q6_K/IQ4_XS that type is Q8_K - NOT fp16. The two differ by +/// 0.6-0.9% per GEMV against a 1e-3 tolerance, and the K-quants are 2.89 GiB of the dense weights: the +/// attention projections and `ssm_out`, i.e. the numerically sensitive ones. +/// +/// `x` is the block_q8_K layout, 292 bytes per 256 elements: `{ f32 d ; int8_t qs[256] ; int16_t bsums[16] }`. +/// `bsums` is NOT read here - it exists for ggml's AVX2 path - but the STRIDE includes it, so a buffer +/// produced by `quantize_q8_K` can be passed straight in. +/// +/// The activation is dequantized on the fly (`d * qs[i]`), one multiply per element, exactly as the fp16 path +/// does one `__half2float` per element. The weight decode is the SAME code as `s_gemv` - one implementation, +/// because a second decode of the canonical form is a second thing to get wrong. +void s_gemv_q8k(const uint8_t* x_q8k, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, void* stream); + +/// The row-split form, for output widths too small to fill the machine. +/// +/// **THERE IS NO `threads_per_row` HERE, AND THAT IS A FIX RATHER THAN AN OVERSIGHT.** The parameter used to be +/// in this signature and the implementation threw it away - `(void) threads_per_row; // informational` - so a +/// caller reading the declaration reasonably believed it was tunable, and round 224 spent a whole sweep +/// measuring it and concluding from the flat result that the kernel was instruction-bound. The sweep could not +/// have shown anything else: **it was measuring a knob that is not connected.** +/// +/// This form is WARP per output row, which is what the shape wants, and the parameter is gone from the +/// interface so that no one can believe otherwise. `s_gemv_split`/`s_gemv_split_async` DO take threads_per_row +/// and DO use it, if a caller wants to tune that shape. +void s_gemv_q8k_split(const uint8_t* x_q8k, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, void* stream); + +/// THE SAME, OVER A **Q8_0** ACTIVATION - `block_q8_0`, 34 bytes per 32 elements, from `quantize_q8_0`. +/// +/// **THIS IS THE KERNEL THAT WAS MISSING, AND ITS ABSENCE WAS DESCRIBED INCORRECTLY FOR SEVERAL ROUNDS.** The +/// legacy block formats (Q2_0, Q4_0, Q5_0, Q8_0, IQ4_NL) have `vec_dot_type` Q8_0, not Q8_K. Every one of them +/// in this pack except `ffn_down_shexp` is `Q2_0`, whose attributes happen to BE S2's (2 bits, group 64, +/// bias -1) - so `s2_gemv_q8`, which hardcodes those, covered them by coincidence. `ffn_down_shexp` is +/// IQ4_NL/Q4_0/Q5_0/Q8_0 in every layer with **group 32 and bias -16/0/-8**, and its `n_in` is **640**. +/// +/// 640 is a multiple of 32 and NOT of 256, so **Q8_K is structurally impossible for it rather than merely +/// unimplemented** - which is what the "Q8_K is not implemented" note in `shared_expert.hpp` was really +/// pointing at. +/// +/// `n_in` must be a multiple of 32. `group_elems` must be a power of two and a multiple of four, the same two +/// requirements `s_gemv_q8k_split` has and for the same reasons. +void s_gemv_q8_0_split(const uint8_t* x_q8_0, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/sampler.hpp b/include/strata/kernels/sampler.hpp new file mode 100644 index 00000000..32a5ded9 --- /dev/null +++ b/include/strata/kernels/sampler.hpp @@ -0,0 +1,43 @@ +// include/strata/kernels/sampler.hpp - the sampler chain, host-callable (P2.S2). +// +// penalties -> top_k -> top_p -> temperature -> pick +// +// The ORDER is specified in docs/sampling.md, transcribed from llama.cpp's own chain. Two facts there are +// easy to get backwards: temperature comes AFTER the truncation filters, and penalties come AFTER temperature. +// +// PENALTIES ARE NOT IMPLEMENTED YET: they need the token history, which is not in this signature, so a caller +// passing a non-zero `penalty_last_n` gets a loud failure rather than a silently unpenalised sample. +#pragma once + +#include + +namespace strata::kernels { + +struct SamplerParams { + int top_k = 20; // 0 disables the filter + float top_p = 0.95f; // 1.0 disables the filter + float temperature = 1.0f; // <= 0 means greedy + int min_keep = 1; // top_p keeps at least this many + int penalty_last_n = 0; // 0 disables; the window over `history` to count occurrences in + float penalty_repeat = 1.0f; + float penalty_freq = 0.0f; + float penalty_present = 0.0f; + uint64_t seed = 0; // drives Philox, which is counter-based on (seed, token index) + uint64_t counter = 0; // absolute draw index of row 0; advance across decode calls + bool greedy = false; +}; + +// logits (n_tokens, n_vocab) -> one sampled token id per row in `out`. +// +// **`logits` AND `out` ARE DEVICE POINTERS.** This is a CUDA kernel launch, not a host function, and nothing +// in the parameter names or the types says so - `const float* logits` reads exactly like a host buffer. +// Measured: passing host memory for `logits` faults inside the kernel with an ILLEGAL MEMORY ACCESS that is +// reported by whatever synchronising call happens NEXT, which will be somewhere else entirely and will name a +// buffer that has nothing to do with it. A sticky async fault does not know where it came from. +// +// `history` is (n_tokens, history_len) int32, the most recent tokens for each row with any unused slots set to +// -1; only the last `p.penalty_last_n` of each row are counted. Pass nullptr and 0 when no penalties apply. +void sample_tokens(const float* logits, int n_tokens, int n_vocab, const int* history, int history_len, + const SamplerParams& p, int* out, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/shared_expert.hpp b/include/strata/kernels/shared_expert.hpp new file mode 100644 index 00000000..8909ed9e --- /dev/null +++ b/include/strata/kernels/shared_expert.hpp @@ -0,0 +1,101 @@ +// include/strata/kernels/shared_expert.hpp - the shared expert, host-callable (P2.S2). +// +// h = silu(x @ gate_shexp.T) * (x @ up_shexp.T) <- SILU ON GATE +// h = h @ down_shexp.T +// g = sigmoid(x @ gate_inp_shexp) one SCALAR per token +// return h * g[:, None] +// +// The result is ADDED to the routed output, not weighted against it. Shape note: `n_ff` for the shared expert +// matches the routed experts' width (640) in this artifact. +// +// The SForm arguments describe canonical planes for the three expert projections and their historical +// CPU-derived activation images (Q8_0 or Q8_K). The default scalar gate separately consumes BF16 activations. +// The opt-in native BF16 gate instead consumes the original F32 activation and uses the pinned CUDA MMVF +// contract. Optional native projection weights select the pinned CUDA Q8_1 MMVQ contract independently. +#pragma once + +#include + +#include "strata/kernels/s_gemv.hpp" + +namespace strata::kernels { + +/// Opt in to pinned CUDA BF16/F32 MMVF and FP32 sigmoid for the shared scalar gate (default false). +/// Configure before session capture; captured graphs retain their selected kernels. +/// In this mode shared_expert requires its optional unrounded x_f32 input. +void shared_expert_set_native_bf16(bool enabled); + +/// Optional native GGUF projections. Each supported type with nonnull data +/// replaces only that canonical projection; absent or unsupported entries fall +/// back independently. Any active entry selects pinned FP32 SwiGLU arithmetic. +/// Caller owns device weights and Q8_1 scratch for the largest active input +/// (n_embd for gate/up, n_ff for down), sized by native_q8_1_bytes. All storage +/// must survive graph execution; use one ordered nonnull stream with no overlap. +struct NativeSharedWeights { + int gate_type = -1, up_type = -1, down_type = -1; + const void* gate_data = nullptr; + const void* up_data = nullptr; + const void* down_data = nullptr; + void* q8_1 = nullptr; +}; + +/// Bytes of caller-owned scratch `shared_expert` needs. **THE KERNEL USED TO `cudaMalloc` FOUR BUFFERS ON +/// EVERY CALL**, which is two separate violations in one line: `cudaMalloc` is ILLEGAL INSIDE A CAPTURE +/// (`cudaErrorStreamCaptureUnsupported`), so the MoE - and therefore the whole per-layer graph - could not be +/// captured at all; and P2.T10 requires ZERO token-path allocations, which this was. Neither is visible in a +/// test that only checks the output, and the capture test found it in one run. +uint64_t shared_expert_scratch_bytes(int64_t n_ff); + +/// `x_q8_0` and `x_q8k` are the TWO QUANTIZED IMAGES of the same activation, and which one a projection uses is +/// decided per weight by its `SForm::act_kind`. There is no fp16 activation here any more. +/// +/// **THE fp16 PATH WAS A REAL ERROR AND IT WAS DESCRIBED WRONG FOR SEVERAL ROUNDS.** This header used to say +/// the weights "are Q3_K / IQ4_XS / Q5_0 whose `vec_dot_type` is Q8_K, which is not implemented". In fact +/// `ffn_down_shexp` is IQ4_NL/Q4_0/Q5_0/Q8_0/Q2_0 in every layer - **no K-quant at all** - so its contract is +/// Q8_0; and its `n_in` is 640, which is not a multiple of 256, so **Q8_K is structurally impossible for it**. +/// The gate/up weights are Q2_0 on 21 and 13 layers (Q8_0 again) and K-quants on the rest. +/// +/// x_f32 is required for the native BF16 gate or native gate/up projections. +/// Native down reads the unrounded F32 SwiGLU intermediate directly. No allocation occurs. +/// +/// Measured cost of the wrong choice: 0.66-1.41% per GEMV. `s_gemv_q8_0_split` was written for the Q8_0 half +/// (LEDGER L55) and `s2_gemv_q8` already served the Q2_0 half. +void shared_expert(const uint8_t* x_q8_0, const uint8_t* x_q8k, const uint16_t* x_bf16, const SForm& gate_form, + const uint8_t* gate_codes, const float* gate_scales, const float* gate_off, + const SForm& up_form, const uint8_t* up_codes, const float* up_scales, const float* up_off, + const SForm& down_form, const uint8_t* down_codes, const float* down_scales, + const float* down_off, const uint16_t* gate_inp_bf16, float* scratch, float* out, + int64_t n_embd, int64_t n_ff, int tpr, void* stream, const float* x_f32 = nullptr, + const NativeSharedWeights* native = nullptr); + +/// Plan v0.3 P6: the shared expert for `n_tok` <= 8 tokens (a verify window) with all three projections native: +/// multi-column MMVQ, so the weights are read once; every token is bitwise `shared_expert` on that token. +/// `x` (n_tok, n_embd) f32, `x_bf16` the same rounded (only read when the native BF16 gate is off), `gate`/`up` +/// (n_tok, n_ff) scratch, `g` n_tok floats, `out` (n_tok, n_embd). `nw.q8_1` must hold n_tok columns of n_embd. +void shared_expert_multi(int n_tok, const float* x, const uint16_t* x_bf16, const NativeSharedWeights& nw, + const uint16_t* gate_inp_bf16, float* gate, float* up, float* g, float* out, int64_t n_embd, + int64_t n_ff, void* stream); + +/// The MoE block's final combination, `ref/moe.py::moe` L156: +/// +/// y = sum over the k routed experts of w[k] * parts[k] + shared +/// +/// `parts` is (k, n_embd) contiguous - one row per SELECTED expert, in the router's own order - and `shared` +/// may be null for a layer with no shared expert. +/// +/// TWO THINGS A READER GETS WRONG HERE, and they are why this is not inlined at the call site: +/// +/// * The ROUTED outputs are router-WEIGHTED; the SHARED output is ADDED PLAIN. `ref/moe.py` L116 says so +/// outright - "router-weighted, not renormalised against it" - and weighting the shared term too is a +/// one-line change that produces a plausible number. +/// * The weights come from `router_top10` ALREADY renormalised (they sum to 1 over the selected experts). +/// Normalising again here would be a second, different normalisation, and it is invisible whenever the +/// router's weights happen to sum near 1 anyway. +/// +/// One thread per output element, k accumulations each; k is 10 at the real geometry. This is where the CPU +/// expert pool's outputs are summed: `ExpertPool` carries a `weight` per job and deliberately does NOT apply +/// it, because the sum has to happen once over all k and not once per worker. +void moe_combine(const float* parts, const float* weights, const float* shared, float* y, int64_t n_embd, + int64_t k, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/kernels/verify_kernels.hpp b/include/strata/kernels/verify_kernels.hpp new file mode 100644 index 00000000..d6609322 --- /dev/null +++ b/include/strata/kernels/verify_kernels.hpp @@ -0,0 +1,80 @@ +// include/strata/kernels/verify_kernels.hpp - plan v0.3 P6: kernels for the speculative VERIFY window, where T +// tokens (the last accepted token and T-1 drafts) go through a layer at once. +// +// The GDN recurrence is the one piece of state that cannot simply be overwritten by the next window, so its +// kernels come in two halves: +// +// * the verify half reads the conv history and the recurrent state, runs all T tokens in order (bitwise the +// single-token kernels' arithmetic per token) and writes ONLY the per-token outputs - the state is left +// untouched; +// * the commit half, launched once acceptance is known, replays the first `n_keep` tokens from the inputs the +// verify half stored and writes the state. Rejected tokens therefore never touch the state and there is no +// snapshot of the 113 MB recurrent state to keep. +// +// Everything that varies per window (token ids, `n_keep`) is read from DEVICE memory so the kernels can be captured. +#pragma once + +#include + +namespace strata::kernels { + +inline constexpr int kVerifyMaxT = 8; + +/// For token t of T: conv over [history(3) | qkv_0 .. qkv_t] -> SiLU -> L2 norm of the q/k heads -> h[t]. +/// `history` is NOT written. Bitwise `fused_gdn_conv_l2` per token. +void gdn_conv_l2_multi(const float* history, const float* qkv, const float* conv_w, float* h, int channels, + int qk_heads, float eps, int n_tok, void* stream, int t_begin = 0); +/// history <- the last 3 entries of [history | qkv_0 .. qkv_{n-1}], n = *n_keep (0 leaves it as it was). +void gdn_conv_commit(float* history, const float* qkv, int channels, const int32_t* n_keep, void* stream); +/// alpha/beta for T columns of x (T, n_embd): gate (T, h_v), beta (T, h_v). Bitwise `fused_gdn_ab` per column. +void gdn_ab_multi(const float* x, const uint16_t* w_alpha, const uint16_t* w_beta, const float* dt, const float* ssm_a, + float* gate, float* beta, int n_embd, int h_v, int n_tok, void* stream); +/// The recurrence + output norm for T tokens (h = (T, conv_channels) as q|k|v, gate/beta (T, h_v), z/y +/// (T, value_dim)). With `n_keep == nullptr` the state is read and NOT written (verify); otherwise the first +/// *n_keep tokens are run and the state is written (commit; `y` may be scratch). Bitwise `fused_gdn_step_norm`. +void gdn_step_norm_multi(float* state, const float* h, int conv_channels, const float* gate, const float* beta, + const float* z, const float* gamma, float eps, float* y, int h_k, int h_v, int n_tok, + const int32_t* n_keep, void* stream, int t_out_begin = 0); +/// Spin until *flag >= value (a mapped host flag). The value is fixed at capture, so several rings can be +/// outstanding at once (the split verify window keeps two). +void wait_flag_ge(const uint32_t* flag, uint32_t value, void* stream); + +/// Rows of the S2/S4/S8 embedding for T token ids read from DEVICE memory; out (T, n). Bitwise `embedding_gather`. +void embedding_gather_dev(const uint8_t* codes, const float* scales, const float* offsets, const int32_t* tokens, + int n_tok, int64_t n, int code_bits, int code_bias, int group_elems, uint64_t row_codes, + uint64_t row_groups, float* out, void* stream); +/// R[t][c][:] = x[t][:] for the hc streams. +void broadcast_streams(const float* x, float* R, int64_t n_embd, int hc, int n_tok, void* stream); +/// dst[:n] = src[(*index) * stride + :n] (index read from device memory; a negative index copies nothing). +void copy_indexed(float* dst, const float* src, int64_t stride, const int32_t* index, int64_t n, void* stream); + +/// Plan v0.3 P6: copy *n (device memory) blobs of `blob_bytes` from mapped host memory (src[k], device aliases) +/// into dst + k * blob_bytes with coalesced 16-byte loads - the PCIe share of a layer's missed experts, staged +/// into VRAM before the grouped expert kernel reads them. Launched for a capacity of `cap` blobs. +void fetch_blobs(const unsigned long long* src, const int32_t* n, uint8_t* dst, int64_t blob_bytes, int cap, void* stream); +/// ptr[k] = base + k * blob_bytes for k < *n (the staged copies `fetch_blobs` made). +void rebase_ptrs(unsigned long long* ptr, const int32_t* n, uint8_t* base, int64_t blob_bytes, void* stream); + +// ---- the MTP draft layer (src/core/mtp.cpp) +/// R[t][c][:] = h[t][c][:] + e[t][:] (the embedding branch added to every stream). +void add_streams_broadcast(const float* h, const float* e, float* R, int64_t n_embd, int hc, int n_tok, void* stream); +/// Every routed expert is resident (slot = expert id): slot[i] = ids[i], dst[i] = i, *count = n. +void ident_hits(const int32_t* ids, int n, int32_t* slot, int32_t* dst, int32_t* count, void* stream); +/// The draft chain's next input: R_dst[:] = R_src[row], tok_dst[0] = ids[row], out[j] = ids[row], with +/// row = *row_dev (device memory). `out` may be mapped host memory. +void mtp_select(const float* R_src, int64_t R_stride, const int32_t* ids, const int32_t* row_dev, float* R_dst, + int32_t* tok_dst, int32_t* out, int j, void* stream, const float* probs = nullptr, + float* out_p = nullptr); +/// dst row i = src row ids[i] (row_bytes each, multiple of 16), for n rows. +void gather_rows(const uint8_t* src, int64_t row_bytes, const int32_t* ids, int64_t n, uint8_t* dst, void* stream); +/// ids[t] = table[ids[t]] for n entries (a subset index back to a token id). +void map_ids(int32_t* ids, const int32_t* table, int n, void* stream); +/// probs[t] = softmax(logits[t])[ids[t]] for n_rows rows of n_vocab (the probability of each row's argmax). +void row_top_prob(const float* logits, int n_rows, int n_vocab, const int32_t* ids, float* probs, void* stream); +/// Dense-attention step records for `n` cells: [cell, cell+1, (cell+1)/4, cell+1] from cells[i] (device memory). +void dense_steps(const int32_t* cells, int n, int32_t* steps, void* stream); +/// A sliding attention window: for `n` step records (kStepCount ints each, n_kv at [1]) the selection becomes the +/// last `window` cells: ids[q * ids_stride + j] = max(0, n_kv - window) + j and the record's width = the count. +void window_ids(int32_t* steps, int n, int window, int32_t* ids, int64_t ids_stride, void* stream); + +} // namespace strata::kernels diff --git a/include/strata/ngram/ple_reader.hpp b/include/strata/ngram/ple_reader.hpp new file mode 100644 index 00000000..0552ff1e --- /dev/null +++ b/include/strata/ngram/ple_reader.hpp @@ -0,0 +1,86 @@ +// include/strata/ngram/ple_reader.hpp - plan v0.3 P2: the n-gram table read straight from the SSD. +// +// The table is 320,001,536 rows of 90 bytes (26.8 GiB) and is never held in RAM: every row comes from an +// unbuffered 4 KiB read (platform::DirectFile). A token needs 16 rows on 16 different pages, and all 16 depend +// on the token itself, so the only time to hide them is the embedding plus layer 0. Hence the split API: +// +// Ticket t = reader.issue(rows, n, out_raw); // as soon as the token id is known +// ... // embedding, layer 0 +// reader.collect(t); // before layer 1 reads the rows +// +// `issue` also serves prefill: pass all 16 x N rows of a chunk; pages are deduplicated, sorted by offset and +// kept at most `max_inflight` deep, so a chunk's reads can run while the previous chunk computes. +// +// THE ROW CACHE IS NOT THE TABLE. It keeps rows this process has already fetched (90 bytes each, bounded, +// clock eviction). Measured on the frozen corpus (bench/results/2026-09-23-ngram-io): 1M rows (~95 MB) +// would serve up to ~82% of reads; within one long prompt 20-34% of rows recur. Capacity 0 disables it. +#pragma once + +#include "strata/platform/direct_file.hpp" + +#include +#include +#include + +namespace strata::ngram { + +inline constexpr uint32_t ROW_BYTES = 90; +inline constexpr uint32_t PAGE = 4096; + +struct ReaderStats { + uint64_t requests = 0; ///< rows asked for + uint64_t cache_hits = 0; ///< rows served from the row cache + uint64_t dedup_rows = 0; ///< rows that shared a page already being read in the same ticket + uint64_t reads = 0; ///< SSD read requests issued + uint64_t bytes = 0; ///< bytes read from the SSD + double wait_us = 0; ///< time `collect` spent blocked + double submit_us = 0; ///< time spent inside the read submission call (non-zero = it blocks) + double read_us_sum = 0; ///< sum of per-read latencies (issue to completion) + uint64_t late_injected = 0; ///< reads delayed by fault injection + std::vector read_us; ///< last <= 65,536 read latencies, for percentiles + double percentile(double q) const; +}; + +class PleReader { +public: + struct Ticket { + uint32_t id = 0; + }; + + PleReader(); + ~PleReader(); + PleReader(const PleReader&) = delete; + PleReader& operator=(const PleReader&) = delete; + + /// `table_offset` is the byte offset of row 0 in the file and `n_rows` the row count; both come from a + /// validated GGUF parse (PleTable::open checks the table exactly fills the file from there). + /// `io_thread` (default): a worker thread submits and reaps reads, so `issue` costs the caller no ReadFile + /// calls. false: the caller's thread does it (A/B arm). + bool open(const std::string& path, uint64_t table_offset, uint64_t n_rows, uint32_t max_inflight, + uint64_t cache_rows, std::string& err, bool io_thread = true); + void close(); + bool is_open() const; + + /// Start fetching `n` rows; row i's 90 raw bytes land at `out_raw + 90 * i`. `out_raw` must stay valid + /// until `collect` returns. Out-of-range rows produce 90 zero bytes (the mmap path's behaviour). + Ticket issue(const uint32_t* rows, size_t n, uint8_t* out_raw); + + /// Block until every row of the ticket is in `out_raw`. Returns false on an I/O error (message in `err`). + bool collect(Ticket t, std::string& err); + + /// Fault injection for tests and for the plan's P2 exit check: every read completes no earlier than + /// `delay_us` after it was issued. 0 disables. + void set_injected_delay_us(double delay_us); + + /// Read with no ticket in flight (the worker updates these while reads are outstanding). + const ReaderStats& stats() const; + void reset_stats(); + uint64_t cache_capacity() const; + uint64_t cache_size() const; + +private: + struct Impl; + Impl* impl_ = nullptr; +}; + +} // namespace strata::ngram diff --git a/include/strata/plan/plan.hpp b/include/strata/plan/plan.hpp new file mode 100644 index 00000000..b974f1f5 --- /dev/null +++ b/include/strata/plan/plan.hpp @@ -0,0 +1,168 @@ +// include/strata/plan/plan.hpp - P1.S9 census + memory planner. +// +// This is the component that makes one binary adapt to the GPU it is running on. The engine has 33.97 GB of +// expert weights (has to live in DRAM and be streamed) and a fixed VRAM budget that has to hold the dense +// weights, the KV cache, the indexer keys, the recurrent state and as many expert-cache slots as remain. +// Every one of those competes, so the plan is an arithmetic result rather than a configuration choice, and +// it must REFUSE rather than silently overcommit. +// +// DERIVATION POLICY. `phases/00-README.md` §2 gives three rows of expected values but no formula, so the +// arithmetic here is derived from the model's geometry and the table is used as a CHECK. Where the two +// disagree the geometry wins and the disagreement is reported - a table transcribed by hand is not a +// specification, and fitting formulas to it would hide exactly the errors this is supposed to catch. +#pragma once + +#include +#include +#include +#include + +namespace strata::plan { + +// ---- model geometry (the real Qwen3.8-Flash-Next artifact) ------------------ +struct Geometry { + int n_layers = 48; + int n_qsa_layers = 12; // layers 3, 7, ... 47 - the full-attention ones + int n_kv_heads = 2; + int head_dim = 256; + int kv_group = 64; // INT8 KV is grouped-64: one fp16 scale per 64 elements + int qsa_block = 4; // the indexer pools one key per 4-token block + /// THE INDEXER'S KEY STORE, and the trap that has now been walked into twice in opposite directions. + /// + /// `indexer.q_proj.weight` is [2560, 512] = **4 QUERY heads** of 128. `indexer.k_proj.weight` is + /// [2560, 128] = **ONE shared KEY head**. The queries are not cached; only the key is. So the cache + /// holds one 128-wide key per TOKEN, pooled to one per `qsa_block`-token block - which + /// `include/strata/kernels/qsa.hpp` states directly: `pooled (max_cells/idx_block + 1, idx_dim)`, and + /// its own note that the fp32 store is "50 MiB at 32K" (12 layers x 8192 blocks x 128 x 4 B = 48 MiB). + /// + /// original indexer_heads(1) * head_dim(256) / 4 = 64 B/token/layer 2x too big + /// round 194 indexer_heads(4) * key_dim(128) / 4 = 128 B/token/layer 4x too big + /// correct key_heads(1) * key_dim(128) / 4 = 32 B/token/layer + /// + /// Round 194 "fixed" this by reading `indexer.head_count = 4` out of the metadata and multiplying by it - + /// right about the metadata, wrong about what was being counted. **A head count in the metadata does not + /// say WHICH tensor it counts.** + int indexer_q_heads = 4; // query heads: they multiply the SCORES, and cost no cache at all + int indexer_key_heads = 1; // k_proj is [n_embd, 128]: ONE shared key head + int indexer_key_dim = 128; // the key width, and the width of the thing that IS cached + + /// GDN geometry. `docs/semantics.md`: `ssm.state_size = 128`, `ssm.time_step_rank = 48` (the v heads), + /// `ssm.conv_kernel = 4`, and `n_embd_r() = (d_conv-1) * (ssm_d_inner + 2*n_group*d_state) = 3 * 10240`. + int ssm_state_size = 128; + int ssm_v_heads = 48; + int ssm_d_conv = 4; + int ssm_conv_channels = 10240; + + /// 36 of the 48 layers are GDN; the rest are QSA. Declared as a derivation rather than a third constant, + /// because a `n_gdn_layers = 36` alongside `n_layers` and `n_qsa_layers` is a third thing to keep in step. + int n_gdn_layers() const { return n_layers - n_qsa_layers; } +}; + +// ---- byte costs ------------------------------------------------------------ +struct Costs { + uint64_t expert_blob = 1382400; // one expert's gate_up+down, codes and scales (architecture §3.2) + uint64_t dense_bytes = 0; // from the manifest: every non-expert tensor, canonical form + uint64_t embd_bytes = 0; // token embedding + uint64_t mtp_bytes = 0; // multi-token-prediction head, when present + uint64_t workspace_bytes = 0; + uint64_t state_bytes = 0; // GDN recurrence + conv history + replay log, per sequence +}; + +// GDN recurrence + conv history, bytes per sequence. DERIVED, for the same reason everything else here is. +// +// This term was MISSING from the plan until round 198: `Costs::state_bytes` exists and `--state` exists, but +// nothing passed it, so the planner was fed zero and quietly planned without it. 36 GDN layers of fp32 state +// is **117.7 MB, or 85 expert cache slots** - not a rounding error. +// +// recurrence (S, h_v, S) fp32 = 128 * 48 * 128 * 4 = 3,145,728 B per layer +// conv history (d_conv-1, C) fp32 = 3 * 10240 * 4 = 122,880 B per layer +// +// It is a FIXED cost and not a cache: it is state, it is resident for the life of a sequence, and it cannot be +// evicted to make room for an expert. A planner that omits it returns a plan it cannot honour, which is the +// failure `DoesNotClose` exists to prevent. +inline uint64_t state_bytes(const Geometry& g) { + const uint64_t per_layer = (uint64_t) g.ssm_state_size * (uint64_t) g.ssm_v_heads * + (uint64_t) g.ssm_state_size * sizeof(float) + + (uint64_t) (g.ssm_d_conv - 1) * (uint64_t) g.ssm_conv_channels * sizeof(float); + return (uint64_t) g.n_gdn_layers() * per_layer; +} + +// INT8 KV + indexer keys, bytes per token. Both terms come from the geometry above and nothing else. +inline uint64_t kv_bytes_per_token(const Geometry& g) { + const uint64_t kv_elems = 2ull * g.n_kv_heads * g.head_dim; // K and V + const uint64_t kv_scales = (kv_elems / (uint64_t)g.kv_group) * 2; // fp16 scale per group + const uint64_t per_layer = kv_elems * 1 + kv_scales; // INT8: one byte per element + // indexer: ONE cached key per block, `idx_dim` wide, SHARED across the query heads. See the note on + // `indexer_key_heads` above - this term has been wrong in both directions and the derivation is why. + const uint64_t idx_layer = + (uint64_t) g.indexer_key_heads * (uint64_t) g.indexer_key_dim / (uint64_t) g.qsa_block; + return (uint64_t)g.n_qsa_layers * (per_layer + idx_layer); +} + +// ---- the plan -------------------------------------------------------------- +struct Plan { + uint64_t max_context = 0; + uint64_t kv_bytes = 0; // KV + indexer keys at max_context + uint64_t state_bytes = 0; // GDN recurrence + conv history - FIXED, not evictable + uint64_t cache_bytes = 0; // what is left for expert cache slots + uint64_t cache_slots = 0; + uint64_t vram_budget = 0; + uint64_t vram_used = 0; + uint64_t dram_experts = 0; // the whole expert arena, streamed + std::vector notes; +}; + +// Thrown when the requested context and the fixed costs cannot both fit. A planner that returns a plan it +// cannot honour is worse than one that fails, because the failure then happens at token 4000 instead of at +// startup. +struct DoesNotClose : std::runtime_error { + explicit DoesNotClose(const std::string& w) : std::runtime_error(w) {} +}; + +// The VRAM budget that the KV cache and the expert cache share. This is the one number taken from +// `00-README.md` §2 rather than derived: the table's three rows all leave `KV + cache` constant, and that +// constant is what the planner is dividing. Expressed in bytes, decimal, matching how the table rounds. +inline uint64_t vram_pool_bytes() { + return 5943000000ull; +} + +inline Plan make_plan(uint64_t max_context, const Geometry& g, const Costs& c, + uint64_t pool = vram_pool_bytes()) { + Plan p; + p.max_context = max_context; + p.vram_budget = pool; + p.kv_bytes = kv_bytes_per_token(g) * max_context; + p.state_bytes = c.state_bytes; + p.dram_experts = c.dense_bytes + c.embd_bytes; // costs that never enter the pool + + const uint64_t fixed = c.dense_bytes + c.embd_bytes + c.mtp_bytes + c.workspace_bytes + c.state_bytes; + if (p.kv_bytes + fixed > pool) { + throw DoesNotClose("max-context " + std::to_string(max_context) + " needs " + + std::to_string(p.kv_bytes + fixed) + " B of the " + std::to_string(pool) + + " B VRAM pool; reduce --max-context"); + } + p.cache_bytes = pool - p.kv_bytes - fixed; + p.cache_slots = p.cache_bytes / c.expert_blob; + p.vram_used = pool - p.cache_bytes % c.expert_blob; // slot remainder is unusable, not free + if (p.cache_slots == 0) { + throw DoesNotClose("no room for even one expert cache slot at max-context " + + std::to_string(max_context)); + } + return p; +} + +inline std::string to_string(const Plan& p) { + auto gb = [](uint64_t b) { return std::to_string(b / 1e9); }; + std::string s; + s += "memory plan --max-context " + std::to_string(p.max_context) + "\n"; + s += " KV + indexer keys " + gb(p.kv_bytes) + " GB\n"; + s += " recurrent state " + gb(p.state_bytes) + " GB (GDN recurrence + conv history, NOT evictable)\n"; + s += " expert cache " + gb(p.cache_bytes) + " GB (" + std::to_string(p.cache_slots) + + " slots of 1,382,400 B)\n"; + s += " VRAM pool " + gb(p.vram_budget) + " GB\n"; + s += " expert arena (DRAM) " + gb(p.dram_experts) + " GB\n"; + for (const std::string& n : p.notes) s += " note: " + n + "\n"; + return s; +} + +} // namespace strata::plan diff --git a/include/strata/platform/direct_file.hpp b/include/strata/platform/direct_file.hpp new file mode 100644 index 00000000..1d196d86 --- /dev/null +++ b/include/strata/platform/direct_file.hpp @@ -0,0 +1,59 @@ +// include/strata/platform/direct_file.hpp - plan v0.3 P1/P2: unbuffered asynchronous file reads. +// +// The n-gram table (26.8 GiB) stays on the SSD and must never occupy RAM, including the OS file cache. A memory +// map cannot promise that; an unbuffered read can. On Windows this is FILE_FLAG_NO_BUFFERING | OVERLAPPED with +// an I/O completion port; on Linux, O_DIRECT (synchronous pread for now; io_uring is phase L). +// +// Contract of every read: offset, length and buffer address are multiples of `alignment()` (4096 here). +// Reads past end of file return the bytes that exist; `Completion::bytes` says how many. +#pragma once + +#include +#include + +namespace strata::platform { + +struct Completion { + uint64_t tag = 0; ///< the caller's tag from `submit` + uint32_t bytes = 0; ///< bytes transferred (short only at end of file) + bool ok = false; +}; + +class DirectFile { +public: + DirectFile(); + ~DirectFile(); + DirectFile(const DirectFile&) = delete; + DirectFile& operator=(const DirectFile&) = delete; + + bool open(const std::string& path, std::string& err); + void close(); + bool is_open() const; + uint64_t size() const; + static constexpr uint32_t alignment() { return 4096; } + + /// Queue one read. Returns false (and sets `err`) if the request could not be queued; a queued request + /// always produces exactly one completion. + bool submit(uint64_t offset, void* buffer, uint32_t length, uint64_t tag, std::string& err); + + /// Wait for up to `max` completions; returns how many were written to `out`. `timeout_ms` < 0 waits + /// forever; 0 polls. A `wake()` shows up as one completion with tag `WAKE_TAG`. + int wait(Completion* out, int max, int timeout_ms); + + /// Make a thread blocked in `wait` return (thread-safe). Used to hand it newly queued work. + static constexpr uint64_t WAKE_TAG = ~0ull; + void wake(); + + /// Allocation helpers for aligned read buffers (page-aligned, never touched by the cache manager). + static void* alloc_aligned(size_t bytes); + static void free_aligned(void* p); + +private: + struct Impl; + Impl* impl_ = nullptr; +}; + +/// Monotonic time in microseconds, for latency accounting. +double now_us(); + +} // namespace strata::platform diff --git a/include/strata/platform/memory.hpp b/include/strata/platform/memory.hpp new file mode 100644 index 00000000..a350a78c --- /dev/null +++ b/include/strata/platform/memory.hpp @@ -0,0 +1,26 @@ +// include/strata/platform/memory.hpp - plan v0.3 P0.1/P1: keep a large host region resident. +// +// `cudaHostRegister` refuses the 31.6 GiB expert arena on Windows, and an unlocked arena is trimmed under memory +// pressure (the CPU pool then swung 2x between runs). With the n-gram table out of RAM there is headroom to lock +// it instead: raise the process's minimum working set by the region's size, then VirtualLock it (Windows needs +// only SeIncreaseWorkingSetPrivilege, which ordinary accounts hold). Linux: mlock. +#pragma once + +#include +#include + +namespace strata::platform { + +struct LockResult { + bool ok = false; + uint64_t locked_bytes = 0; ///< may be less than requested; the rest stays pageable + std::string note; ///< what was done or why it failed, for the startup print +}; + +/// Lock [p, p + bytes) into physical memory. Partial success is reported, not hidden. +LockResult lock_resident(void* p, uint64_t bytes); + +/// Undo lock_resident for the same region (best effort). +void unlock_resident(void* p, uint64_t bytes); + +} // namespace strata::platform diff --git a/include/strata/prefill/gemm.hpp b/include/strata/prefill/gemm.hpp new file mode 100644 index 00000000..b0aca482 --- /dev/null +++ b/include/strata/prefill/gemm.hpp @@ -0,0 +1,54 @@ +// include/strata/prefill/gemm.hpp - plan v0.3 P5: the batched projections of prompt processing. +// +// Every projection of a chunk of T tokens is Y[T, N] = X[T, K] . W[N, K]^T with W row-major (the GGUF / pack layout) +// and FP32 outputs. Weights are BF16 on the device - either already (the pack's BF16 tensors) or dequantized from +// their native GGUF blocks into a reusable scratch (`dequant_bf16`) right before the product - and activations are +// rounded to BF16, which is also what llama.cpp's batched CUDA path does. Tensor-core GEMM through cuBLAS. +#pragma once + +#include +#include + +namespace strata::prefill { + +class Gemm { +public: + Gemm() = default; + ~Gemm(); + Gemm(const Gemm&) = delete; + Gemm& operator=(const Gemm&) = delete; + + /// `scratch_elems`: BF16 elements of the dequantization scratch (the largest weight dequantized at once). + bool init(void* stream, int64_t scratch_elems, std::string& err); + /// The same with caller-owned device buffers (the prompt path borrowing expert-cache slots). + bool init_external(void* stream, uint16_t* scratch, int64_t scratch_elems, void* workspace, size_t ws_bytes, + std::string& err); + + /// Y[T, N] (fp32, row stride ldy) = X[T, K] (bf16, row-major) . W[N, K]^T (bf16, row-major). `beta` = 1 adds. + void bf16(const uint16_t* X, const uint16_t* W, float* Y, int64_t T, int64_t N, int64_t K, int64_t ldy = 0, + float beta = 0.0f); + + /// Y = X . W^T with both in FP16 (bits). + void f16(const uint16_t* X, const uint16_t* W, float* Y, int64_t T, int64_t N, int64_t K, int64_t ldy = 0, + float beta = 0.0f); + + /// W given as native GGUF blocks of `ggml_type`, dequantized to FP16 in the scratch, X in FP16. + void native(const uint16_t* X, int ggml_type, const void* W_blocks, float* Y, int64_t T, int64_t N, int64_t K, + int64_t ldy = 0, float beta = 0.0f); + + uint16_t* scratch() const { return scratch_; } + int64_t scratch_elems() const { return scratch_elems_; } + void* stream() const { return stream_; } + +private: + void* handle_ = nullptr; + void* stream_ = nullptr; + uint16_t* scratch_ = nullptr; + int64_t scratch_elems_ = 0; + void* workspace_ = nullptr; + bool external_ = false; +}; + + + +} // namespace strata::prefill diff --git a/include/strata/prefill/kernels.hpp b/include/strata/prefill/kernels.hpp new file mode 100644 index 00000000..b70a2a56 --- /dev/null +++ b/include/strata/prefill/kernels.hpp @@ -0,0 +1,74 @@ +// include/strata/prefill/kernels.hpp - plan v0.3 P5: the element-wise and stateful kernels of a prompt chunk. +// +// T tokens at a time, all row-major with the token as the slow index. The arithmetic mirrors the per-token native +// kernels (the decode path), batched; where a step is a recurrence in time (the GDN conv and state, the PLE conv) +// the kernel walks the chunk's tokens in order inside one launch. +#pragma once + +#include + +namespace strata::prefill { + +// ---- hyper-connection (n_embd 2560, hc 4, hc_lr 320) +/// xn[t, c*2560 + d] = R[t,c,d] * rsqrt(mean_d R[t,c,:]^2 + eps) * w_norm[c*2560 + d]; also its BF16 image. +void gr_norm(const float* R, const float* w_norm, float eps, float* xn, uint16_t* xn16, int64_t T, void* stream); +/// lo16[t, k] = bf16(silu(lo[t, k] / hc)) +void gr_silu(const float* lo, uint16_t* lo16, int64_t T, void* stream); +/// mixed[t, d] = mean_c xn[t, c, d] * sigmoid(gated[t, c, d]); FP32, BF16 and FP16 (either image may be null). +void gr_mix(const float* xn, const float* gated, float* mixed, uint16_t* mixed16, int64_t T, void* stream, + uint16_t* mixed_h = nullptr); +/// R[t, c, d] += bo[t, d] * 2 sigmoid(inj[t, c] / hc) (inj has row stride inj_ld) +void gr_write(float* R, const float* bo, const float* inj, int64_t inj_ld, int64_t T, void* stream); +/// R[t, c, :] = e[t, :] for all four streams (the embedding broadcast). +void gr_broadcast(const float* e, float* R, int64_t T, void* stream); + +// ---- GDN (state 128, 16 k heads, 48 v heads, 10240 conv channels, 4 taps) +/// gate[t,h] = softplus(ab[t,h] + dt[h]) * ssm_a[h]; beta[t,h] = sigmoid(ab[t, 48 + h]) (ab: [T, 96]) +void gdn_gates(const float* ab, const float* dt, const float* ssm_a, float* gate, float* beta, int64_t T, void* stream); +/// The 4-tap causal conv + SiLU over the chunk (history [C][3] in, updated to the chunk's last three inputs), then +/// the L2 norm of the q and k heads of every token. h: [T, C]. +void gdn_conv(float* history, const float* qkv, const float* conv_w, float* h, int64_t T, float eps, void* stream); +/// The recurrence over the chunk, block per value head, state in registers; y[t] = rmsnorm(o) * gamma * sigmoid(z) +/// (FP32 and FP16 bits: the out projection is quantized). +void gdn_recurrence(float* state, const float* h, const float* gate, const float* beta, const float* z, + const float* gamma, float eps, float* y, uint16_t* y16, int64_t T, void* stream); + +// ---- MoE +/// softmax over 512, top-10 (ties to the lower id), weights renormalised over the ten (the native router). +void route(const float* logits, int32_t* ids, float* weights, int64_t T, void* stream); +/// Expert blob (Strata pack layout, Q2_0) -> BF16 matrices: gate/up interleaved [1280, 2560], down [2560, 640]. +void blob_dequant(const uint8_t* blob, uint16_t* gu16, uint16_t* down16, void* stream); +/// h16[n, r] = fp16(silu(gu[n, 2r]) * gu[n, 2r + 1]) (the interleaved expert gate/up) +void swiglu_interleaved(const float* gu, uint16_t* h16, int64_t n, void* stream); +/// h16[n, r] = fp16(silu(g[n, r]) * u[n, r]) (the shared expert, gate and up separate, width 640) +void swiglu_pair(const float* g, const float* u, uint16_t* h16, int64_t n, void* stream); +/// Gather rows: dst16[i, :] = x16[src[i], :] (n rows of `width` BF16). +void gather_rows16(const uint16_t* x16, const int32_t* src, uint16_t* dst16, int64_t n, int64_t width, void* stream); +/// bo[t, :] = shared[t, :] * sigmoid(sg[t]) + sum_k w[t, k] * D[slot[t, k], :] +void moe_combine(const float* D, const int32_t* slot, const float* w, const float* shared, const float* sg, float* bo, + int64_t T, void* stream); + +// ---- QSA helpers +/// In place: x[r, :] = x[r, :] * rsqrt(mean x^2 + eps) * w over rows of `cols` (row stride `ld`). +void rms_rows(float* x, const float* w, int64_t rows, int64_t cols, int64_t ld, float eps, void* stream); +/// NEOX rotary on the first 64 dims of each head: x [T, heads, dim] at positions pos0 + t. +void rope(float* x, int64_t T, int64_t heads, int64_t dim, int64_t ld, int64_t pos0, float freq_base, void* stream); +/// q_full [T, 24, 512] (q | gate per head) -> q [T, 24, 256] +void split_q(const float* q_full, float* q, int64_t T, void* stream); +/// attn[t, h, d] *= sigmoid(q_full[t, h, 256 + d]) -> out16 (fp16 bits: the o-projection is quantized) +void gate_attn(const float* attn, const float* q_full, uint16_t* out16, int64_t T, void* stream); + +/// K and V of T consecutive cells (positions pos0..pos0+T-1; K normed and rotated) into the paged pools: FP16 +/// (`k_pool`/`v_pool`) or INT8 codes + FP16 scale per 64 (`k_q`...), the decode append's arithmetic. +/// K, V: [T, 2, 256]. +void kv_append(const float* K, const float* V, int64_t T, int64_t pos0, const int32_t* page_table, int64_t page_size, + uint16_t* k_pool, uint16_t* v_pool, int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale, + void* stream); + +/// fp32 -> fp16 bits and fp32 -> bf16, n elements (the two activation images of the prompt GEMMs). +void to_f16(const float* x, uint16_t* y, int64_t n, void* stream); +void to_bf16(const float* x, uint16_t* y, int64_t n, void* stream); +/// Expert blob -> FP16 (Q2_0 values are exact in FP16). +void blob_dequant_f16(const uint8_t* blob, uint16_t* gu16, uint16_t* down16, void* stream); + +} // namespace strata::prefill diff --git a/include/strata/prefill/prefill.hpp b/include/strata/prefill/prefill.hpp new file mode 100644 index 00000000..11c0baef --- /dev/null +++ b/include/strata/prefill/prefill.hpp @@ -0,0 +1,75 @@ +// include/strata/prefill/prefill.hpp - plan v0.3 P5: batched prompt processing. +// +// The prompt's positions [pos0, pos0 + n) are processed in chunks of `chunk` tokens through all 48 layers, leaving +// the session state (GDN recurrence and conv state, QSA KV pools and indexer, PLE history) where the token path +// would have left it; the decode loop then continues with the next token. Per layer: the projections are +// tensor-core GEMMs (quantized weights dequantized to FP16 on the fly, BF16 weights as they are), the recurrences +// walk the chunk inside one kernel, and the routed experts are grouped by expert: resident ones are read from the +// VRAM tier, the others streamed from the host arena through a pinned ring on a copy stream. +// +// Requires the native weights (`--native`): every quantized projection must carry its GGUF blocks. +#pragma once + +#include "strata/core/expert_cache.hpp" +#include "strata/core/expert_source.hpp" +#include "strata/core/layer.hpp" +#include "strata/core/session.hpp" +#include "strata/core/weights.hpp" + +#include +#include +#include +#include + +namespace strata::prefill { + +struct PrefillStats { + int64_t tokens = 0; + int64_t chunks = 0; + double ms_total = 0; + double ms_experts_host = 0; ///< host time staging non-resident experts + int64_t experts_streamed = 0; ///< expert blobs copied host -> device + int64_t experts_dma = 0; ///< ...of which straight from the pinned arena (no CPU copy) + int64_t experts_resident = 0; ///< expert-layer groups served from the VRAM tier + double ms_ple = 0; +}; + +class Prefill { +public: + Prefill(); + ~Prefill(); + Prefill(const Prefill&) = delete; + Prefill& operator=(const Prefill&) = delete; + + /// `host_res`: the static residency table (n_layers x n_expert, slot or -1) or null; `cache` its slots. + /// `borrow`/`borrow_bytes`: device memory to carve every buffer from (the top slots of the expert cache, + /// lent for the prompt and refilled after it); null = allocate normally. + bool init(const core::WeightTable& wt, const core::ModelGeometry& g, core::SessionState& ss, + core::ExpertSource* src, const core::ExpertCache* cache, const int32_t* host_res, int64_t chunk, + void* stream, std::string& err, void* borrow = nullptr, uint64_t borrow_bytes = 0); + + /// Device bytes `init` needs for a chunk of `chunk` tokens (what a borrowed region must hold). + static uint64_t bytes_needed(const core::ModelGeometry& g, const core::SessionState& ss, int64_t chunk); + + /// Positions [pos0, pos0 + n) holding `tokens`; `ss.ple_prev` must be the two tokens before pos0 (oldest + /// first, -1 for none) and is advanced to the last two of these. + bool run(const int64_t* tokens, int64_t n, int64_t pos0, std::string& err); + + const PrefillStats& stats() const { return stats_; } + + /// Plan v0.3 P6: called after every chunk with the chunk's final multi-stream residual rows (device, + /// T x hc*n_embd, valid until the next chunk) and the chunk's first position; the MTP draft layer builds its + /// K/V from them. The prefill stream is synchronized before the call. + std::function on_chunk; + + /// The vision path: HOST rows (n_embd floats) indexed by absolute position, read in place of the token + /// embedding where non-null (an image's <|image_pad|> cells). Null (default): every position embeds its token. + const float* const* embd_rows = nullptr; + +private: + struct Impl; + std::unique_ptr impl_; + PrefillStats stats_; +}; + +} // namespace strata::prefill diff --git a/include/strata/program/logits_selection.hpp b/include/strata/program/logits_selection.hpp new file mode 100644 index 00000000..04462a82 --- /dev/null +++ b/include/strata/program/logits_selection.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +namespace strata::program::logits_selection { + +// Storage selection only: callers must still condition on every input token. +// Positive decimal int64, with no sign, whitespace, suffix or overflow. +inline bool parse_stride(std::string_view text, int64_t& result) { + if (text.empty() || text.front() < '0' || text.front() > '9') return false; + int64_t value = 0; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value); + if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || value <= 0) return false; + result = value; + return true; +} + +// Zero-based multiples of stride, followed by the final input position once. +inline constexpr bool selected(int64_t position, int64_t total, int64_t stride) { + return total > 0 && stride > 0 && position >= 0 && position < total && + (position % stride == 0 || position == total - 1); +} + +inline constexpr int64_t row_count(int64_t total, int64_t stride) { + return total > 0 && stride > 0 ? (total - 1) / stride + 1 + ((total - 1) % stride != 0) : 0; +} + +} // namespace strata::program::logits_selection diff --git a/include/strata/spec/controller.hpp b/include/strata/spec/controller.hpp new file mode 100644 index 00000000..4864ce93 --- /dev/null +++ b/include/strata/spec/controller.hpp @@ -0,0 +1,73 @@ +// include/strata/spec/controller.hpp - plan v0.3 P6: which drafter, and how many draft tokens, this step. +// +// Each step the controller maximizes E[tokens committed] / T(step) over the choices +// none (k = 0) | suffix lookup with k <= its proposal | MTP with k <= K_MAX +// using running estimates of per-position acceptance and a cost model of a (k+1)-token verify pass. It keeps +// k = 0 unless the best choice beats plain decoding by `min_gain` (plan: 5%). +// +// Acceptance: for MTP, position i's conditional acceptance p_i (EMA). For lookup, one conditional acceptance per +// match-length bucket (a long match is far more reliable than a trigram), also an EMA. `observe` updates them +// from how many drafted tokens the verify pass accepted. +// +// Cost: T(n tokens) = dense(n) + experts(n) + sync + draft. The defaults are the 23 Sep measurements: +// dense ratio per token count from native MMVQ (bench/results/2026-09-23-mmvq-multi), distinct experts per window +// from the routing trace (2026-09-23-spec-economics), extra-token CPU cost 0.2-0.35 (2026-09-23-cpu-expert-multi). +// Replace them with in-engine measurements through `CostModel`. +#pragma once + +#include +#include + +namespace strata::spec { + +inline constexpr int K_MAX = 8; + +struct CostModel { + double dense_ms = 11.0; // one-token dense pass (plan P3 target) + std::array dense_ratio{1.0, 1.05, 1.3, 1.45, 1.85, 2.2, 2.6, 3.0, 3.3}; // by n = k+1 (index n-1) + double cpu_all_miss_ms = 15.8; // 480 experts on the CPU + double hit_rate = 0.55; // share of distinct experts served from VRAM + std::array distinct_ratio{1.0, 1.70, 2.31, 2.88, 3.40, 3.89, 4.35, 4.80, 5.2}; // U(n)/U(1) + double extra_use_cost = 0.25; // each extra token on an expert, fraction of a read + double sync_ms = 2.4; + double mtp_draft_ms = 1.2; // per MTP draft token + double lookup_draft_ms = 0.01; + + /// Step time for verifying n = k + 1 tokens, plus drafting k tokens with the given source. + double step_ms(int k, bool mtp) const; +}; + +enum class Source { None, Lookup, Mtp }; + +struct Choice { + Source source = Source::None; + int k = 0; + double expected_tokens = 1.0; + double tokens_per_ms = 0.0; +}; + +class Controller { +public: + explicit Controller(CostModel cost = {}, double min_gain = 0.05, double ema = 0.05); + + /// `lookup_available` tokens the suffix drafter can propose now, from a match of `lookup_match` tokens. + Choice choose(int lookup_available, int lookup_match, bool mtp_ready) const; + + /// After verification: `accepted` of the `k` drafted tokens matched (a prefix). + void observe(const Choice& c, int accepted, int lookup_match); + + double mtp_accept(int position) const { return mtp_p_[position]; } // position 0 = first draft token + double lookup_accept(int match) const { return lookup_q_[bucket(match)]; } + const CostModel& cost() const { return cost_; } + +private: + static int bucket(int match) { return match >= 16 ? 3 : match >= 8 ? 2 : match >= 5 ? 1 : 0; } + static double expected(const double* p, int k, bool conditional_same); + + CostModel cost_; + double min_gain_, ema_; + std::array mtp_p_{}; + std::array lookup_q_{}; +}; + +} // namespace strata::spec diff --git a/include/strata/spec/suffix_drafter.hpp b/include/strata/spec/suffix_drafter.hpp new file mode 100644 index 00000000..731bbef1 --- /dev/null +++ b/include/strata/spec/suffix_drafter.hpp @@ -0,0 +1,53 @@ +// include/strata/spec/suffix_drafter.hpp - plan v0.3 P6: the suffix-lookup drafter (no weights, no GPU). +// +// Proposes the tokens that followed the longest earlier occurrence of the sequence's current suffix: when the +// output quotes its input (edits, refactors, repeated code) the continuation is usually exact, and a verify pass +// accepts many tokens at once. It needs no model and costs microseconds. +// +// Index: every trigram of the history (prompt + accepted output) maps to its WAYS most recent end positions in a +// fixed-size open-addressing table, so memory is bounded (~20 bytes per history token) and appends are O(1). +// A proposal checks those candidates, extends each match backwards up to `max_match`, and takes the longest +// (most recent on ties). Matches shorter than `min_match` propose nothing. +#pragma once + +#include +#include +#include + +namespace strata::spec { + +class SuffixDrafter { +public: + static constexpr int WAYS = 4; + + explicit SuffixDrafter(int min_match = 3, int max_match = 32, size_t capacity_tokens = 1u << 19); + + void reset(); + /// Add tokens to the history (the prompt, then every accepted token). + void append(const int32_t* tokens, size_t n); + void append(int32_t token) { append(&token, 1); } + + /// Write up to `max_k` proposed next tokens to `out`; returns how many (0 = no match of at least min_match). + int propose(int max_k, int32_t* out); + + /// Length of the match behind the last proposal (0 if none). + int last_match() const { return last_match_; } + size_t size() const { return hist_.size(); } + +private: + struct Slot { + uint64_t key = 0; // trigram hash + 1 (0 = empty) + uint32_t pos[WAYS] = {}; // end positions, most recent first + uint8_t n = 0; + }; + Slot* find_slot(uint64_t key, bool insert); + uint64_t key_at(size_t end) const; + + int min_match_, max_match_; + std::vector hist_; + std::vector table_; + size_t mask_ = 0; + int last_match_ = 0; +}; + +} // namespace strata::spec diff --git a/ref/load.py b/ref/load.py new file mode 100644 index 00000000..0e4575ac --- /dev/null +++ b/ref/load.py @@ -0,0 +1,212 @@ +"""ref/load.py - P1.S4: GGUF -> FP32 tensors on demand, with LAZY per-expert dequantization. + +The plan is explicit: "experts are dequantized lazily per (layer, expert) - do not materialize 120B +parameters". That is the whole design constraint here, and it is what `expert()` below exists for: the +routed-expert tensors are fused `[in, out, experts]`, and touching one expert must not touch the other +511. + +Dequantization itself is NOT reimplemented. `gguf-py` handles the twelve standard encodings and is +already the reference this project validates its C++ against; `Q2_0` (type 42) is the one gguf-py +cannot represent at all, so it is served by this project's own decoder. That split is deliberate: it +means a bug in `tools/gguf_writer.dequantize_q2_0` cannot hide behind gguf-py and vice versa. + + from ref.load import Gguf + g = Gguf("path.gguf") + w = g.tensor("blk.0.attn_gate.weight") # (out, in) fp32, whole tensor + e = g.expert("blk.0.ffn_gate_exps.weight", 7) # (out, in) fp32, ONE expert only +""" +from __future__ import annotations + +import pathlib +import re +import sys + +import numpy as np + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "tools")) +from gguf_reader import GGUFFile # noqa: E402 +from gguf_writer import dequantize_q2_0 # noqa: E402 + +# gguf-py names differ from our reader's names in a few places; map only what differs. +_GGUF_PY_NAME = {"Q4_0": "Q4_0", "Q8_0": "Q8_0", "Q4_K": "Q4_K", "Q5_K": "Q5_K", "Q6_K": "Q6_K", + "Q3_K": "Q3_K", "IQ4_NL": "IQ4_NL", "IQ4_XS": "IQ4_XS", "Q5_0": "Q5_0"} + +# (elements per block, bytes per block) - mirrors block_geometry() in src/artifact/gguf_reader.cpp +GEOM = {"F32": (1, 4), "F16": (1, 2), "BF16": (1, 2), + "Q4_0": (32, 18), "Q5_0": (32, 22), "Q8_0": (32, 34), "IQ4_NL": (32, 18), + "Q3_K": (256, 110), "Q4_K": (256, 144), "Q5_K": (256, 176), "Q6_K": (256, 210), + "IQ4_XS": (256, 136), "Q2_0": (64, 18)} + + +def _dequant_flat(type_name: str, raw: np.ndarray) -> np.ndarray: + """One dequantization dispatch, shared by tensor()/expert()/rows(). + + Q2_0 is this project's own decoder because gguf-py's enum cannot represent type 42 at all; + everything else goes through gguf-py, which is already the reference this project validates its + C++ against. Keeping one dispatch means a type added in one path cannot be forgotten in another. + """ + if type_name == "Q2_0": + return dequantize_q2_0(raw.tobytes()) + if type_name == "BF16": + return _bf16_to_f32(raw) + if type_name == "F32": + return raw.view(np.float32) + if type_name == "F16": + return raw.view(np.float16).astype(np.float32) + from gguf import quants, GGMLQuantizationType as Q + return quants.dequantize(raw, Q[_GGUF_PY_NAME[type_name]]).astype(np.float32) + + +def _bf16_to_f32(raw: np.ndarray) -> np.ndarray: + return (raw.view(np.uint16).astype(np.uint32) << 16).view(np.float32) + + +def _discover_shards(path) -> list[pathlib.Path]: + """Resolve a GGUF argument to its shard list. + + The real artifact is TWO files and the split is not incidental: shard 2 holds exactly one tensor, + `per_layer_token_embd.weight` (26.82 GiB). A reader that opens one file can therefore load the + whole model EXCEPT the PLE table and will not fail until a forward pass reaches layer 1 - so + shard discovery is not a convenience, it is a correctness requirement for the real model. + + Accepts a directory (all `*.gguf` in it), any single shard (siblings found by the + `-NNNNN-of-MMMMM` suffix), or an explicit list. + """ + if isinstance(path, (list, tuple)): + return [pathlib.Path(p) for p in path] + p = pathlib.Path(path) + if p.is_dir(): + # A directory is ambiguous in practice: the project root holds BOTH the model shards (in a + # subdirectory) and an unrelated `mmproj-*.gguf`. Globbing `*.gguf` there silently loaded + # the multimodal projector instead of the model. So prefer an explicit shard set, accept a + # single unambiguous file, and REFUSE to guess otherwise. + shards = sorted(p.glob("*-[0-9][0-9][0-9][0-9][0-9]-of-[0-9][0-9][0-9][0-9][0-9].gguf")) + if shards: + totals = {re.match(r"^.*-(\d{5})-of-(\d{5})\.gguf$", s.name).group(2) for s in shards} + if len(totals) != 1: + raise ValueError(f"{p}: shard files from more than one split: {sorted(totals)}") + return shards + found = sorted(p.glob("*.gguf")) + if len(found) == 1: + return found + if not found: + raise FileNotFoundError(f"no .gguf files in {p}") + raise ValueError( + f"{p}: {len(found)} .gguf files and none is a shard set, so which is the model is " + f"ambiguous: {[f.name for f in found]}. Pass one file explicitly.") + m = re.match(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$", p.name) + if m: + stem, _, total = m.groups() + sibs = sorted(p.parent.glob(f"{stem}-*-of-{total}.gguf")) + if len(sibs) != int(total): + raise FileNotFoundError( + f"{p.name}: expected {total} shards, found {len(sibs)} in {p.parent}") + return sibs + return [p] + + +class Gguf: + """Read-only, demand-paged view of a (possibly multi-shard) GGUF. + + Nothing is dequantized until asked for. `g` is the FIRST shard's `GGUFFile`, so metadata and the + tensor directory are reachable as before; reads route to whichever shard owns the tensor. + """ + + def __init__(self, path): + self.paths = _discover_shards(path) + self.path = self.paths[0] + self.shards: list[tuple[GGUFFile, object]] = [] + self._by_name: dict[str, tuple[int, object]] = {} + for i, sp in enumerate(self.paths): + gf = GGUFFile(sp) + self.shards.append((gf, sp.open("rb"))) + for t in gf.tensors: + if t.name in self._by_name: + raise ValueError(f"{sp.name}: duplicate tensor {t.name} across shards") + self._by_name[t.name] = (i, t) + self.g = self.shards[0][0] # metadata / directory / alignment live in shard 1 + + # ---- raw bytes + def _raw(self, name: str, nbytes: int, byte_offset: int = 0) -> bytes: + i, t = self._by_name[name] + gf, fh = self.shards[i] + fh.seek(gf.data_start + t.offset + byte_offset) + return fh.read(nbytes) + + def info(self, name: str): + return self._by_name[name][1] + + def row_bytes(self, t) -> int: + el, by = GEOM[t.type_name] + return t.shape[0] // el * by + + # ---- full tensors + def tensor(self, name: str) -> np.ndarray: + """Dequantize a whole tensor to FP32, shape as GGUF order reversed to (out, in[, experts]).""" + t = self.info(name) + el, by = GEOM[t.type_name] + if t.elements % el: + raise ValueError(f"{name}: {t.elements} not a multiple of block {el}") + raw = np.frombuffer(self._raw(name, t.elements // el * by), dtype=np.uint8) + flat = _dequant_flat(t.type_name, raw) + # GGUF dim 0 varies fastest; numpy's last axis does, so reverse and transpose to (out, in). + return flat.reshape(tuple(reversed(t.shape))) + + # ---- the lazy row path + def rows(self, name: str, idx) -> np.ndarray: + """Gather specific ROWS of a 2-D tensor without decoding the rest of it. + + This exists for `per_layer_token_embd.weight`, which is `[160, 320001536]` IQ4_NL = **26.82 + GiB** - 43% of the artifact, the entire second shard - and is read 16 rows per token by one + layer (see round 98's finding). Calling `tensor()` on it would dequantize 320 million rows to + FP32, about 205 GB, to use 2.5 KB of them. + + A row is contiguous in GGUF (ne0 varies fastest), so one row is `row_bytes` at + `row * row_bytes`. The type's block size must divide the row width; for this table it does + (160 = 5 x 32 for IQ4_NL). + """ + t = self.info(name) + if len(t.shape) != 2: + raise ValueError(f"{name}: rows() needs a 2-D tensor, got shape {t.shape}") + el, by = GEOM[t.type_name] + width = t.shape[0] + if width % el: + raise ValueError(f"{name}: row width {width} is not a multiple of block {el}") + rb = self.row_bytes(t) + idx = np.asarray(idx, dtype=np.int64).ravel() + if idx.size and (idx.min() < 0 or idx.max() >= t.shape[1]): + raise ValueError(f"{name}: row index out of range (0..{t.shape[1] - 1})") + out = np.empty((idx.size, width), dtype=np.float32) + for k, r in enumerate(idx): + raw = np.frombuffer(self._raw(name, rb, int(r) * rb), dtype=np.uint8) + out[k] = _dequant_flat(t.type_name, raw) + return out + + # ---- the lazy path + def expert(self, name: str, expert_id: int) -> np.ndarray: + """One expert out of a fused `[in, out, experts]` tensor, WITHOUT touching the others. + + In GGUF order the expert index is the LAST axis, so expert e's matrix occupies a contiguous + run of `in*out` elements at offset e*in*out. Only that run is read and decoded - which is the + plan's "do not materialize 120B parameters" requirement, and the reason the engine can run at + all on a machine where the expert set is 34 GB. + """ + t = self.info(name) + if len(t.shape) != 3: + raise ValueError(f"{name}: not a fused expert tensor (shape {t.shape})") + in_f, out_f, n_exp = t.shape + if not 0 <= expert_id < n_exp: + raise ValueError(f"{name}: expert {expert_id} out of range (0..{n_exp - 1})") + el, by = GEOM[t.type_name] + per_expert = in_f * out_f + if per_expert % el: + raise ValueError(f"{name}: per-expert element count {per_expert} not a multiple of {el}") + # byte offset of expert e: whole experts before it, in blocks + start = expert_id * (per_expert // el) * by + raw = np.frombuffer(self._raw(name, per_expert // el * by, start), dtype=np.uint8) + flat = _dequant_flat(t.type_name, raw) + return flat.reshape((out_f, in_f)) + + def close(self) -> None: + for _, fh in self.shards: + fh.close() diff --git a/serve/__init__.py b/serve/__init__.py new file mode 100644 index 00000000..c3b0bdd9 --- /dev/null +++ b/serve/__init__.py @@ -0,0 +1 @@ +"""serve - plan v0.3 P8: the API layer (text in, token ids to the engine, text deltas out).""" diff --git a/serve/chat_golden.json b/serve/chat_golden.json new file mode 100644 index 00000000..652e3369 --- /dev/null +++ b/serve/chat_golden.json @@ -0,0 +1,197 @@ +[ + { + "name": "single user", + "messages": [ + { + "role": "user", + "content": "Hello" + } + ], + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n\n" + }, + { + "name": "system + user", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant." + }, + { + "role": "user", + "content": "What is 2+2?" + } + ], + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.\n\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\nWhat is 2+2?<|im_end|>\n<|im_start|>assistant\n\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.\n\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\nWhat is 2+2?<|im_end|>\n<|im_start|>assistant\n\n" + }, + { + "name": "multi-turn", + "messages": [ + { + "role": "system", + "content": "Be brief." + }, + { + "role": "user", + "content": "Hi" + }, + { + "role": "assistant", + "content": "Hello! How can I help?" + }, + { + "role": "user", + "content": "Explain BPE in one sentence." + } + ], + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.\n\nBe brief.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n\n\n\n\nHello! How can I help?<|im_end|>\n<|im_start|>user\nExplain BPE in one sentence.<|im_end|>\n<|im_start|>assistant\n\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.\n\nBe brief.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n\n\n\n\nHello! How can I help?<|im_end|>\n<|im_start|>user\nExplain BPE in one sentence.<|im_end|>\n<|im_start|>assistant\n\n" + }, + { + "name": "assistant with thinking", + "messages": [ + { + "role": "user", + "content": "What is 17*23?" + }, + { + "role": "assistant", + "content": "Let me compute.", + "reasoning_content": "17*23 = 17*20 + 17*3 = 340+51 = 391" + }, + { + "role": "user", + "content": "Thanks" + } + ], + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\nWhat is 17*23?<|im_end|>\n<|im_start|>assistant\n\n17*23 = 17*20 + 17*3 = 340+51 = 391\n\n\nLet me compute.<|im_end|>\n<|im_start|>user\nThanks<|im_end|>\n<|im_start|>assistant\n\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\nWhat is 17*23?<|im_end|>\n<|im_start|>assistant\n\n17*23 = 17*20 + 17*3 = 340+51 = 391\n\n\nLet me compute.<|im_end|>\n<|im_start|>user\nThanks<|im_end|>\n<|im_start|>assistant\n\n" + }, + { + "name": "no generation prompt", + "messages": [ + { + "role": "user", + "content": "Hello" + }, + { + "role": "assistant", + "content": "Hi there" + } + ], + "add_generation_prompt": false, + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n\n\n\n\nHi there<|im_end|>\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n\n\n\n\nHi there<|im_end|>\n" + }, + { + "name": "thinking disabled", + "messages": [ + { + "role": "user", + "content": "Hello" + } + ], + "enable_thinking": false, + "rendered": "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n\n\n\n\n", + "error": null, + "hf": "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n\n\n\n\n" + }, + { + "name": "unicode content", + "messages": [ + { + "role": "user", + "content": "你好,世界 😀" + } + ], + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\n你好,世界 😀<|im_end|>\n<|im_start|>assistant\n\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\n你好,世界 😀<|im_end|>\n<|im_start|>assistant\n\n" + }, + { + "name": "tool call and response", + "messages": [ + { + "role": "user", + "content": "Weather in Oslo?" + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "get_weather", + "arguments": { + "city": "Oslo" + } + } + } + ] + }, + { + "role": "tool", + "content": "{\"temp\": 3}", + "name": "get_weather" + }, + { + "role": "user", + "content": "And in Bergen?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ] + } + } + } + ], + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.\n\n# Tools\n\nYou have access to the following functions:\n\n\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get the weather\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"]}}}\n\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n\n\n\nvalue_1\n\n\nThis is the value for the second parameter\nthat can span\nmultiple lines\n\n\n\n\n\nReminder:\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n<|im_end|>\n<|im_start|>user\nWeather in Oslo?<|im_end|>\n<|im_start|>assistant\n\n\n\n\n\n\n\nOslo\n\n\n<|im_end|>\n<|im_start|>user\n\n{\"temp\": 3}\n<|im_end|>\n<|im_start|>user\nAnd in Bergen?<|im_end|>\n<|im_start|>assistant\n\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.\n\n# Tools\n\nYou have access to the following functions:\n\n\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get the weather\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"]}}}\n\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n\n\n\nvalue_1\n\n\nThis is the value for the second parameter\nthat can span\nmultiple lines\n\n\n\n\n\nReminder:\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n<|im_end|>\n<|im_start|>user\nWeather in Oslo?<|im_end|>\n<|im_start|>assistant\n\n\n\n\n\n\n\nOslo\n\n\n<|im_end|>\n<|im_start|>user\n\n{\"temp\": 3}\n<|im_end|>\n<|im_start|>user\nAnd in Bergen?<|im_end|>\n<|im_start|>assistant\n\n" + }, + { + "name": "empty user", + "messages": [ + { + "role": "user", + "content": "" + } + ], + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\n<|im_end|>\n<|im_start|>assistant\n\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\n<|im_end|>\n<|im_start|>assistant\n\n" + }, + { + "name": "whitespace and newlines", + "messages": [ + { + "role": "user", + "content": " line1\n line2\n\n\nend " + } + ], + "rendered": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\nline1\n line2\n\n\nend<|im_end|>\n<|im_start|>assistant\n\n", + "error": null, + "hf": "<|im_start|>system\nReasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>\n<|im_start|>user\nline1\n line2\n\n\nend<|im_end|>\n<|im_start|>assistant\n\n" + } +] \ No newline at end of file diff --git a/serve/chat_template.jinja b/serve/chat_template.jinja new file mode 100644 index 00000000..a0877006 --- /dev/null +++ b/serve/chat_template.jinja @@ -0,0 +1,184 @@ +{%- set image_count = namespace(value=0) %} +{%- set video_count = namespace(value=0) %} +{%- macro render_content(content, do_vision_count, is_system_content=false) %} + {%- if content is string %} + {{- content }} + {%- elif content is iterable and content is not mapping %} + {%- for item in content %} + {%- if 'image' in item or 'image_url' in item or item.type == 'image' %} + {%- if is_system_content %} + {{- raise_exception('System message cannot contain images.') }} + {%- endif %} + {%- if do_vision_count %} + {%- set image_count.value = image_count.value + 1 %} + {%- endif %} + {%- if add_vision_id %} + {{- 'Picture ' ~ image_count.value ~ ': ' }} + {%- endif %} + {{- '<|vision_start|><|image_pad|><|vision_end|>' }} + {%- elif 'video' in item or item.type == 'video' %} + {%- if is_system_content %} + {{- raise_exception('System message cannot contain videos.') }} + {%- endif %} + {%- if do_vision_count %} + {%- set video_count.value = video_count.value + 1 %} + {%- endif %} + {%- if add_vision_id %} + {{- 'Video ' ~ video_count.value ~ ': ' }} + {%- endif %} + {{- '<|vision_start|><|video_pad|><|vision_end|>' }} + {%- elif 'text' in item %} + {{- item.text }} + {%- else %} + {{- raise_exception('Unexpected item type in content.') }} + {%- endif %} + {%- endfor %} + {%- elif content is none or content is undefined %} + {{- '' }} + {%- else %} + {{- raise_exception('Unexpected content type.') }} + {%- endif %} +{%- endmacro %} +{%- if not messages %} + {{- raise_exception('No messages provided.') }} +{%- endif %} +{%- set sysns = namespace(count=0, text='') %} +{%- for message in messages %} + {%- if sysns.count == loop.index0 and (message.role == 'system' or message.role == 'developer') %} + {%- set sys_content = render_content(message.content, false, true)|trim %} + {%- if sys_content %} + {%- set sysns.text = sysns.text + ('\n' if sysns.text else '') + sys_content %} + {%- endif %} + {%- set sysns.count = sysns.count + 1 %} + {%- endif %} +{%- endfor %} +{%- set num_sys = sysns.count %} +{%- set merged_system = sysns.text %} +{%- set reasoning_instructions = '' %} +{%- if enable_thinking is undefined or enable_thinking is true %} + {%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %} + {%- if resolved_reasoning_effort == 'high' %} + {%- set resolved_reasoning_effort = 'xhigh' %} + {%- endif %} + {%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %} + {{- raise_exception('Unexpected reasoning effort ' ~ reasoning_effort ~ '. Supported types are xhigh (default), medium, and low.') }} + {%- endif %} + {%- if resolved_reasoning_effort == 'xhigh' %} + {%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %} + {%- elif resolved_reasoning_effort == 'low' %} + {%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %} + {%- endif %} +{%- endif %} +{%- if tools and tools is iterable and tools is not mapping %} + {{- '<|im_start|>system\n' }} + {%- if reasoning_instructions %} + {{- reasoning_instructions + '\n\n' }} + {%- endif %} + {{- "# Tools\n\nYou have access to the following functions:\n\n" }} + {%- for tool in tools %} + {{- "\n" }} + {{- tool | tojson }} + {%- endfor %} + {{- "\n" }} + {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n\n\n\nvalue_1\n\n\nThis is the value for the second parameter\nthat can span\nmultiple lines\n\n\n\n\n\nReminder:\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n' }} + {%- if merged_system %} + {{- '\n\n' + merged_system }} + {%- endif %} + {{- '<|im_end|>\n' }} +{%- else %} + {%- if merged_system %} + {{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + merged_system + '<|im_end|>\n' }} + {%- elif reasoning_instructions %} + {{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }} + {%- endif %} +{%- endif %} +{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %} +{%- for message in messages[::-1] %} + {%- set index = (messages|length - 1) - loop.index0 %} + {%- if ns.multi_step_tool and message.role == "user" %} + {%- set content = render_content(message.content, false)|trim %} + {%- if not(content.startswith('') and content.endswith('')) %} + {%- set ns.multi_step_tool = false %} + {%- set ns.last_query_index = index %} + {%- endif %} + {%- endif %} +{%- endfor %} +{%- for message in messages %} + {%- if loop.index0 >= num_sys %} + {%- set content = render_content(message.content, true)|trim %} + {%- if message.role == "system" or message.role == "developer" %} + {{- raise_exception('System message must be at the beginning.') }} + {%- elif message.role == "user" %} + {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }} + {%- elif message.role == "assistant" %} + {%- set reasoning_content = '' %} + {%- if message.reasoning_content is string %} + {%- set reasoning_content = message.reasoning_content %} + {%- endif %} + {%- set reasoning_content = reasoning_content|trim %} + {%- if preserve_thinking is undefined or preserve_thinking is true or loop.index0 > ns.last_query_index %} + {{- '<|im_start|>' + message.role + '\n\n' + reasoning_content + '\n\n\n' + content }} + {%- else %} + {{- '<|im_start|>' + message.role + '\n' + content }} + {%- endif %} + {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %} + {%- for tool_call in message.tool_calls %} + {%- if tool_call.function is defined %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {%- if tool_call.name is not defined or tool_call.name is none %} + {{- raise_exception('Tool call is missing a function name.') }} + {%- endif %} + {%- if loop.first %} + {%- if content|trim %} + {{- '\n\n\n\n' }} + {%- else %} + {{- '\n\n' }} + {%- endif %} + {%- else %} + {{- '\n\n\n' }} + {%- endif %} + {%- if tool_call.arguments is mapping %} + {%- for args_name, args_value in tool_call.arguments|items %} + {{- '\n' }} + {%- set args_value = args_value | string if args_value is string else args_value | tojson | safe %} + {{- args_value }} + {{- '\n\n' }} + {%- endfor %} + {%- elif tool_call.arguments is string %} + {%- if tool_call.arguments|trim %} + {{- raise_exception('Tool call arguments for function "' + (tool_call.name | string) + '" were passed as a JSON string. Parse them into an object before calling apply_chat_template.') }} + {%- endif %} + {%- elif tool_call.arguments is defined and tool_call.arguments is not none %} + {{- raise_exception('Tool call arguments for function "' + (tool_call.name | string) + '" must be an object/mapping or a JSON string.') }} + {%- endif %} + {{- '\n' }} + {%- endfor %} + {%- endif %} + {{- '<|im_end|>\n' }} + {%- elif message.role == "tool" %} + {%- if loop.previtem and loop.previtem.role != "tool" %} + {{- '<|im_start|>user' }} + {%- endif %} + {{- '\n\n' }} + {{- content }} + {{- '\n' }} + {%- if not loop.last and loop.nextitem.role != "tool" %} + {{- '<|im_end|>\n' }} + {%- elif loop.last %} + {{- '<|im_end|>\n' }} + {%- endif %} + {%- else %} + {{- raise_exception('Unexpected message role.') }} + {%- endif %} + {%- endif %} +{%- endfor %} +{%- if add_generation_prompt %} + {{- '<|im_start|>assistant\n' }} + {%- if enable_thinking is defined and enable_thinking is false %} + {{- '\n\n\n\n' }} + {%- else %} + {{- '\n' }} + {%- endif %} +{%- endif %} +{#- Unsloth fixes - developer role, merged system messages, tool calling #} \ No newline at end of file diff --git a/serve/frontend.py b/serve/frontend.py new file mode 100644 index 00000000..de7a0b03 --- /dev/null +++ b/serve/frontend.py @@ -0,0 +1,344 @@ +"""serve/frontend.py - plan v0.3 P8: the request/response layer between API clients and the engine. + +Everything here is text: no model, no GPU. The engine consumes token ids and produces text deltas; this module + + * normalizes OpenAI Chat Completions and Anthropic Messages requests into the chat template's message form, + * renders the model's own Jinja chat template (pack `tokenizer/chat_template.jinja`) exactly as Hugging Face + does (checked against the pack's `chat_golden.json`, 10 cases incl. thinking and tools), + * parses the streamed output incrementally into reasoning (`...`), content and tool calls in + the template's XML form (`VALUE...`), + never emitting a partial tag to the client. + +Design note: plan v0.3 describes a single C++ process. The frontend is Python first because the template is Jinja +and the formats change often; the engine boundary is token ids in, text deltas out, which keeps a later C++ port +(or a pybind wrapper) a local change. +""" +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass, field +from pathlib import Path + +import jinja2 +from jinja2.sandbox import ImmutableSandboxedEnvironment + + +# ------------------------------------------------------------------------------------------------ template +class ChatTemplate: + """The model's chat template, rendered with the same Jinja settings as transformers' apply_chat_template.""" + + def __init__(self, path: str | Path): + def raise_exception(message): + raise jinja2.exceptions.TemplateError(message) + + def tojson(x, ensure_ascii=False, indent=None, separators=None, sort_keys=False): + return json.dumps(x, ensure_ascii=ensure_ascii, indent=indent, separators=separators, sort_keys=sort_keys) + + env = ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True, extensions=["jinja2.ext.loopcontrols"]) + env.filters["tojson"] = tojson + env.globals["raise_exception"] = raise_exception + self.template = env.from_string(Path(path).read_text(encoding="utf-8")) + + def render(self, messages: list[dict], tools: list[dict] | None = None, add_generation_prompt: bool = True, + **kwargs) -> str: + return self.template.render(messages=messages, tools=tools, add_generation_prompt=add_generation_prompt, + **kwargs) + + +# ------------------------------------------------------------------------------------------------ requests +def _text_of(content) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + return "".join(part.get("text", "") for part in content if isinstance(part, dict) and part.get("type") in + ("text", "input_text", None)) + + +IMAGE_PARTS = ("image_url", "input_image", "image") + +# Thinking levels. The model's template knows low, medium and xhigh (its default; "high" means xhigh), and +# enable_thinking=false for none. Clients spell these many ways; everything maps onto those four. +EFFORT = {"none": None, "off": None, "minimal": None, "disabled": None, "false": None, + "low": "low", "medium": "medium", "high": "xhigh", "xhigh": "xhigh", "max": "xhigh", "maximum": "xhigh"} + + +def effort_kwargs(value) -> dict: + """A reasoning effort as given by a client -> the template's kwargs. Unknown values are a 400, not a crash.""" + if value is None or value == "": + return {} + if value is False: + return {"enable_thinking": False} + key = str(value).strip().lower() + if key not in EFFORT: + raise ValueError(f"unknown reasoning effort {value!r}: use none, low, medium or high") + level = EFFORT[key] + return {"enable_thinking": False} if level is None else {"reasoning_effort": level} + + +def budget_effort(tokens) -> dict: + """Anthropic's thinking budget (budget_tokens) -> a level: under 2K low, under 8K medium, else high.""" + try: + n = int(tokens) + except (TypeError, ValueError): + return {} + return {"reasoning_effort": "low" if n < 2048 else "medium" if n < 8192 else "xhigh"} + + +def _has_image(content) -> bool: + return isinstance(content, list) and any(isinstance(p, dict) and p.get("type") in IMAGE_PARTS for p in content) + + +def _image_source(part: dict) -> str: + """An image part's source as one string: a data: URL, an http(s) URL or a local file path. + OpenAI: {"type": "image_url", "image_url": {"url": ...}} (or "image_url": "..."), Responses-style + {"type": "input_image", "image_url": ...}; Anthropic: {"type": "image", "source": {"type": "base64", + "media_type": ..., "data": ...}} or {"source": {"type": "url", "url": ...}}.""" + if part.get("type") == "image": + src = part.get("source") or {} + if src.get("type") == "base64": + return f"data:{src.get('media_type', 'image/png')};base64,{src.get('data', '')}" + return src.get("url") or src.get("path") or "" + url = part.get("image_url") + if isinstance(url, dict): + url = url.get("url") + return url or "" + + +def _parts_of(content): + """Message content for the template: a string when there is no image (unchanged behaviour), otherwise the + template's list form - text items and image items, in order - whose image items carry their source.""" + if not _has_image(content): + return _text_of(content) + items = [] + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") in IMAGE_PARTS: + items.append({"type": "image", "source": _image_source(part)}) + elif part.get("type") in ("text", "input_text", None) and "text" in part: + items.append({"type": "text", "text": part.get("text", "")}) + return items + + +def images_of(messages: list[dict]) -> list[str]: + """The image sources of the rendered conversation, in prompt order (the template renders one + <|vision_start|><|image_pad|><|vision_end|> per image item, message by message).""" + return [item["source"] for m in messages if isinstance(m.get("content"), list) + for item in m["content"] if item.get("type") == "image"] + + +def openai_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]: + """OpenAI Chat Completions -> (template messages, template tools, template kwargs).""" + messages = [] + for m in req.get("messages", []): + role = m.get("role") + if role == "developer": + role = "system" + out = {"role": role, "content": _parts_of(m.get("content")) if role == "user" else _text_of(m.get("content"))} + if m.get("reasoning_content"): + out["reasoning_content"] = m["reasoning_content"] + if m.get("tool_calls"): + calls = [] + for c in m["tool_calls"]: + fn = c.get("function", c) + args = fn.get("arguments") + if isinstance(args, str): # the template requires a mapping, not a JSON string + args = json.loads(args) if args.strip() else {} + calls.append({"function": {"name": fn.get("name"), "arguments": args or {}}}) + out["tool_calls"] = calls + messages.append(out) + tools = [t.get("function", t) if isinstance(t, dict) and t.get("type") == "function" else t + for t in req.get("tools") or []] or None + kwargs = {} + # OpenAI Chat Completions: "reasoning_effort"; Responses style: "reasoning": {"effort": ...} + reasoning = req.get("reasoning") if isinstance(req.get("reasoning"), dict) else {} + kwargs.update(effort_kwargs(req.get("reasoning_effort") or reasoning.get("effort"))) + # the vLLM / llama.cpp convention: {"chat_template_kwargs": {"enable_thinking": false, "reasoning_effort": "low"}} + for k, v in (req.get("chat_template_kwargs") or {}).items(): + if k == "enable_thinking" and not v: + kwargs = {"enable_thinking": False} + elif k == "reasoning_effort" and "enable_thinking" not in kwargs: + kwargs.update(effort_kwargs(v)) + return messages, tools, kwargs + + +def anthropic_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]: + """Anthropic Messages -> (template messages, template tools, template kwargs).""" + messages = [] + system = req.get("system") + if system: + messages.append({"role": "system", "content": _text_of(system)}) + for m in req.get("messages", []): + content = m.get("content") + if isinstance(content, str): + messages.append({"role": m["role"], "content": content}) + continue + if m.get("role") == "user" and _has_image(content) and not any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in content): + messages.append({"role": "user", "content": _parts_of(content)}) + continue + text, reasoning, calls = [], [], [] + for block in content or []: + kind = block.get("type") + if kind == "text": + text.append(block.get("text", "")) + elif kind == "thinking": + reasoning.append(block.get("thinking", "")) + elif kind == "tool_use": + calls.append({"function": {"name": block.get("name"), "arguments": block.get("input") or {}}}) + elif kind == "tool_result": + messages.append({"role": "tool", "content": _text_of(block.get("content"))}) + if text or calls or reasoning: + out = {"role": m["role"], "content": "".join(text)} + if reasoning: + out["reasoning_content"] = "".join(reasoning) + if calls: + out["tool_calls"] = calls + messages.append(out) + tools = [{"name": t["name"], "description": t.get("description", ""), "parameters": t.get("input_schema", {})} + for t in req.get("tools") or []] or None + kwargs = {} + # Anthropic: "thinking": {"type": "disabled"} or {"type": "enabled", "budget_tokens": N}; + # "output_config": {"effort": "low" | "medium" | "high"} + thinking = req.get("thinking") + effort = (req.get("output_config") or {}).get("effort") if isinstance(req.get("output_config"), dict) else None + if isinstance(thinking, dict) and thinking.get("type") == "disabled": + kwargs["enable_thinking"] = False + elif effort: + kwargs.update(effort_kwargs(effort)) + elif isinstance(thinking, dict) and thinking.get("budget_tokens"): + kwargs.update(budget_effort(thinking["budget_tokens"])) + return messages, tools, kwargs + + +# ------------------------------------------------------------------------------------------------ output parser +@dataclass +class ToolCall: + name: str + arguments: dict + id: str = field(default_factory=lambda: "call_" + uuid.uuid4().hex[:24]) + + +@dataclass +class Event: + kind: str # "reasoning" | "content" | "tool_call" + text: str = "" + call: ToolCall | None = None + + +THINK_END = "" +CALL_START = "" +CALL_END = "" + + +def parse_tool_call(body: str, schema: dict | None = None) -> ToolCall: + """`\\n\\nVALUE\\n...` -> ToolCall. Values are JSON-decoded + when the tool's schema says the parameter is not a string (or, without a schema, when they parse as JSON + objects/arrays/numbers/booleans).""" + body = body.strip() + if not body.startswith("" not in body: + raise ValueError("malformed tool call: " + body[:80]) + name = body[len("")] + rest = body[body.index(">") + 1:] + props = ((schema or {}).get("parameters") or {}).get("properties") or {} + args = {} + while "")] + rest = rest[rest.index(">") + 1:] + end = rest.find("") + value = rest[:end] if end >= 0 else rest + rest = rest[end + len(""):] if end >= 0 else "" + if value.startswith("\n"): + value = value[1:] + if value.endswith("\n"): + value = value[:-1] + declared = (props.get(pname) or {}).get("type") + if declared == "string": + args[pname] = value + else: + try: + args[pname] = json.loads(value) + except ValueError: + args[pname] = value + return ToolCall(name=name, arguments=args) + + +class OutputParser: + """Incremental parser of the model's text. Feed deltas; get events. A tag split across deltas is held back + until it is complete, so clients never see ` int: + """Length of the longest suffix of `text` that is a proper prefix of one of `tags`.""" + best = 0 + for tag in tags: + for n in range(1, len(tag)): + if text.endswith(tag[:n]): + best = max(best, n) + return best + + def feed(self, delta: str) -> list[Event]: + self.buf += delta + out: list[Event] = [] + while True: + if self.state == "reasoning": + i = self.buf.find(THINK_END) + if i < 0: + keep = self._hold(self.buf, (THINK_END,)) + if len(self.buf) > keep: + out.append(Event("reasoning", self.buf[:len(self.buf) - keep])) + self.buf = self.buf[len(self.buf) - keep:] + return out + if i: + out.append(Event("reasoning", self.buf[:i])) + self.buf = self.buf[i + len(THINK_END):] + self.state, self.lead = "content", True + elif self.state == "content": + if self.lead: # newlines right after or a call + stripped = self.buf.lstrip("\n") + if not stripped: + self.buf = "" + return out + self.buf, self.lead = stripped, False + i = self.buf.find(CALL_START) + if i < 0: + # Hold a partial tag AND the newlines before it: if a tool call follows, they are dropped, + # so emitting them early would make streamed and whole outputs differ. + j = len(self.buf) - self._hold(self.buf, (CALL_START,)) + while j > 0 and self.buf[j - 1] == "\n": + j -= 1 + if j > 0: + out.append(Event("content", self.buf[:j])) + self.buf = self.buf[j:] + return out + if i and self.buf[:i].strip(): + out.append(Event("content", self.buf[:i].rstrip("\n"))) + self.buf = self.buf[i + len(CALL_START):] + self.state = "call" + else: + i = self.buf.find(CALL_END) + if i < 0: + return out + body = self.buf[:i] + self.buf = self.buf[i + len(CALL_END):] + name = body.strip()[len("", 1)[0] + out.append(Event("tool_call", call=parse_tool_call(body, self.schemas.get(name)))) + self.state, self.lead = "content", True + + def finish(self) -> list[Event]: + """End of generation: flush whatever is held (an unterminated tool call is returned as content).""" + out = [] + if self.buf: + kind = {"reasoning": "reasoning", "content": "content"}.get(self.state, "content") + text = self.buf if self.state != "call" else CALL_START + self.buf + out.append(Event(kind, text)) + self.buf = "" + return out diff --git a/serve/index.html b/serve/index.html new file mode 100644 index 00000000..1da0b5ce --- /dev/null +++ b/serve/index.html @@ -0,0 +1,152 @@ + + + + + +Strata + + + +
    +
    +

    Strata connecting ...

    +
    + Model- + Context- + Images- + OpenAI API (any API key) + Anthropic API +
    +

    Point any app with an "OpenAI-compatible" or "Anthropic" provider at these + addresses. Close the Strata window to stop the model.

    +
    + +
    + +
    +
    + + + + +
    +
    + + + + +
    +
    +
    + + + diff --git a/serve/server.py b/serve/server.py new file mode 100644 index 00000000..91889ee5 --- /dev/null +++ b/serve/server.py @@ -0,0 +1,606 @@ +"""serve/server.py - plan v0.3 P8: OpenAI and Anthropic endpoints over any engine that maps token ids to tokens. + + python -m serve.server --engine mock --port 8095 (a scripted engine, for clients and tests) + python -m serve.server --engine strata --config strata.json --port 8080 (the real engine, resident) + +Endpoints: POST /v1/chat/completions (OpenAI, stream and non-stream), POST /v1/messages (Anthropic, stream and +non-stream), GET /v1/models, GET /health. One sequence at a time behind a FIFO (plan: one resident sequence). +Images (optional, when the config has a "vision" entry): OpenAI image_url parts and Anthropic image blocks (base64 +data, http(s) URLs or local file paths; JPEG/PNG/BMP/GIF) go through `strata-vision` (the model's mmproj file) and +reach the engine as embeddings (`GENI`). +Requests whose prompt plus max tokens exceed the engine's context are REJECTED with 400, never truncated. + +The engine boundary is `Engine.generate(prompt_ids, max_new, sampling, cancel) -> iterator of token ids`. +`StrataEngine` keeps one `strata --serve` process resident (weights, expert arena and VRAM tier load once) and +talks to it over stdin/stdout; `MockEngine` is a scripted stand-in that makes every API path testable without a GPU. +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +import urllib.request +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Iterator, Protocol + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +sys.path.insert(0, str(ROOT)) # run as a script (run-.bat) as well as a module +from serve.frontend import (ChatTemplate, Event, OutputParser, anthropic_to_messages, # noqa: E402 + images_of, openai_to_messages) + +IM_END = "<|im_end|>" +IMAGE_PAD = "<|image_pad|>" + + +# ------------------------------------------------------------------------------------------------ engines +class Engine(Protocol): + max_context: int + def generate(self, ids: list[int], max_new: int, sampling: dict, cancel: threading.Event) -> Iterator[int]: ... + + +class MockEngine: + """Replays a scripted completion (text) as token ids, one per step, then the end-of-turn token.""" + + def __init__(self, tokenizer, script: str, max_context: int = 32768, delay_s: float = 0.0): + self.tok, self.max_context, self.delay = tokenizer, max_context, delay_s + self.script = tokenizer.encode(script, parse_special=True) + tokenizer.encode(IM_END, parse_special=True) + self.last_prompt: list[int] = [] + + def generate(self, ids, max_new, sampling, cancel, embeddings=None): + self.last_prompt = list(ids) + self.last_embeddings = embeddings + for t in self.script[:max_new]: + if cancel.is_set(): + return + if self.delay: + time.sleep(self.delay) + yield t + + +class StrataEngine: + """The resident engine: `strata --serve` reads `GEN ` lines and streams `T ` lines, then + `DONE ...`. Requests are serialized by the service's FIFO, so one pipe is enough.""" + + def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str | None = None, + env: dict | None = None): + self.log = open(log, "a", encoding="utf-8") if log else subprocess.DEVNULL + self.proc = subprocess.Popen([exe, "--serve", *args], cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=self.log, text=True, encoding="utf-8", bufsize=1, env=env) + self.max_context = 0 + self.last = {} + for line in self.proc.stdout: + if line.startswith("READY"): + self.max_context = int(line.split()[1]) + break + if self.max_context <= 0: + raise RuntimeError("the engine exited before it was ready" + (f" (see {log})" if log else "")) + + def generate(self, ids, max_new, sampling, cancel, embeddings=None): + head = f"GENI {int(max_new)} {embeddings}" if embeddings else f"GEN {int(max_new)}" + self.proc.stdin.write(f"{head} {','.join(str(int(t)) for t in ids)}\n") + self.proc.stdin.flush() + done = False + try: + for line in self.proc.stdout: + if line.startswith("T "): + if not cancel.is_set(): + yield int(line[2:]) + elif line.startswith("DONE"): + f = line.split() + self.last = {"generated": int(f[1]), "prompt_tokens": int(f[2]), "prompt_ms": float(f[3]), + "decode_ms": float(f[4]), "finish": f[5]} + done = True + return + elif line.startswith("ERR"): + done = True + raise ValueError(line[4:].strip()) + raise RuntimeError("the engine process ended") + finally: + if not done: # the consumer stopped early: drain to DONE + for line in self.proc.stdout: + if line.startswith("DONE") or line.startswith("ERR"): + break + + def close(self): + try: + self.proc.stdin.write("QUIT\n") + self.proc.stdin.flush() + self.proc.wait(timeout=10) + except Exception: + self.proc.kill() + + +class Vision: + """The resident image encoder: `strata-vision` (llama.cpp mtmd + the mmproj file) reads `ENC ` + lines and writes each image's embeddings; results are cached by the image's hash, so a conversation that + sends the same picture again (every turn, with most clients) encodes it once.""" + + def __init__(self, cfg: dict, log=None, env: dict | None = None): + args = [cfg["exe"], "--mmproj", cfg["mmproj"], "--model", cfg["model"]] + if cfg.get("gpu"): + args.append("--gpu") + if cfg.get("threads"): + args += ["--threads", str(cfg["threads"])] + if cfg.get("max_tokens"): + args += ["--max-tokens", str(cfg["max_tokens"])] + self.dir = Path(tempfile.mkdtemp(prefix="strata-vision-")) + self.proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log or subprocess.DEVNULL, + text=True, encoding="utf-8", bufsize=1, env=env) + line = self.proc.stdout.readline() + if not line.startswith("READY"): + raise RuntimeError("the vision encoder did not start: " + line.strip()) + self.lock = threading.Lock() + self.cache: dict[str, tuple[Path, int]] = {} + + @staticmethod + def load(source: str) -> bytes: + if source.startswith("data:"): + return base64.b64decode(source.split(",", 1)[1]) + if source.startswith(("http://", "https://")): + req = urllib.request.Request(source, headers={"User-Agent": "strata"}) + with urllib.request.urlopen(req, timeout=60) as r: + return r.read() + path = source[7:] if source.startswith("file://") else source + if path and os.path.isfile(path): + return Path(path).read_bytes() + raise ValueError("an image must be a data: URL, an http(s) URL or a local file path") + + def encode(self, source: str) -> tuple[Path, int]: + """-> (embeddings file, number of image tokens).""" + data = self.load(source) + key = hashlib.sha256(data).hexdigest()[:32] + with self.lock: + if key in self.cache: + return self.cache[key] + img, out = self.dir / f"{key}.img", self.dir / f"{key}.sve" + img.write_bytes(data) + self.proc.stdin.write(f"ENC {img} {out}\n") + self.proc.stdin.flush() + line = self.proc.stdout.readline().strip() + img.unlink(missing_ok=True) + if not line.startswith("OK"): + raise ValueError("the image could not be read: " + (line[4:] if line.startswith("ERR") else + "the vision encoder stopped")) + self.cache[key] = (out, int(line.split()[1])) + if len(self.cache) > 64: # oldest first + old = next(iter(self.cache)) + self.cache.pop(old)[0].unlink(missing_ok=True) + return self.cache[key] + + def close(self): + try: + self.proc.stdin.write("QUIT\n") + self.proc.stdin.flush() + self.proc.wait(timeout=10) + except Exception: + self.proc.kill() + + +def child_env(cfg: dict) -> dict: + """The engine's environment: the CUDA libraries setup installed (pip's nvidia packages, or the toolkit that + compiled it) first on the library search path.""" + env = dict(os.environ) + dirs = [d for d in cfg.get("lib_dirs") or [] if Path(d).is_dir()] + if dirs: + var = "PATH" if os.name == "nt" else "LD_LIBRARY_PATH" + env[var] = os.pathsep.join(dirs + ([env[var]] if env.get(var) else [])) + return env + + +class ByteTokenizer: + """Tiny stand-in tokenizer for tests without the pack: one id per UTF-8 byte, specials as ids >= 256.""" + SPECIALS = ["<|im_start|>", "<|im_end|>", "<|endoftext|>", "<|vision_start|>", "<|image_pad|>", "<|vision_end|>"] + + def encode(self, text, parse_special=False): + out, i = [], 0 + while i < len(text): + for k, s in enumerate(self.SPECIALS): + if parse_special and text.startswith(s, i): + out.append(256 + k) + i += len(s) + break + else: + out.extend(text[i].encode("utf-8")) + i += 1 + return out + + def decode(self, ids, errors="replace"): + raw = bytearray() + for t in ids: + raw += self.SPECIALS[t - 256].encode() if t >= 256 else bytes([t]) + return raw.decode("utf-8", errors=errors) + + +# ------------------------------------------------------------------------------------------------ core +class Detokenizer: + """Incremental decode: re-decode the generated ids and emit only the new, complete suffix (a multi-byte + character split across tokens is held until complete).""" + + def __init__(self, tok): + self.tok, self.ids, self.sent = tok, [], 0 + + def push(self, t: int) -> str: + self.ids.append(t) + text = self.tok.decode(self.ids) + if text.endswith("�"): + return "" + delta, self.sent = text[self.sent:], len(text) + return delta + + +class Service: + def __init__(self, engine: Engine, tokenizer, template: ChatTemplate, model_name: str = "qwen3.8-flash-next", + vision: Vision | None = None): + self.engine, self.tok, self.template, self.model, self.vision = engine, tokenizer, template, model_name, vision + self.fifo = threading.Lock() + self.embeddings = threading.local() # the current request's image embeddings file (GENI) + self.api_key = "" # when set, /v1/* needs it (Bearer or x-api-key) + self.stop_ids = set(tokenizer.encode(IM_END, parse_special=True) + + tokenizer.encode("<|endoftext|>", parse_special=True)) + + def prepare(self, messages, tools, kwargs, max_new): + prompt = self.template.render(messages, tools=tools, **kwargs) + ids = self.tok.encode(prompt, parse_special=True) + self.embeddings.path = None + images = images_of(messages) + if images: + if self.vision is None: + raise ValueError("this server was started without the vision encoder (run setup again and choose " + "'vision'), so it cannot read images") + pad = self.tok.encode(IMAGE_PAD, parse_special=True)[0] + encoded = [self.vision.encode(src) for src in images] + out, k = [], 0 + for t in ids: # one <|image_pad|> per image -> one per image token + if t == pad and k < len(encoded): + out += [pad] * encoded[k][1] + k += 1 + else: + out.append(t) + if k != len(encoded): + raise ValueError("the prompt and its images do not match") + ids = out + combined = self.vision.dir / f"req-{uuid.uuid4().hex[:12]}.sve" + with open(combined, "wb") as f: + for path, _ in encoded: + f.write(path.read_bytes()) + self.embeddings.path = combined + if len(ids) + max_new > self.engine.max_context: + raise ValueError(f"prompt ({len(ids)} tokens) + max tokens ({max_new}) exceeds the context " + f"({self.engine.max_context}); requests are never truncated") + return ids, kwargs.get("enable_thinking", True) is not False + + def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple[str, object]]: + """Yields ("event", Event) as text arrives, then ("done", {"finish": .., "completion_tokens": ..}).""" + parser, detok, n, finish = OutputParser(thinking=thinking, tools=tools), Detokenizer(self.tok), 0, "length" + emb = getattr(self.embeddings, "path", None) + try: + with self.fifo: + gen = self.engine.generate(ids, max_new, sampling, cancel, embeddings=emb) if emb else \ + self.engine.generate(ids, max_new, sampling, cancel) + for t in gen: + n += 1 + if t in self.stop_ids: + finish = "stop" + break + for ev in parser.feed(detok.push(t)): + yield "event", ev + finally: + if emb: + Path(emb).unlink(missing_ok=True) + for ev in parser.finish(): + yield "event", ev + yield "done", {"finish": finish, "completion_tokens": n} + + +# ------------------------------------------------------------------------------------------------ OpenAI +def openai_chunks(svc: Service, req: dict, ids, thinking, tools, max_new, cancel): + cid, created = "chatcmpl-" + uuid.uuid4().hex[:24], int(time.time()) + + def chunk(delta, finish=None): + return {"id": cid, "object": "chat.completion.chunk", "created": created, "model": svc.model, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]} + + yield chunk({"role": "assistant", "content": ""}) + calls = 0 + for kind, x in svc.run(ids, thinking, tools, max_new, req, cancel): + if kind == "event": + ev: Event = x + if ev.kind == "reasoning" and ev.text: + yield chunk({"reasoning_content": ev.text}) + elif ev.kind == "content" and ev.text: + yield chunk({"content": ev.text}) + elif ev.kind == "tool_call": + yield chunk({"tool_calls": [{"index": calls, "id": ev.call.id, "type": "function", + "function": {"name": ev.call.name, + "arguments": json.dumps(ev.call.arguments, ensure_ascii=False)}}]}) + calls += 1 + else: + finish = "tool_calls" if calls and x["finish"] == "stop" else x["finish"] + last = chunk({}, finish) + last["usage"] = {"prompt_tokens": len(ids), "completion_tokens": x["completion_tokens"], + "total_tokens": len(ids) + x["completion_tokens"]} + yield last + + +def openai_collect(chunks) -> dict: + content, reasoning, calls, last = [], [], [], None + for c in chunks: + d = c["choices"][0]["delta"] + content.append(d.get("content") or "") + reasoning.append(d.get("reasoning_content") or "") + calls += [{k: v for k, v in tc.items() if k != "index"} for tc in d.get("tool_calls") or []] + last = c + msg = {"role": "assistant", "content": "".join(content) or None} + if "".join(reasoning): + msg["reasoning_content"] = "".join(reasoning) + if calls: + msg["tool_calls"] = calls + return {"id": last["id"], "object": "chat.completion", "created": last["created"], "model": last["model"], + "choices": [{"index": 0, "message": msg, "finish_reason": last["choices"][0]["finish_reason"]}], + "usage": last["usage"]} + + +# ------------------------------------------------------------------------------------------------ Anthropic +def anthropic_events(svc: Service, req: dict, ids, thinking, tools, max_new, cancel): + mid = "msg_" + uuid.uuid4().hex[:24] + yield "message_start", {"type": "message_start", "message": { + "id": mid, "type": "message", "role": "assistant", "model": svc.model, "content": [], + "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": len(ids), "output_tokens": 0}}} + index, open_kind, used_tool = -1, None, False + + def close(): + return ("content_block_stop", {"type": "content_block_stop", "index": index}) + + for kind, x in svc.run(ids, thinking, tools, max_new, req, cancel): + if kind == "event": + ev: Event = x + want = {"reasoning": "thinking", "content": "text", "tool_call": "tool_use"}[ev.kind] + if ev.kind != "tool_call" and not ev.text: + continue + if open_kind != want or want == "tool_use": + if open_kind is not None: + yield close() + index += 1 + open_kind = want + block = {"thinking": {"type": "thinking", "thinking": "", "signature": ""}, + "text": {"type": "text", "text": ""}, + "tool_use": {"type": "tool_use", "id": ev.call.id if ev.call else "", "name": + ev.call.name if ev.call else "", "input": {}}}[want] + yield "content_block_start", {"type": "content_block_start", "index": index, "content_block": block} + if want == "thinking": + yield "content_block_delta", {"type": "content_block_delta", "index": index, + "delta": {"type": "thinking_delta", "thinking": ev.text}} + elif want == "text": + yield "content_block_delta", {"type": "content_block_delta", "index": index, + "delta": {"type": "text_delta", "text": ev.text}} + else: + used_tool = True + yield "content_block_delta", {"type": "content_block_delta", "index": index, "delta": { + "type": "input_json_delta", "partial_json": json.dumps(ev.call.arguments, ensure_ascii=False)}} + else: + if open_kind is not None: + yield close() + stop = "tool_use" if used_tool and x["finish"] == "stop" else \ + {"stop": "end_turn", "length": "max_tokens"}[x["finish"]] + yield "message_delta", {"type": "message_delta", "delta": {"stop_reason": stop, "stop_sequence": None}, + "usage": {"output_tokens": x["completion_tokens"]}} + yield "message_stop", {"type": "message_stop"} + + +def anthropic_collect(events) -> dict: + msg, blocks = None, [] + for name, e in events: + if name == "message_start": + msg = e["message"] + elif name == "content_block_start": + blocks.append(dict(e["content_block"])) + elif name == "content_block_delta": + d, b = e["delta"], blocks[-1] + if d["type"] == "text_delta": + b["text"] += d["text"] + elif d["type"] == "thinking_delta": + b["thinking"] += d["thinking"] + else: + b["input"] = json.loads(d["partial_json"]) + elif name == "message_delta": + msg["stop_reason"] = e["delta"]["stop_reason"] + msg["usage"]["output_tokens"] = e["usage"]["output_tokens"] + msg["content"] = blocks + return msg + + +# ------------------------------------------------------------------------------------------------ HTTP +def make_handler(svc: Service): + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" # SSE ends by closing the connection + + def log_message(self, fmt, *args): + pass + + def _json(self, code, obj): + body = json.dumps(obj, ensure_ascii=False).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _authorized(self) -> bool: + if not svc.api_key: + return True + auth = self.headers.get("Authorization", "") + given = auth[7:].strip() if auth.lower().startswith("bearer ") else self.headers.get("x-api-key", "") + if given == svc.api_key: + return True + self._json(401, {"error": {"type": "authentication_error", "message": "missing or wrong API key"}}) + return False + + def do_GET(self): + path = self.path.split("?")[0].rstrip("/") + if path == "": + body = (ROOT / "serve" / "index.html").read_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + elif path == "/health": + self._json(200, {"status": "ok", "max_context": svc.engine.max_context, "model": svc.model, + "images": svc.vision is not None, "api_key": bool(svc.api_key)}) + elif path == "/v1/models": + if self._authorized(): + self._json(200, {"object": "list", "data": [{"id": svc.model, "object": "model"}]}) + else: + self._json(404, {"error": {"message": "not found"}}) + + def do_POST(self): + if not self._authorized(): + return + try: + req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") + if self.path.rstrip("/") == "/v1/chat/completions": + self._openai(req) + elif self.path.rstrip("/") == "/v1/messages": + self._anthropic(req) + else: + self._json(404, {"error": {"message": "not found"}}) + except ValueError as e: + self._json(400, {"error": {"type": "invalid_request_error", "message": str(e)}}) + + def _sse(self): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + + def _openai(self, req): + messages, tools, kw = openai_to_messages(req) + max_new = int(req.get("max_completion_tokens") or req.get("max_tokens") or 1024) + ids, thinking = svc.prepare(messages, tools, kw, max_new) + cancel = threading.Event() + chunks = openai_chunks(svc, req, ids, thinking, tools, max_new, cancel) + if not req.get("stream"): + return self._json(200, openai_collect(chunks)) + self._sse() + try: + for c in chunks: + self.wfile.write(b"data: " + json.dumps(c, ensure_ascii=False).encode() + b"\n\n") + self.wfile.flush() + self.wfile.write(b"data: [DONE]\n\n") + except OSError: + cancel.set() # client went away: stop at the next step + + def _anthropic(self, req): + messages, tools, kw = anthropic_to_messages(req) + max_new = int(req.get("max_tokens") or 1024) + ids, thinking = svc.prepare(messages, tools, kw, max_new) + cancel = threading.Event() + events = anthropic_events(svc, req, ids, thinking, tools, max_new, cancel) + if not req.get("stream"): + return self._json(200, anthropic_collect(events)) + self._sse() + try: + for name, e in events: + self.wfile.write(f"event: {name}\n".encode() + b"data: " + + json.dumps(e, ensure_ascii=False).encode() + b"\n\n") + self.wfile.flush() + except OSError: + cancel.set() + + return Handler + + +class Server(ThreadingHTTPServer): + # On Windows SO_REUSEADDR lets a second server bind a port that is already serving, and requests then land on + # either one (a forgotten second start of run-.bat). Without it the second start fails loudly instead. + allow_reuse_address = os.name != "nt" + + +def serve(svc: Service, host="127.0.0.1", port=8095) -> ThreadingHTTPServer: + httpd = Server((host, port), make_handler(svc)) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--engine", choices=["mock", "strata"], default="mock") + ap.add_argument("--config", help="strata engine config (JSON: exe, args, cwd, tokenizer, model_name), " + "written by setup.py") + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--script", default="Thinking about it.
    \n\nHello from the mock engine.") + ap.add_argument("--port", type=int, default=8095) + ap.add_argument("--tokenizer", default=str(ROOT / "pack/full/tokenizer"), + help="pack tokenizer directory (falls back to a byte tokenizer if absent)") + ap.add_argument("--open", action="store_true", help="open the local page in the browser once the model is ready") + ap.add_argument("--api-key", default=os.environ.get("STRATA_API_KEY", ""), + help="require this key on /v1/* (Authorization: Bearer ... or x-api-key); also $STRATA_API_KEY") + a = ap.parse_args() + cfg = json.loads(Path(a.config).read_text(encoding="utf-8")) if a.config else {} + try: # before the minutes of loading: is the port free? + Server((a.host, a.port), BaseHTTPRequestHandler).server_close() + except OSError: + ap.error(f"port {a.port} is already in use - is Strata (or another server) already running? " + f"Close it, or start this one with a different --port") + if cfg.get("tokenizer"): + a.tokenizer = cfg["tokenizer"] + tok = ByteTokenizer() + tpath = Path(a.tokenizer) + if a.engine == "strata" and not (tpath / "vocab.json").exists(): + ap.error(f"the model's tokenizer is missing ({tpath / 'vocab.json'}); run setup again") + if (tpath / "vocab.json").exists(): + import strata_tokenizer as ST + vocab = json.loads((tpath / "vocab.json").read_text(encoding="utf-8")) + tokens = [None] * len(vocab) + for t, i in vocab.items(): + tokens[i] = t + merges = (tpath / "merges.txt").read_text(encoding="utf-8").split("\n") + types = json.loads((tpath / "token_type.json").read_text()) + tok = ST.Tokenizer(tokens, merges, types) + if a.engine == "strata": + if not cfg: + ap.error("--engine strata needs --config") + vision = None + env = child_env(cfg) + if cfg.get("vision"): + print("loading the vision encoder ...", flush=True) + vision = Vision(cfg["vision"], log=open(cfg["log"], "a", encoding="utf-8") if cfg.get("log") else None, + env=env) + print("loading the model (the first start takes a minute or two) ...", flush=True) + engine = StrataEngine(cfg["exe"], cfg["args"], cwd=cfg.get("cwd"), log=cfg.get("log"), env=env) + else: + engine, vision = MockEngine(tok, a.script), None + svc = Service(engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"), + model_name=cfg.get("model_name", "qwen3.8-flash-next"), vision=vision) + svc.api_key = a.api_key or cfg.get("api_key", "") + httpd = serve(svc, host=a.host, port=a.port) + print(f"ready: http://{a.host}:{a.port}/v1 (OpenAI: /v1/chat/completions, Anthropic: /v1/messages, " + f"context {engine.max_context} tokens{', images on' if vision else ''}" + f"{', API key required' if svc.api_key else ''})", flush=True) + print(f" open http://{a.host}:{a.port}/ in a browser to chat; close this window to stop the model", flush=True) + if a.open: + import webbrowser + webbrowser.open(f"http://{'127.0.0.1' if a.host in ('0.0.0.0', '') else a.host}:{a.port}/") + try: + threading.Event().wait() + except KeyboardInterrupt: + httpd.shutdown() + if hasattr(engine, "close"): + engine.close() + if vision: + vision.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..b91a2c37 --- /dev/null +++ b/setup.py @@ -0,0 +1,806 @@ +#!/usr/bin/env python3 +"""Strata one-click setup and start (Windows and Linux, NVIDIA GPUs). + + START-HERE.bat (Windows) / ./setup.sh (Linux) - they install Python if needed and run this file + +The first time it asks three questions - which model, how much context, and whether the model should also read +images - then installs everything and starts the model on http://127.0.0.1:8080 (OpenAI- and Anthropic-compatible +API; a small page there shows that it runs). Every later start skips straight to running the model: nothing that +is already downloaded, installed or prepared is done again. + +What the first run does (each step is skipped when it is already done): + + 1. checks your PC: NVIDIA GPU and driver, RAM, CPU, free disk space + 2. asks the three questions + 3. installs the Python packages it needs into .venv (numpy, jinja2, ..., and NVIDIA's CUDA libraries) + 4. gets the Strata engine: a ready-made build for RTX 30/40/50 cards (no compiler needed); if none fits your PC, + it installs the build tools (asks first) and compiles the engine for your GPU + 5. downloads the model from Hugging Face (resumable), and the vision encoder if you want images + 6. prepares the model for Strata and fetches the MTP draft layer (~5 GB, from the original Qwen checkpoint) + 7. writes run-.bat / run-.sh and starts the model + +Options: --model Q2_0|IQ2_XS|IQ3_XXS, --context 32768, --vision yes|no|gpu|cpu, --port 8080, --yes (recommended +answers, no questions), --setup (install another model / change settings instead of starting), --no-start, +--models-dir DIR, --gguf-dir DIR (use GGUF files you already have), --build (compile instead of the ready-made +engine), --check (only check this PC). +""" +from __future__ import annotations + +import argparse +import ctypes +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import time +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +WIN = os.name == "nt" +HF = "https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF/resolve/main/" +LLAMA_CPP_COMMIT = "3cf03257f219afbe7334045ff7c6a06ac68c627d" +LLAMA_CPP_ZIP = f"https://github.com/ggml-org/llama.cpp/archive/{LLAMA_CPP_COMMIT}.zip" + +# The ready-made engine: , a zip with strata(.exe), strata-vision(.exe) and BUILD.json, built +# by tools/make_release.py. Set this to the GitHub release download folder when publishing, e.g. +# "https://github.com//Strata/releases/latest/download/" (or pass --prebuilt / set STRATA_PREBUILT_URL). +PREBUILT_URL = "https://github.com/Niko1221/Strata/releases/latest/download/" +PREBUILT_ASSET = "strata-windows-x64.zip" if WIN else "strata-linux-x64.zip" +# the CUDA libraries the ready-made engine loads (the same CUDA 13.0 it is built with), from NVIDIA's pip packages +CUDA_WHEELS = ["nvidia-cublas==13.0.2.14", "nvidia-cuda-runtime==13.0.96"] +MIN_DRIVER = 580 # CUDA 13.0 +PY_PACKAGES = ["numpy", "jinja2", "regex", "pyyaml", "tqdm", "requests", "cmake", "ninja"] + +MODELS = { + "Q2_0": {"about": "2-bit, the fastest", "download_gb": 66.4, "ram_gb": 48, "arena_gb": 34.0}, + "IQ2_XS": {"about": "2-bit i-quant, a little better quality, close in speed", "download_gb": 68.0, "ram_gb": 48, + "arena_gb": 35.5}, + "IQ3_XXS": {"about": "3-bit i-quant, the best quality, slower (more CPU work per token)", "download_gb": 75.8, + "ram_gb": 60, "arena_gb": 42.9}, +} +CONTEXTS = [8192, 32768, 65536, 131072, 262144] +MMPROJ = "mmproj-Qwen3.8-Flash-Next-BF16.gguf" +# the image encoder on the GPU: ~0.9 GB of weights + ~0.3 GB of work buffers at 1024 image tokens, kept free of +# expert slots (the engine's default reserve is 700 MiB) +VISION = {"gpu": {"max_tokens": 1024, "reserve_mib": 2100}, + "cpu": {"max_tokens": 300, "reserve_mib": 700}} +EXE = "strata.exe" if WIN else "strata" +VEXE = "strata-vision.exe" if WIN else "strata-vision" + + +# ------------------------------------------------------------------------------------------------ output +def say(msg=""): + print(msg, flush=True) + + +def step(n, title): + say() + say(f"=== Step {n}: {title} ===") + + +def ok(msg): + say(f" [ok] {msg}") + + +def warn(msg): + say(f" [!] {msg}") + + +def fail(msg, hint=None): + say(f"\n [X] {msg}") + if hint: + say(f" {hint}") + say("\nSetup stopped. Fix the item above and run it again - everything already done is kept and skipped.") + sys.exit(1) + + +def ask(question, choices, default, yes): + if yes: + return default + while True: + try: + a = input(f"{question} [{default}]: ").strip() + except EOFError: + return default + if not a: + return default + if a.lower() in [c.lower() for c in choices]: + return next(c for c in choices if c.lower() == a.lower()) + say(f" please answer one of: {', '.join(choices)}") + + +def run(cmd, cwd=None, env=None, check=True, quiet=False): + say(" > " + " ".join(str(c) for c in cmd)) + r = subprocess.run([str(c) for c in cmd], cwd=cwd, env=env, + stdout=subprocess.PIPE if quiet else None, stderr=subprocess.STDOUT if quiet else None, + text=True) + if check and r.returncode != 0: + if quiet and r.stdout: + say(r.stdout[-4000:]) + fail(f"command failed (exit {r.returncode}): {Path(str(cmd[0])).name}") + return r + + +def out(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60).stdout + except (OSError, subprocess.TimeoutExpired): + return "" + + +def done(path: Path) -> bool: + """A step's finish mark: .done exists (written only after the step completed).""" + return path.with_name(path.name + ".done").exists() + + +def mark(path: Path, text=""): + path.with_name(path.name + ".done").write_text(text or time.strftime("%Y-%m-%d %H:%M"), encoding="utf-8") + + +# ------------------------------------------------------------------------------------------------ the PC +def ram_gb(): + if WIN: + class MS(ctypes.Structure): + _fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong)] + m = MS() + m.dwLength = ctypes.sizeof(MS) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m)) + return m.ullTotalPhys / 2**30 + for line in open("/proc/meminfo"): + if line.startswith("MemTotal"): + return int(line.split()[1]) * 1024 / 2**30 + return 0.0 + + +def cpu_info(): + """(name, avx2, avx512): avx512 means everything Strata's fast AVX-512 kernels use (F, BW, VL, VNNI, VBMI), + the same test the engine makes (cpu_avx512_ok), not just AVX-512F.""" + name, avx2, avx512 = platform.processor() or "unknown CPU", False, False + if WIN: + pf = ctypes.windll.kernel32.IsProcessorFeaturePresent + avx2 = bool(pf(40)) # PF_AVX2_INSTRUCTIONS_AVAILABLE + n = out(["powershell", "-NoProfile", "-Command", "(Get-CimInstance Win32_Processor).Name"]).strip() + name = n or name + avx512 = bool(pf(41)) and _cpuid_avx512_full() + else: + try: + txt = open("/proc/cpuinfo").read() + flags = set(re.search(r"^flags\s*:\s*(.*)$", txt, re.M).group(1).split()) + avx2 = "avx2" in flags + avx512 = {"avx512f", "avx512bw", "avx512vl", "avx512_vnni", "avx512vbmi"} <= flags + m = re.search(r"^model name\s*:\s*(.*)$", txt, re.M) + name = m.group(1) if m else name + except OSError: + pass + return name, avx2, avx512 + + +def _cpuid_avx512_full() -> bool: + """Windows has no feature bit for VNNI / VBMI: ask the CPU (CPUID leaf 7) through a tiny machine-code stub.""" + try: + code = bytes([0x53, 0x49, 0x89, 0xC8, 0xB8, 0x07, 0x00, 0x00, 0x00, 0x31, 0xC9, 0x0F, 0xA2, # push rbx; r8=rcx; cpuid(7,0) + 0x41, 0x89, 0x18, 0x41, 0x89, 0x48, 0x04, 0x5B, 0xC3]) # [r8]=ebx,[r8+4]=ecx; pop rbx + k32 = ctypes.windll.kernel32 + k32.VirtualAlloc.restype = ctypes.c_void_p + buf = k32.VirtualAlloc(None, len(code), 0x3000, 0x40) + if not buf: + return False + ctypes.memmove(buf, code, len(code)) + regs = (ctypes.c_uint32 * 2)() + ctypes.CFUNCTYPE(None, ctypes.c_void_p)(buf)(ctypes.addressof(regs)) + ebx, ecx = regs[0], regs[1] + need_ebx = (1 << 16) | (1 << 30) | (1 << 31) # F, BW, VL + need_ecx = (1 << 1) | (1 << 11) # VBMI, VNNI + return (ebx & need_ebx) == need_ebx and (ecx & need_ecx) == need_ecx + except Exception: + return False + + +def gpu_info(): + s = out(["nvidia-smi", "--query-gpu=name,memory.total,compute_cap,driver_version", "--format=csv,noheader,nounits"]) + if not s.strip(): + return None + name, mem, cc, drv = [x.strip() for x in s.strip().splitlines()[0].split(",")] + return {"name": name, "vram_gb": float(mem) / 1024.0, "arch": cc.replace(".", ""), "driver": drv} + + +def find_nvcc(): + cands = [shutil.which("nvcc")] + if os.environ.get("CUDA_PATH"): + cands.append(str(Path(os.environ["CUDA_PATH"]) / "bin" / ("nvcc.exe" if WIN else "nvcc"))) + if WIN: + base = Path(r"C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA") + if base.exists(): + cands += [str(p / "bin" / "nvcc.exe") for p in sorted(base.iterdir(), reverse=True)] + else: + cands += [str(p / "bin" / "nvcc") for p in sorted(Path("/usr/local").glob("cuda*"), reverse=True)] + best = (None, None) + for c in dict.fromkeys(cands): # every toolkit found; the newest wins + if c and Path(c).exists(): + v = re.search(r"release (\d+)\.(\d+)", out([c, "--version"])) + if v and (best[1] is None or (int(v.group(1)), int(v.group(2))) > best[1]): + best = (c, (int(v.group(1)), int(v.group(2)))) + return best + + +def find_vcvars(): + vswhere = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "Microsoft Visual Studio/Installer/vswhere.exe" + if not vswhere.exists(): + return None + p = out([str(vswhere), "-latest", "-products", "*", "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"]).strip() + v = Path(p) / "VC/Auxiliary/Build/vcvars64.bat" if p else None + return v if v and v.exists() else None + + +def find_tool(name): + """A tool on PATH, or the one pip installed next to this Python (cmake, ninja).""" + p = shutil.which(name) + if p: + return p + for d in (Path(sys.executable).parent / "Scripts", Path(sys.executable).parent, + Path.home() / ".local" / "bin"): + c = d / (name + (".exe" if WIN else "")) + if c.exists(): + return str(c) + return None + + +def free_gb(path): + path.mkdir(parents=True, exist_ok=True) + return shutil.disk_usage(path).free / 1e9 + + +# ------------------------------------------------------------------------------------------------ downloads +def download(url, dst: Path, what=None): + """Resumable HTTP(S) download with a progress line; `file://` and plain paths are copied (tests, mirrors). + A finished file gets a .done mark, so a later run skips it without asking the server.""" + dst.parent.mkdir(parents=True, exist_ok=True) + if dst.exists() and done(dst): + ok(f"{what or dst.name} already downloaded") + return + if not url.startswith(("http://", "https://")): + src = Path(url[7:] if url.startswith("file://") else url) + if not src.exists(): + fail(f"not found: {src}") + shutil.copyfile(src, dst) + mark(dst) + ok(f"{what or dst.name} copied") + return + part = dst.with_name(dst.name + ".part") + total = 0 + for attempt in range(5): + try: + req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "strata-setup"}) + total = int(urllib.request.urlopen(req, timeout=60).headers.get("Content-Length", 0)) + break + except OSError as e: + if attempt == 4: + fail(f"cannot reach {url.split('/')[2]} ({e})", "check your internet connection and run it again") + time.sleep(5) + if dst.exists() and total and dst.stat().st_size == total: # finished by an older setup (no mark yet) + mark(dst) + ok(f"{what or dst.name} already downloaded") + return + have = part.stat().st_size if part.exists() else 0 + for attempt in range(30): + try: + req = urllib.request.Request(url, headers={"User-Agent": "strata-setup", "Range": f"bytes={have}-"}) + with urllib.request.urlopen(req, timeout=60) as r, open(part, "ab" if have else "wb") as f: + if have and r.status != 206: # the server ignored the range: start over + f.seek(0) + f.truncate() + have = 0 + last = 0.0 + while True: + b = r.read(8 << 20) + if not b: + break + f.write(b) + have += len(b) + if time.time() - last > 2: + last = time.time() + size = f"{have / 1e9:6.2f} / {total / 1e9:.2f} GB ({100 * have / total:.0f}%)" if total \ + else f"{have / 1e6:7.1f} MB" + print(f"\r {what or dst.name}: {size} ", end="", flush=True) + print() + if not total or have >= total: + break + except OSError as e: + print() + warn(f"download interrupted ({e}); retrying in 10 s ...") + time.sleep(10) + if total and part.stat().st_size != total: + fail(f"could not finish downloading {dst.name}", "check your internet connection and run it again") + part.replace(dst) + mark(dst) + ok(f"{what or dst.name} downloaded") + + +def get_llama_cpp(): + """llama.cpp at the pinned commit (ggml for the build, gguf-py for the tools, mtmd for images), as a zip: no git.""" + llama = ROOT / "third_party" / "llama.cpp" + if (llama / "ggml" / "CMakeLists.txt").exists() and (llama / "gguf-py").is_dir(): + return llama + z = ROOT / "third_party" / f"llama.cpp-{LLAMA_CPP_COMMIT[:7]}.zip" + download(LLAMA_CPP_ZIP, z, "llama.cpp source") + tmp = ROOT / "third_party" / "_unpack" + shutil.rmtree(tmp, ignore_errors=True) + with zipfile.ZipFile(z) as f: + f.extractall(tmp) + top = next(tmp.iterdir()) + shutil.rmtree(llama, ignore_errors=True) + top.replace(llama) + shutil.rmtree(tmp, ignore_errors=True) + z.unlink(missing_ok=True) + z.with_name(z.name + ".done").unlink(missing_ok=True) + return llama + + +def pip_install(packages, what): + """pip install into .venv, skipped when the same list was installed before.""" + stamp = Path(sys.prefix) / ".strata-pip.json" + have = json.loads(stamp.read_text()) if stamp.exists() else [] + need = [p for p in packages if p not in have] + if not need: + ok(f"{what} already installed") + return + say(f" Installing {what} ...") + run([sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check", *need]) + stamp.write_text(json.dumps(sorted(set(have) | set(need)), indent=0)) + ok(f"{what} installed") + + +def cuda_lib_dirs(): + """Where pip put NVIDIA's CUDA libraries (nvidia/cu13/bin/x86_64 on Windows, nvidia/cu13/lib on Linux).""" + pattern = "cublas64_13.dll" if WIN else "libcublas.so.13*" + dirs = [] + for sp in {Path(p) for p in sys.path if p.endswith("site-packages")}: + for hit in (sp / "nvidia").rglob(pattern) if (sp / "nvidia").is_dir() else []: + if hit.parent not in dirs: + dirs.append(hit.parent) + return [str(d) for d in dirs] + + +# ------------------------------------------------------------------------------------------------ the engine +def driver_major(gpu): + try: + return int(gpu["driver"].split(".")[0]) + except (ValueError, KeyError): + return 0 + + +def get_prebuilt(url_base, gpu, vision) -> Path | None: + """The ready-made engine in engine/ (kept between runs), or None when there is none for this PC.""" + eng = ROOT / "engine" + info = eng / "BUILD.json" + if info.exists() and (eng / EXE).exists(): + ok("ready-made engine already installed") + return eng + if not url_base: + return None + z = ROOT / "engine" / PREBUILT_ASSET + base = url_base if url_base.endswith(("/", "\\")) else url_base + "/" + if base.startswith(("http://", "https://")): + try: # not published (yet), or no internet: compile instead + req = urllib.request.Request(base + PREBUILT_ASSET, method="HEAD", headers={"User-Agent": "strata-setup"}) + urllib.request.urlopen(req, timeout=60).close() + except OSError as e: + warn(f"no ready-made engine at {base} ({e}): compiling instead") + return None + say(" Downloading the ready-made Strata engine ...") + download(base + PREBUILT_ASSET, z, "Strata engine") + tmp = ROOT / "engine" / "_unpack" + shutil.rmtree(tmp, ignore_errors=True) + with zipfile.ZipFile(z) as f: + f.extractall(tmp) + meta = json.loads((tmp / "BUILD.json").read_text()) + archs = [int(a) for a in meta.get("archs", [])] + arch = int(gpu["arch"]) + if arch not in archs and not (meta.get("ptx") and arch > max(archs)): + warn(f"the ready-made engine is built for {', '.join(str(a) for a in archs)}; your GPU is {arch}: compiling instead") + shutil.rmtree(tmp, ignore_errors=True) + return None + for p in tmp.iterdir(): + dst = eng / p.name + if dst.exists(): + shutil.rmtree(dst) if dst.is_dir() else dst.unlink() + p.replace(dst) + shutil.rmtree(tmp, ignore_errors=True) + z.unlink(missing_ok=True) + z.with_name(z.name + ".done").unlink(missing_ok=True) + if not (eng / EXE).exists(): + fail("the ready-made engine archive has no " + EXE) + if not WIN: + for x in (EXE, VEXE): + if (eng / x).exists(): + (eng / x).chmod(0o755) + ok(f"ready-made engine {meta.get('version', '')} for {', '.join('sm_' + str(a) for a in archs)} (CUDA " + f"{meta.get('cuda', '?')})") + return eng + + +def install_build_tools(gpu, yes): + """The compiler and the CUDA toolkit, installed for the user (asks once). Returns (nvcc, vcvars).""" + nvcc, cuda_v = find_nvcc() + need_cuda = (12, 8) if int(gpu["arch"]) >= 120 else (12, 0) + vcvars = find_vcvars() if WIN else None + have_cc = vcvars is not None if WIN else shutil.which("g++") is not None + missing = [] + if not have_cc: + missing.append("Visual Studio 2022 Build Tools (C++)" if WIN else "the C++ compiler (build-essential)") + if nvcc is None or cuda_v < need_cuda: + missing.append("the NVIDIA CUDA Toolkit 13.0") + if not missing: + ok(f"build tools present (CUDA {cuda_v[0]}.{cuda_v[1]})") + return nvcc, vcvars + say(" The engine has to be compiled for your PC, which needs: " + " and ".join(missing) + ".") + say(" They can be installed now (about 8-10 GB, 15-40 minutes" + (", Windows will ask for permission" if WIN else + ", sudo will ask for your password") + ").") + if ask(" Install them now?", ["y", "n"], "y", yes) != "y": + fail("the build tools are needed", "install them yourself (see README.md) and run it again") + if WIN: + if shutil.which("winget") is None: + fail("winget (Windows package manager) is not available", + "install 'App Installer' from the Microsoft Store, or install the tools by hand (README.md)") + wg = ["winget", "install", "-e", "--source", "winget", "--accept-package-agreements", + "--accept-source-agreements", "--disable-interactivity"] + if not have_cc: + run([*wg, "--id", "Microsoft.VisualStudio.2022.BuildTools", "--override", + "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"], + check=False) + if nvcc is None or cuda_v < need_cuda: + run([*wg, "--id", "Nvidia.CUDA", "--version", "13.0"], check=False) + vcvars = find_vcvars() + else: + apt = shutil.which("apt-get") + if apt is None: + fail("automatic install is only done on Ubuntu/Debian", + "install g++ and the CUDA Toolkit 13 (https://developer.nvidia.com/cuda-downloads), then run it again") + if not have_cc: + run(["sudo", "apt-get", "install", "-y", "build-essential"]) + if nvcc is None or cuda_v < need_cuda: + osr = dict(line.split("=", 1) for line in open("/etc/os-release").read().splitlines() if "=" in line) + ver = osr.get("VERSION_ID", "").strip('"').replace(".", "") + if osr.get("ID") != "ubuntu" or ver not in ("2204", "2404"): + fail("the CUDA Toolkit can be installed automatically on Ubuntu 22.04 / 24.04 only", + "install it from https://developer.nvidia.com/cuda-downloads and run it again") + deb = Path("/tmp/cuda-keyring.deb") + download(f"https://developer.download.nvidia.com/compute/cuda/repos/ubuntu{ver}/x86_64/cuda-keyring_1.1-1_all.deb", + deb, "CUDA repository key") + run(["sudo", "dpkg", "-i", str(deb)]) + run(["sudo", "apt-get", "update"]) + run(["sudo", "apt-get", "install", "-y", "cuda-toolkit-13-0"]) + nvcc, cuda_v = find_nvcc() + if (WIN and find_vcvars() is None) or (not WIN and shutil.which("g++") is None): + fail("the C++ build tools did not install", "install them by hand (README.md) and run it again") + if nvcc is None or cuda_v < need_cuda: + fail("the CUDA Toolkit did not install", "install it from https://developer.nvidia.com/cuda-downloads, then run it again") + ok(f"build tools installed (CUDA {cuda_v[0]}.{cuda_v[1]})") + return nvcc, find_vcvars() if WIN else None + + +def cmake_build(src, bdir, target, defs, vcvars, bat_name): + cmake, ninja = find_tool("cmake"), find_tool("ninja") + if cmake is None or ninja is None: + fail("cmake / ninja not found after installing them", "run: .venv python -m pip install cmake ninja") + conf = [cmake, "-G", "Ninja", f"-DCMAKE_MAKE_PROGRAM={ninja}", "-S", str(src), "-B", str(bdir), + "-DCMAKE_BUILD_TYPE=Release", *defs] + build = [cmake, "--build", str(bdir), "--target", target, "-j", str(max(2, (os.cpu_count() or 4) // 2))] + if WIN: + bat = ROOT / bat_name + q = lambda c: " ".join(f'"{x}"' if " " in str(x) else str(x) for x in c) # noqa: E731 + bat.write_text(f'@echo off\r\ncall "{vcvars}" >nul\r\n{q(conf)} || exit /b 1\r\n{q(build)} || exit /b 1\r\n', + encoding="utf-8") + run(["cmd", "/c", str(bat)]) + else: + run(conf) + run(build) + + +def build_engine(gpu, vision, yes, llama) -> Path: + """Compile the engine (and, for images, the encoder) for this GPU; the results go to engine/.""" + eng = ROOT / "engine" + eng.mkdir(exist_ok=True) + stamp = eng / "BUILD.json" + meta = json.loads(stamp.read_text()) if stamp.exists() else {} + want_vision = vision != "none" + if meta.get("source") == "local" and (eng / EXE).exists() and (not want_vision or (eng / VEXE).exists()): + ok("engine already built for this PC") + return eng + nvcc, vcvars = install_build_tools(gpu, yes) + if not (eng / EXE).exists() or meta.get("source") != "local": + say(" Compiling the Strata engine for your GPU (10-20 minutes, once) ...") + cmake_build(ROOT, ROOT / "build", "strata", + ["-DSTRATA_ENABLE_CUDA=ON", "-DSTRATA_BUILD_TESTS=OFF", f"-DCMAKE_CUDA_ARCHITECTURES={gpu['arch']}", + f"-DCMAKE_CUDA_COMPILER={nvcc}", f"-DSTRATA_GGML_DIR={llama}"], vcvars, "build-strata.bat") + shutil.copy2(ROOT / "build" / EXE, eng / EXE) + if want_vision and not (eng / VEXE).exists(): + say(" Compiling the image encoder" + (" with CUDA (10-20 minutes, once) ..." if vision == "gpu" else " ...")) + defs = [f"-DLLAMA_DIR={llama}", f"-DSTRATA_VISION_CUDA={'ON' if vision == 'gpu' else 'OFF'}"] + if vision == "gpu": + defs += [f"-DCMAKE_CUDA_ARCHITECTURES={gpu['arch']}", f"-DCMAKE_CUDA_COMPILER={nvcc}"] + cmake_build(ROOT / "tools" / "vision", ROOT / "build-vision", "strata-vision", defs, vcvars, "build-vision.bat") + shutil.copy2(ROOT / "build-vision" / "bin" / VEXE, eng / VEXE) + bindir = Path(nvcc).parent # the toolkit's own libraries (bin, bin/x64, lib64) + dirs = [str(d) for d in (bindir, bindir / "x64", bindir.parent / "lib64") if d.is_dir()] + stamp.write_text(json.dumps({"source": "local", "archs": [int(gpu["arch"])], "vision": vision, + "cuda_dirs": dirs}, indent=1)) + ok(f"engine compiled: {eng / EXE}") + return eng + + +# ------------------------------------------------------------------------------------------------ start +def installed_configs(): + return sorted(ROOT.glob("strata-*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + + +def start(cfg_path: Path, port: int | None, open_browser=True) -> int: + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + missing = [p for p in [cfg["exe"], *[a for a in cfg["args"] if a.endswith(".gguf")]] if not Path(p).exists()] + if missing: + fail(f"{cfg_path.name} refers to missing files: {missing[0]}", "run it again with --setup to repair") + cfg_path.touch() # the most recently used model + cmd = [sys.executable, str(ROOT / "serve" / "server.py"), "--engine", "strata", "--config", str(cfg_path), + "--port", str(port or cfg.get("port", 8080))] + if open_browser: + cmd.append("--open") + say() + say(f"Starting {cfg.get('model_name', 'the model')} (loads 34-43 GB into RAM: 30-90 s). Close this window to stop it.") + return subprocess.call(cmd) + + +def write_run_script(model, cfg_path, port): + serve = [sys.executable, str(ROOT / "serve" / "server.py"), "--engine", "strata", "--config", str(cfg_path), + "--port", str(port), "--open"] + if WIN: + script = ROOT / f"run-{model.lower()}.bat" + script.write_text("@echo off\r\ntitle Strata " + model + "\r\ncd /d \"" + str(ROOT) + "\"\r\n" + + " ".join(f'"{x}"' for x in serve) + "\r\npause\r\n", encoding="utf-8") + else: + script = ROOT / f"run-{model.lower()}.sh" + script.write_text("#!/bin/sh\ncd \"" + str(ROOT) + "\"\nexec " + " ".join(f'"{x}"' for x in serve) + "\n", + encoding="utf-8") + script.chmod(0o755) + return script + + +# ------------------------------------------------------------------------------------------------ main +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--model", choices=list(MODELS)) + ap.add_argument("--context", type=int) + ap.add_argument("--vision", choices=["yes", "no", "none", "gpu", "cpu"], + help="let the model read images (yes = the encoder on the GPU)") + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--models-dir", default=str(ROOT / "models"), help="where the GGUF files go (~70 GB)") + ap.add_argument("--gguf-dir", help="use GGUF files you already have (a folder with the two shards)") + ap.add_argument("--yes", action="store_true", help="accept the recommended answers") + ap.add_argument("--setup", action="store_true", help="install another model or change settings") + ap.add_argument("--no-start", action="store_true", help="install only, do not start the model") + ap.add_argument("--build", action="store_true", help="compile the engine instead of using the ready-made one") + ap.add_argument("--prebuilt", default=os.environ.get("STRATA_PREBUILT_URL", PREBUILT_URL), + help="where the ready-made engine is (a URL folder or a local folder)") + ap.add_argument("--check", action="store_true", help="only check this PC and exit") + ap.add_argument("--skip-build", action="store_true", help=argparse.SUPPRESS) + a = ap.parse_args() + + say("Strata - Qwen3.8-Flash-Next on a normal PC (NVIDIA GPU + system RAM + CPU)") + + # ---- 0. already installed: just start it + have = installed_configs() + if have and not (a.setup or a.model or a.check or a.no_start): + if len(have) == 1: + return start(have[0], None) + say() + for i, c in enumerate(have, 1): + say(f" {i}) {c.stem[len('strata-'):].upper()}") + say(f" {len(have) + 1}) install another model / change settings") + pick = int(ask("Which one?", [str(i) for i in range(1, len(have) + 2)], "1", a.yes)) + if pick <= len(have): + return start(have[pick - 1], None) + + # ---- 1. the PC + step(1, "checking your PC") + gpu = gpu_info() + if gpu is None: + fail("no NVIDIA GPU found (nvidia-smi did not answer)", + "install the NVIDIA driver from https://www.nvidia.com/drivers and restart the PC") + ok(f"GPU: {gpu['name']}, {gpu['vram_gb']:.1f} GB VRAM, compute capability {gpu['arch'][:-1]}.{gpu['arch'][-1]}, " + f"driver {gpu['driver']}") + if int(gpu["arch"]) < 80: + fail("this GPU is older than the RTX 30 series (compute capability 8.0 is required)") + if driver_major(gpu) < MIN_DRIVER: + fail(f"the NVIDIA driver is too old ({gpu['driver']}; {MIN_DRIVER} or newer is needed)", + "update it with the NVIDIA App or from https://www.nvidia.com/drivers, restart, and run this again") + if gpu["vram_gb"] < 11: + warn("less than 12 GB of VRAM: Strata will run, but most experts stay on the CPU and it will be slow") + ram = ram_gb() + cpu, avx2, avx512 = cpu_info() + ok(f"RAM: {ram:.0f} GB") + ok(f"CPU: {cpu} ({'AVX-512' if avx512 else 'AVX2' if avx2 else 'no AVX2'})") + if not avx2: + fail("this CPU has no AVX2; Strata needs at least AVX2") + if a.check: + say() + for m, d in MODELS.items(): + verdict = "fits" if ram >= d["ram_gb"] else "tight" if ram >= d["ram_gb"] - 8 else "does not fit" + say(f" {m:8s} needs ~{d['ram_gb']} GB RAM: {verdict}") + say("\nThis PC can run Strata. Run it again without --check to install.") + return 0 + + # ---- 2. the questions + step(2, "your choices") + names = list(MODELS) + for i, m in enumerate(names, 1): + d = MODELS[m] + fit = "" if ram >= d["ram_gb"] else f" <- needs {d['ram_gb']} GB RAM, you have {ram:.0f}" + say(f" {i}) {m:8s} {d['about']}; download {d['download_gb']:.0f} GB, uses ~{d['arena_gb']:.0f} GB of RAM{fit}") + rec = "3" if ram >= 60 else "1" + model = a.model or names[int(ask("Which model?", ["1", "2", "3"], rec, a.yes)) - 1] + if ram < MODELS[model]["ram_gb"] - 4: + fail(f"{model} needs about {MODELS[model]['ram_gb']} GB of RAM; this PC has {ram:.0f} GB", + "choose Q2_0 or IQ2_XS, or add RAM") + ok(f"model: {model}") + rec_ctx = 32768 if gpu["vram_gb"] < 14 else 65536 if gpu["vram_gb"] < 20 else 131072 + if a.context: + ctx = a.context + else: + say() + say(" Context length = how much text the model can see at once (your chat, files, tool output).") + say(" Longer needs more VRAM for it, so fewer experts fit on the GPU:") + for i, c in enumerate(CONTEXTS, 1): + say(f" {i}) {c // 1024}K tokens" + (" (recommended for your GPU)" if c == rec_ctx else "")) + ctx = CONTEXTS[int(ask("Context?", [str(i) for i in range(1, 6)], str(CONTEXTS.index(rec_ctx) + 1), a.yes)) - 1] + if model == "IQ3_XXS" and ram < 90 and ctx > 131072: + warn("IQ3_XXS with a 262K context needs more than 64 GB of RAM (43 GB of experts + the context): using 128K") + ctx = 131072 + ok(f"context: {ctx} tokens") + if a.vision: + vision = {"yes": "gpu", "no": "none"}.get(a.vision, a.vision) + else: + say() + say(" Images: the model can also read pictures (screenshots, photos, scanned pages). This adds a 0.9 GB") + say(" download and keeps ~1.4 GB of VRAM free for the image encoder, so text is a few % slower.") + vision = "gpu" if ask("Do you want images?", ["y", "n"], "n", a.yes) == "y" else "none" + ok("images: " + {"none": "off", "gpu": "on", "cpu": "on (encoder on the CPU)"}[vision]) + models_dir = Path(a.gguf_dir) if a.gguf_dir else Path(a.models_dir) / model + shards = [models_dir / f"Qwen3.8-Flash-Next-GSQ-RCO-{model}-0000{i}-of-00002.gguf" for i in (1, 2)] + have_model = all(s.exists() and (done(s) or a.gguf_dir) for s in shards) + need = (0 if a.gguf_dir or have_model else MODELS[model]["download_gb"]) + 8 + \ + (40 if model == "Q2_0" and avx512 else 0) + (1 if vision != "none" else 0) + if free_gb(models_dir) < need: + fail(f"not enough free disk space in {models_dir}: need ~{need:.0f} GB", "use --models-dir on a bigger drive") + + # ---- 3. python packages + step(3, "Python packages") + pip_install(PY_PACKAGES, "numpy, jinja2, regex, pyyaml, tqdm, requests, cmake, ninja") + + # ---- 4. the engine + step(4, "the Strata engine") + llama = get_llama_cpp() + ok(f"llama.cpp {LLAMA_CPP_COMMIT[:7]} (gguf-py, ggml, mtmd)") + eng = None if a.build else get_prebuilt(a.prebuilt, gpu, vision) + if eng is not None and json.loads((eng / "BUILD.json").read_text()).get("source") != "local": + pip_install(CUDA_WHEELS, "NVIDIA CUDA libraries (cuBLAS, CUDA runtime; ~0.4 GB)") + if vision != "none" and not (eng / VEXE).exists(): + warn("the ready-made engine has no image encoder: compiling it") + eng = None + if eng is None: + eng = build_engine(gpu, vision, a.yes, llama) + meta = json.loads((eng / "BUILD.json").read_text()) + lib_dirs = meta.get("cuda_dirs") or cuda_lib_dirs() + ok(f"engine: {eng / EXE}") + + # ---- 5. the model files + step(5, f"downloading {model}") + if not a.gguf_dir: + for s in shards: + if s.exists() and done(s): + ok(f"{s.name} already downloaded") + continue + # shard 2 is the same file for all three models: reuse one that is already here + other = [p for p in Path(a.models_dir).glob("*/Qwen3.8-Flash-Next-GSQ-RCO-*-00002-of-00002.gguf") if done(p)] + if s.name.endswith("00002-of-00002.gguf") and other and not s.exists(): + try: + os.link(other[0], s) + mark(s) + ok(f"{s.name} shared with {other[0].parent.name} (identical file)") + continue + except OSError: + pass + download(HF + f"{model}/{s.name}", s) + for s in shards: + if not s.exists(): + fail(f"missing {s}") + ok("model files present") + mmproj = Path(a.models_dir) / MMPROJ + if vision != "none": + if not mmproj.exists() and a.gguf_dir and (Path(a.gguf_dir) / MMPROJ).exists(): + mmproj = Path(a.gguf_dir) / MMPROJ + else: + download(HF + MMPROJ, mmproj, "vision encoder") + ok(f"vision encoder: {mmproj}") + + # ---- 6. the pack and the MTP draft layer + step(6, "preparing the model for Strata") + pack = ROOT / "packs" / model.lower() + env = dict(os.environ, STRATA_GGUF_PY=str(llama / "gguf-py")) + if model == "Q2_0" and avx512: + # the Q2_0 experts repacked for the AVX-512 kernel (the measured speed): a one-time ~40 GB conversion + if not (pack / "index.txt").exists() or not (pack / "experts.bin").exists(): # index.txt is written last + say(" Converting the Q2_0 experts for the AVX-512 kernel (one time, ~40 GB written, 2-5 min) ...") + run([sys.executable, str(ROOT / "tools" / "strata_pack.py"), "build", "--gguf", str(shards[0]), + "--out", str(pack), "--skip-hash"], env=env) + run([sys.executable, str(ROOT / "tools" / "pack_index.py"), "--pack", str(pack)], env=env) + if not (pack / "tokenizer" / "vocab.json").exists(): + run([sys.executable, str(ROOT / "tools" / "strata_tokenizer.py"), "--gguf", str(shards[0]), + "--out", str(pack)], env=env) # writes /tokenizer/ + elif not (pack / "native_experts.txt").exists() or not (pack / "tokenizer" / "vocab.json").exists(): + # every tensor as the GGUF stores it; the experts are read from the GGUF at start (seconds to build) + run([sys.executable, str(ROOT / "tools" / "iq_pack.py"), "--gguf", str(shards[0]), "--out", str(pack)], env=env) + ok(f"model prepared: {pack}") + mtp = ROOT / "mtp" + rt = mtp / "rt" + if not (rt / "experts.bin").exists(): + say(" The MTP draft layer (speculative decoding, ~2x faster output) comes from the original Qwen checkpoint:") + say(" only its ~5 GB of MTP tensors are downloaded.") + run([sys.executable, str(ROOT / "tools" / "mtp_fetch.py"), "fetch", "--out", str(mtp)], env=env) + run([sys.executable, str(ROOT / "tools" / "mtp_pack.py"), "--src", str(mtp), "--experts", "q2_0", + "--out", str(mtp / "mtp-q2_0.gguf")], env=env) + run([sys.executable, str(ROOT / "tools" / "mtp_rt.py"), "--gguf", str(mtp / "mtp-q2_0.gguf"), "--out", str(rt)], + env=env) + if not (rt / "draft_vocab.bin").exists(): + shutil.copyfile(ROOT / "data" / "draft_vocab.bin", rt / "draft_vocab.bin") + ok(f"MTP draft layer: {rt}") + + # ---- 7. the start script + step(7, "writing the start script") + args = ["--pack", str(pack), "--native", str(shards[0]), "--ple-gguf", str(shards[1]), + "--expert-profile", str(ROOT / "data" / "expert-profile.bin"), "--expert-cache", "auto", + "--prefill", "2048", "--spec", "4", "--spec-min-p", "0.5", "--mtp", str(rt), + "--max-context", str(ctx)] + if ctx > 8192: + args += ["--kv", "int8"] + if vision != "none": + args += ["--vision", "--vram-reserve-mib", str(VISION[vision]["reserve_mib"])] + cfg = {"exe": str(eng / EXE), "args": args, "cwd": str(ROOT), "tokenizer": str(pack / "tokenizer"), + "model_name": f"qwen3.8-flash-next-{model.lower()}", "log": str(ROOT / f"strata-{model.lower()}.log"), + "lib_dirs": lib_dirs, "port": a.port} + if vision != "none": + cfg["vision"] = {"exe": str(eng / VEXE), "mmproj": str(mmproj), "model": str(shards[0]), + "gpu": vision == "gpu", "max_tokens": VISION[vision]["max_tokens"]} + if vision == "cpu": + cfg["vision"]["threads"] = max(1, (os.cpu_count() or 8) // 2) + cfg_path = ROOT / f"strata-{model.lower()}.json" + cfg_path.write_text(json.dumps(cfg, indent=1), encoding="utf-8") + script = write_run_script(model, cfg_path, a.port) + ok(f"start script: {script.name}") + + say() + say("All set.") + say(f" API (OpenAI): http://127.0.0.1:{a.port}/v1 (any API key; model name: anything)") + say(f" API (Anthropic): http://127.0.0.1:{a.port}/v1/messages") + say(f" Next time: just run {'START-HERE.bat' if WIN else './setup.sh'} (or {script.name}) - it starts right away") + if vision != "none": + say(" Images: send them in the chat page, in chat.py (/image ) or over the API") + if a.no_start: + return 0 + return start(cfg_path, a.port) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + say("\nstopped.") + sys.exit(1) diff --git a/setup.sh b/setup.sh new file mode 100644 index 00000000..5ba074ce --- /dev/null +++ b/setup.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Strata for Linux: the first run installs everything and starts the model; later runs just start it. +# Needs only an NVIDIA driver. Python (with venv) is installed through apt/dnf if it is missing (asks for sudo). +cd "$(dirname "$0")" || exit 1 +if [ ! -x .venv/bin/python ]; then + PY="" + for c in python3 python; do + if command -v $c >/dev/null 2>&1 && $c -c 'import sys, venv; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then + PY=$c; break + fi + done + if [ -z "$PY" ]; then + echo "Python 3.10+ with venv is needed; installing it (sudo will ask for your password) ..." + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y python3 python3-venv python3-pip + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y python3 python3-pip + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -S --noconfirm python python-pip + fi + PY=python3 + if ! $PY -c 'import sys, venv; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then + echo "Please install Python 3.10 or newer (with venv), then run ./setup.sh again." + exit 1 + fi + fi + # a private environment inside this folder (system Python stays untouched; newer distros refuse global pip) + $PY -m venv .venv || { echo "could not create .venv: sudo apt install python3-venv"; exit 1; } +fi +exec .venv/bin/python setup.py "$@" diff --git a/src/artifact/dequant.cpp b/src/artifact/dequant.cpp new file mode 100644 index 00000000..ff058c0a --- /dev/null +++ b/src/artifact/dequant.cpp @@ -0,0 +1,123 @@ +// src/artifact/dequant.cpp - the `strata-dequant` CLI (dequantize a GGUF tensor and dump F32). +// The dequantizers themselves are header-only in include/strata/artifact/dequant.hpp. +#include "strata/artifact/dequant.hpp" + +#ifdef STRATA_DEQUANT_SELFTEST +int main(int argc, char** argv) { + if (argc < 2) { + std::printf("usage: dequant \n"); + return 2; + } + try { + strata::GgufFile g(argv[1]); + int checked = 0, bad = 0; + for (const auto& t : g.tensors()) { + if (t.type != 42) continue; // Q2_0 only for now + if (checked >= 8) break; + const uint8_t* base = g.tensor_data(t); + const uint64_t nblocks = t.elements() / 64; + for (uint64_t b = 0; b < nblocks && b < 64; ++b) { + const uint8_t* blk = base + b * 18; + float out[64]; + strata::dequantize_q2_0(blk, out); + const float d = strata::fp16_to_fp32(strata::read_u16(blk)); + // STRUCTURAL INVARIANT: every value must be exactly one of {-d, 0, d, 2d}. + for (int j = 0; j < 64; ++j) { + const float v = out[j]; + const bool ok = (v == -d) || (v == 0.0f) || (v == d) || (v == 2.0f * d) || (d == 0.0f); + if (!ok) { + ++bad; + if (bad < 4) + std::printf(" VIOLATION %s block %llu elem %d: v=%g d=%g\n", t.name.c_str(), + (unsigned long long)b, j, v, d); + } + } + } + if (checked == 0) { + // A folded checksum over the FIRST 64 blocks, for cross-checking against the Python + // implementation on identical bytes. A single implementation being self-consistent + // proves little - two disagreeing is what finds bugs (round 67). + double sum = 0, sumabs = 0; + const uint8_t* base = g.tensor_data(t); + for (uint64_t b = 0; b < nblocks && b < 64; ++b) { + float out[64]; + strata::dequantize_q2_0(base + b * 18, out); + for (int j = 0; j < 64; ++j) { + sum += out[j]; + sumabs += std::fabs(out[j]); + } + } + std::printf(" CHECKSUM %s sum=%.10g sumabs=%.10g\n", t.name.c_str(), sum, sumabs); + } + ++checked; + } + // Q5_0 cross-check on the same 64-block window gguf-py is given, using the first Q5_0 tensor. + for (const auto& t : g.tensors()) { + if (t.type != 6 && t.type != 20 && t.type != 14 && t.type != 12 && t.type != 13 && t.type != 11 && + t.type != 23) + continue; + const uint8_t* base = g.tensor_data(t); + const int bsz = + (t.type == 20) + ? 18 + : (t.type == 14 + ? 210 + : (t.type == 12 + ? 144 + : (t.type == 13 ? 176 : (t.type == 11 ? 110 : (t.type == 23 ? 136 : 22))))); + const int nel = + (t.type == 14 || t.type == 12 || t.type == 13 || t.type == 11 || t.type == 23) ? 256 : 32; + const uint64_t nb = t.elements() / (uint64_t)nel; + double sum = 0, sumabs = 0; + for (uint64_t b = 0; b < nb && b < 64; ++b) { + float out[256]; + if (t.type == 20) + strata::dequantize_iq4_nl(base + b * bsz, out); + else if (t.type == 14) + strata::dequantize_q6_K(base + b * bsz, out); + else if (t.type == 12) + strata::dequantize_q4_K(base + b * bsz, out); + else if (t.type == 13) + strata::dequantize_q5_K(base + b * bsz, out); + else if (t.type == 11) + strata::dequantize_q3_K(base + b * bsz, out); + else if (t.type == 23) + strata::dequantize_iq4_xs(base + b * bsz, out); + else + strata::dequantize_q5_0(base + b * bsz, out); + for (int j = 0; j < nel; ++j) { + sum += out[j]; + sumabs += std::fabs(out[j]); + } + } + std::printf(" CHECKSUM %s %s sum=%.10g sumabs=%.10g\n", t.type_name(), t.name.c_str(), sum, + sumabs); + float f8[256]; + if (t.type == 20) + strata::dequantize_iq4_nl(base, f8); + else if (t.type == 14) + strata::dequantize_q6_K(base, f8); + else if (t.type == 12) + strata::dequantize_q4_K(base, f8); + else if (t.type == 13) + strata::dequantize_q5_K(base, f8); + else if (t.type == 11) + strata::dequantize_q3_K(base, f8); + else if (t.type == 23) + strata::dequantize_iq4_xs(base, f8); + else + strata::dequantize_q5_0(base, f8); + std::printf(" REF first8:"); + for (int j = 0; j < 8; ++j) std::printf(" %.6g", f8[j]); + std::printf("\n"); + if (t.type == 23) break; // emit Q5_0 first, then IQ4_NL + } + std::printf("%s\n Q2_0 tensors spot-checked: %d\n symbolic invariant violations: %d -> %s\n", + argv[1], checked, bad, bad ? "FAIL" : "PASS"); + return bad ? 1 : 0; + } catch (const std::exception& e) { + std::printf("ERROR: %s\n", e.what()); + return 1; + } +} +#endif diff --git a/src/artifact/gguf_reader.cpp b/src/artifact/gguf_reader.cpp new file mode 100644 index 00000000..5498ecd7 --- /dev/null +++ b/src/artifact/gguf_reader.cpp @@ -0,0 +1,67 @@ +// src/artifact/gguf_reader.cpp - the `strata-gguf` CLI. The reader itself is header-only in +// include/strata/artifact/gguf_reader.hpp; this file is just the entry point, so it cannot drift +// from the library it exercises. +#include "strata/artifact/gguf_reader.hpp" + +#ifndef STRATA_GGUF_MAIN_DISABLED +int main(int argc, char** argv) { + if (argc < 2) { + std::printf("usage: gguf_reader [--check]\n"); + return 2; + } + const bool check = (argc > 2 && std::string(argv[2]) == "--check"); + try { + strata::GgufFile g(argv[1]); + std::printf("%s\n", argv[1]); + std::printf(" version %u tensors %zu metadata %zu data_start %llu size %llu\n", g.version(), + g.tensors().size(), g.metadata().size(), (unsigned long long)g.data_start(), + (unsigned long long)g.file_size()); + + if (check) { + // Independent expectations, all established by earlier phases. + size_t n_blk = 0; + for (const auto& t : g.tensors()) + if (t.name.rfind("blk.", 0) == 0) ++n_blk; + std::printf(" blk.* tensors %zu globals %zu\n", n_blk, g.tensors().size() - n_blk); + std::printf(" Q2_0 %llu BF16 %llu F32 %llu\n", (unsigned long long)g.count_type("Q2_0"), + (unsigned long long)g.count_type("BF16"), (unsigned long long)g.count_type("F32")); + + // Every tensor's byte size must land inside the gap to the next tensor's offset, since + // GGUF aligns every tensor. That is the same bracket test tools/verify_q2_0_geometry.py + // uses, and it validates block_geometry() against the file rather than asserting it. + std::vector ordered; + for (const auto& t : g.tensors()) ordered.push_back(&t); + std::sort(ordered.begin(), ordered.end(), [](auto a, auto b) { return a->offset < b->offset; }); + size_t bad = 0, unknown = 0; + for (size_t i = 0; i < ordered.size(); ++i) { + const auto* t = ordered[i]; + int el = 0, by = 0; + if (!strata::block_geometry(t->type, el, by)) { + ++unknown; + continue; + } + if (t->elements() % (uint64_t)el) { + ++bad; + continue; + } + const uint64_t nb = t->elements() / (uint64_t)el * (uint64_t)by; + // The bracket is on the GAP to the next tensor's offset, not on that offset itself - + // offsets are relative to data_start, so the gap is a difference. Comparing against + // the raw offset flagged almost every tensor (213/214) while the same test in Python + // (tools/verify_q2_0_geometry.py) passed. + const uint64_t end = (i + 1 < ordered.size()) ? ordered[i + 1]->offset - t->offset + : g.file_size() - g.data_start() - t->offset; + if (!(nb <= end && end < nb + 32)) ++bad; + } + std::printf(" geometry bracket: %zu out of range, %zu types unknown\n", bad, unknown); + const std::string err = strata::check_architecture(g); + std::printf(" architecture guard: %s\n", err.empty() ? "PASS" : ("FAIL - " + err).c_str()); + if (bad) return 1; + } + return 0; + } catch (const std::exception& e) { + std::printf("ERROR: %s\n", e.what()); + return 1; + } +} +#endif // STRATA_GGUF_MAIN_DISABLED diff --git a/src/core/concurrent_main.cpp b/src/core/concurrent_main.cpp new file mode 100644 index 00000000..ab5b5adb --- /dev/null +++ b/src/core/concurrent_main.cpp @@ -0,0 +1,197 @@ +// src/core/concurrent_main.cpp - do the CPU expert path and the GPU stream path actually run at once? +// +// L9 concluded that the two miss paths are INDEPENDENT RESOURCES - 42.55 GB/s of DRAM bandwidth for the CPU +// expert kernel and 28.9 GB/s of PCIe for the stream - and that running them together would cover a token's +// 552.7 MB of misses in 7.74 ms, which is what puts 100 tps inside reach on this Gen4 machine. +// +// THAT CLAIM HAS A HOLE. An H2D copy from PINNED host memory READS HOST DRAM to do it, so the bus path is not +// free of the resource the CPU path is bound by. If the two contend, the combined rate is not 42.55 + 28.9 and +// the 129 tok/s figure is optimistic; if they overlap cleanly, it stands. This measures it three ways in one +// process - CPU alone, GPU alone, both - because comparing two separately-measured rates is exactly the +// inference that has been wrong twice already in this ledger (L2's dependency chain, L8's bus-only ceiling). +// +// The CPU side is a READ LOOP over the same pinned arena rather than the real VNNI kernel, and that is +// deliberate: the question is whether the two paths contend for DRAM BANDWIDTH, and a read loop is a pure +// measurement of that demand, with no compute that could mask it. +#include "strata/core/pinned.hpp" +#include "strata/kernels/s_gemv.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +// Volatile sink so the read loop cannot be optimised away; summed across threads and printed, so a compiler +// that removed the loads would produce a suspiciously round zero rather than a plausible number. +std::atomic g_sink{0}; + +struct Run { + double seconds = 0; + double cpu_gb = 0; // GB/s read by the CPU threads + double gpu_experts = 0; // experts streamed and computed by the GPU +}; + +} // namespace + +int main(int argc, char** argv) { + std::string path; + // 4096 planes = 2 GiB, which is 64x the 32 MB L3 on this CPU. At 0.1 GiB the read loop measured 206 GB/s - + // that is CACHE bandwidth, not DRAM, and it would have answered the contention question with a number about + // a different resource entirely. + int experts = 4096; + int cpu_threads = 6; + int gpu_roles = 256; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto val = [&]() -> const char* { + if (i + 1 >= argc) { std::fprintf(stderr, "%s needs a value\n", a.c_str()); std::exit(2); } + return argv[++i]; + }; + if (a == "--file") path = val(); + else if (a == "--experts") experts = std::atoi(val()); + else if (a == "--cpu-threads") cpu_threads = std::atoi(val()); + else if (a == "--gpu-roles") gpu_roles = std::atoi(val()); + else { + std::fprintf(stderr, "usage: strata-concurrent --file experts.bin [--experts N] " + "[--cpu-threads N] [--gpu-roles N]\n"); + return 2; + } + } + if (path.empty()) { std::fprintf(stderr, "--file is required\n"); return 2; } + + const long long n_in = 2560, n_out = 640; + const uint64_t role_codes = (uint64_t) n_out * n_in / 4; + const uint64_t plane_bytes = role_codes; // the CPU loop reads the same planes + + strata::core::PinnedArena arena((uint64_t) experts * role_codes); + if (!arena.valid()) { std::fprintf(stderr, "arena allocation failed\n"); return 1; } + { + std::ifstream f(path, std::ios::binary); + if (!f) { std::fprintf(stderr, "cannot open %s\n", path.c_str()); return 1; } + for (int i = 0; i < experts; ++i) { + f.seekg((std::streamoff) i * 1382400); + f.read((char*) arena.data() + (uint64_t) i * role_codes, (std::streamsize) role_codes); + if ((uint64_t) f.gcount() != role_codes) { std::fprintf(stderr, "short read\n"); return 1; } + } + } + std::printf("arena %.2f GiB pinned; %d CPU threads; %d GPU roles\n", + (double) arena.capacity / (1024.0 * 1024 * 1024), cpu_threads, gpu_roles); + + uint8_t* d_codes = nullptr; + check(cudaMalloc(&d_codes, role_codes), "cudaMalloc codes"); + uint16_t* d_x = nullptr; + float *d_scales = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_x, n_in * sizeof(uint16_t)), "cudaMalloc x"); + check(cudaMalloc(&d_scales, (size_t) n_out * (n_in / 64) * sizeof(float)), "cudaMalloc scales"); + check(cudaMalloc(&d_y, n_out * sizeof(float)), "cudaMalloc y"); + std::vector hx((size_t) n_in, 0x3C00); + std::vector hs((size_t) n_out * (size_t) (n_in / 64), 0.001f); + check(cudaMemcpy(d_x, hx.data(), hx.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "copy x"); + check(cudaMemcpy(d_scales, hs.data(), hs.size() * sizeof(float), cudaMemcpyHostToDevice), "copy scales"); + cudaStream_t s_copy{}, s_comp{}; + check(cudaStreamCreate(&s_copy), "stream copy"); + check(cudaStreamCreate(&s_comp), "stream compute"); + const strata::kernels::SForm form{2, -1, 64, strata::kernels::Codebook::Affine, false}; + + // The CPU loop: read the whole arena, Nthreads ways, and sum bytes so nothing is elided. + auto cpu_work = [&](std::atomic& stop, std::atomic& bytes) { + uint64_t acc = 0, n = 0; + const uint8_t* p = arena.data(); + while (!stop.load(std::memory_order_relaxed)) { + // touch one byte per 64 - a stride that defeats prefetch into a pure streaming read + for (uint64_t i = 0; i < plane_bytes * (uint64_t) experts; i += 64) acc += p[i]; + n += plane_bytes * (uint64_t) experts; + } + bytes.fetch_add(n); + g_sink.fetch_add(acc); + }; + + // The GPU loop: stream each role plane on the copy stream while computing on the compute stream. + auto gpu_work = [&](int roles) { + cudaEvent_t ev[2]; + for (int b = 0; b < 2; ++b) check(cudaEventCreateWithFlags(&ev[b], cudaEventDisableTiming), "event"); + uint8_t* buf[2] = {d_codes, d_codes}; // one buffer is enough: the timing is what is measured + check(cudaMemcpyAsync(buf[0], arena.data(), role_codes, cudaMemcpyHostToDevice, s_copy), "prologue"); + check(cudaEventRecord(ev[0], s_copy), "prologue event"); + for (int i = 0; i < roles; ++i) { + const int cur = i & 1, nxt = (i + 1) & 1; + if (i + 1 < roles) { + check(cudaMemcpyAsync(buf[nxt], arena.data() + (uint64_t) ((i + 1) % experts) * role_codes, + role_codes, cudaMemcpyHostToDevice, s_copy), + "copy"); + check(cudaEventRecord(ev[nxt], s_copy), "event"); + } + check(cudaStreamWaitEvent(s_comp, ev[cur], 0), "wait"); + strata::kernels::s_gemv_split_async(d_x, buf[cur], d_scales, nullptr, d_y, n_in, n_out, form, 32, + (void*) s_comp); + } + check(cudaStreamSynchronize(s_comp), "sync comp"); + check(cudaStreamSynchronize(s_copy), "sync copy"); + for (int b = 0; b < 2; ++b) cudaEventDestroy(ev[b]); + }; + + auto run = [&](bool do_cpu, bool do_gpu, const char* name) { + std::atomic stop{false}; + std::atomic bytes{0}; + std::vector cpu; + check(cudaStreamSynchronize(0), "pre"); + const auto t0 = std::chrono::steady_clock::now(); + if (do_cpu) for (int t = 0; t < cpu_threads; ++t) cpu.emplace_back(cpu_work, std::ref(stop), std::ref(bytes)); + if (do_gpu) { + gpu_work(gpu_roles); + } else { + // CPU-only needs a REAL window. The first version let t0..t1 span nothing when there was no GPU + // work, so the threads were stopped immediately and the run reported 0.000 s and 0.00 GB/s. + std::this_thread::sleep_for(std::chrono::milliseconds(3000)); + } + const auto t1 = std::chrono::steady_clock::now(); + stop = true; + for (auto& t : cpu) t.join(); + Run r; + r.seconds = std::chrono::duration(t1 - t0).count(); + r.cpu_gb = (double) bytes.load() / 1e9 / r.seconds; + r.gpu_experts = do_gpu ? (double) gpu_roles / r.seconds : 0.0; + std::printf(" %-18s %7.3f s CPU read %6.2f GB/s GPU %7.1f roles/s\n", name, r.seconds, r.cpu_gb, + r.gpu_experts); + return r; + }; + + std::printf("\n mode wall CPU read GPU\n"); + const Run cpu_only = run(true, false, "CPU only"); + const Run gpu_only = run(false, true, "GPU only"); + const Run both = run(true, true, "BOTH"); + + std::printf("\n independence check (1.00 = perfectly shared, 0.00 = fully independent):\n"); + const double cpu_interference = cpu_only.cpu_gb > 0 ? 1.0 - both.cpu_gb / cpu_only.cpu_gb : 0.0; + const double gpu_interference = gpu_only.gpu_experts > 0 ? 1.0 - both.gpu_experts / gpu_only.gpu_experts : 0.0; + std::printf(" CPU throughput lost when the GPU also runs : %5.1f%%\n", cpu_interference * 100); + std::printf(" GPU throughput lost when the CPU also runs : %5.1f%%\n", gpu_interference * 100); + std::printf(" combined vs the sum of the parts : CPU %6.2f + GPU-equivalent vs %6.2f measured\n", + cpu_only.cpu_gb, both.cpu_gb); + std::printf("\n NOTE: the CPU side is a READ LOOP, not the VNNI kernel - it measures DRAM DEMAND, which is the\n" + " resource the question is about. L9's 129 tok/s assumes these two do not contend.\n"); + + cudaFree(d_codes); + cudaFree(d_x); + cudaFree(d_scales); + cudaFree(d_y); + cudaStreamDestroy(s_copy); + cudaStreamDestroy(s_comp); + return 0; +} diff --git a/src/core/device.cu b/src/core/device.cu new file mode 100644 index 00000000..9b8a5373 --- /dev/null +++ b/src/core/device.cu @@ -0,0 +1,117 @@ +// src/core/device.cu - P2.S1: the CUDA side of the runtime core. +#include "strata/core/device.hpp" + +#include + +#include +#include + +namespace strata::core { + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + throw CudaError(std::string(what) + ": " + cudaGetErrorString(e), (int) e); + } +} + +// A NaN pattern, not zero. Zeros read from uninitialised memory are indistinguishable from real zeros in a +// dequantized weight or a masked attention score, which is exactly the kind of wrong-but-plausible value the +// Phase 1 harnesses kept catching. +__global__ void poison_kernel(float* p, uint64_t n_floats) { + const uint64_t i = (uint64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i < n_floats) p[i] = __int_as_float(0x7fc00000); +} + +} // namespace + +DeviceInfo device_info(int ordinal) { + int count = 0; + check(cudaGetDeviceCount(&count), "cudaGetDeviceCount"); + if (count == 0) { + throw CudaError("no CUDA device is present; Strata targets sm_120 (RTX 5000 series)", -1); + } + if (ordinal < 0 || ordinal >= count) { + throw CudaError("device ordinal " + std::to_string(ordinal) + " is out of range (have " + + std::to_string(count) + ")", + -1); + } + DeviceInfo d; + d.ordinal = ordinal; + check(cudaSetDevice(ordinal), "cudaSetDevice"); + + cudaDeviceProp p{}; + check(cudaGetDeviceProperties(&p, ordinal), "cudaGetDeviceProperties"); + d.name = p.name; + d.cc_major = p.major; + d.cc_minor = p.minor; + d.multi_processor_count = p.multiProcessorCount; + + size_t free_b = 0, total_b = 0; + check(cudaMemGetInfo(&free_b, &total_b), "cudaMemGetInfo"); + d.free_bytes = free_b; + d.total_bytes = total_b; + + check(cudaDriverGetVersion(&d.driver_version), "cudaDriverGetVersion"); + check(cudaRuntimeGetVersion(&d.runtime_version), "cudaRuntimeGetVersion"); + + // The engine is written against sm_120. Compiling for it is enforced by CMake; RUNNING on something else + // is caught here, because a binary can be carried to a machine with an older card and would otherwise + // silently take whatever path the driver chose. + if (d.cc_major != 12) { + throw CudaError("device " + d.name + " reports compute capability " + std::to_string(d.cc_major) + + "." + std::to_string(d.cc_minor) + + "; Strata targets sm_120 (RTX 5000 series / Blackwell) only", + -1); + } + return d; +} + +DeviceArena::DeviceArena(uint64_t bytes, int ordinal, bool poison) + : capacity_(bytes), ordinal_(ordinal), poison_(poison) { + if (bytes == 0) throw CudaError("DeviceArena of 0 bytes", -1); + check(cudaSetDevice(ordinal), "cudaSetDevice"); + // One allocation for the whole region. cudaMalloc of a large block is the thing that can fail late, so it + // happens once, here, before anything depends on it. + check(cudaMalloc(&base_, (size_t) bytes), "cudaMalloc"); + if (poison_) { + const int threads = 256; + const uint64_t n = bytes / sizeof(float); + const uint64_t blocks = (n + threads - 1) / threads; + // gridDim.x is 32-bit, so a large region needs a loop. 12 GB of floats is 3e9 elements = 1.2e7 + // blocks, which fits, but the loop keeps it correct for any size rather than for today's sizes. + const uint64_t max_blocks = 0x7FFFFFFFull; + for (uint64_t b = 0; b < blocks; b += max_blocks) { + const uint64_t chunk = (blocks - b < max_blocks) ? (blocks - b) : max_blocks; + poison_kernel<<<(unsigned) chunk, threads>>>((float*) base_ + b * threads, n - b * threads); + check(cudaGetLastError(), "poison_kernel"); + } + check(cudaDeviceSynchronize(), "poison sync"); + } +} + +DeviceArena::~DeviceArena() { + if (base_) cudaFree(base_); // best effort: a destructor must not throw +} + +void* DeviceArena::alloc(uint64_t bytes, uint64_t align) { + if (bytes == 0) return nullptr; + if (align == 0 || (align & (align - 1)) != 0) { + throw CudaError("DeviceArena::alloc alignment must be a power of two", -1); + } + const uint64_t start = (used_ + align - 1) & ~(align - 1); + if (start + bytes > capacity_) { + char msg[256]; + std::snprintf(msg, sizeof(msg), + "DeviceArena out of memory: asked for %llu B at offset %llu (align %llu) in a %llu B " + "region - the plan from P1.S9 did not close", + (unsigned long long) bytes, (unsigned long long) start, (unsigned long long) align, + (unsigned long long) capacity_); + throw CudaError(msg, -1); + } + used_ = start + bytes; + return (char*) base_ + start; +} + +} // namespace strata::core diff --git a/src/core/device_main.cpp b/src/core/device_main.cpp new file mode 100644 index 00000000..e14657e5 --- /dev/null +++ b/src/core/device_main.cpp @@ -0,0 +1,78 @@ +// src/core/device_main.cpp - `strata-device`: report the GPU, the plan, and exercise the arena. +// +// This is P2.S1's "startup prints the memory plan vs actual cudaMemGetInfo" bullet, on its own so it can run +// without the model. It is also the run-time half of the sm_120 policy: CMake refuses to COMPILE for another +// architecture, and this refuses to RUN on one. +#include "strata/core/device.hpp" +#include "strata/plan/plan.hpp" + +#include +#include +#include + +static std::string human(uint64_t b) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.3f GiB (%llu B)", (double) b / (1024.0 * 1024 * 1024), + (unsigned long long) b); + return buf; +} + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--selftest") == 0) selftest = true; + else if (std::strcmp(argv[i], "--help") == 0 || std::strcmp(argv[i], "-h") == 0) { + std::printf("usage: strata-device [--selftest]\n"); + return 0; + } else { + std::fprintf(stderr, "unknown argument: %s\n", argv[i]); + return 2; + } + } + + try { + const strata::core::DeviceInfo d = strata::core::device_info(0); + std::printf("device %d: %s\n", d.ordinal, d.name.c_str()); + std::printf(" compute capability %d.%d (sm_%d%d)\n", d.cc_major, d.cc_minor, d.cc_major, d.cc_minor); + std::printf(" multiprocessors %d\n", d.multi_processor_count); + std::printf(" VRAM total / free %s / %s\n", human(d.total_bytes).c_str(), human(d.free_bytes).c_str()); + std::printf(" driver / runtime %d / %d\n", d.driver_version, d.runtime_version); + + // The planner's view against the card's. A plan that does not fit in what is actually FREE is the + // failure this print exists to make visible at startup rather than at token 4000. + const auto plan = strata::plan::make_plan(20480, strata::plan::Geometry{}, strata::plan::Costs{}); + std::printf("\n%s", strata::plan::to_string(plan).c_str()); + std::printf(" card free %s\n", human(d.free_bytes).c_str()); + std::printf(" plan + KV vs free %s\n", + plan.vram_budget <= d.free_bytes ? "FITS" : "*** DOES NOT FIT ***"); + + if (selftest) { + // Exercise the arena for real: allocate, write from the host, read back, and check the poison + // path leaves NaNs rather than zeros. A GPU test that only asks the driver for its name does not + // test the runtime this file exists to provide. + const uint64_t bytes = 64ull << 20; // 64 MiB, small enough to be safe on any card + strata::core::DeviceArena arena(bytes, 0, /*poison=*/true); + void* a = arena.alloc(1 << 20, 256); + void* b = arena.alloc(1 << 20, 4096); + if (((uintptr_t) a % 256) || ((uintptr_t) b % 4096)) { + std::fprintf(stderr, "selftest: alignment not honoured\n"); + return 1; + } + std::printf("\nselftest: arena %s, used %s after two 1 MiB allocations\n", human(arena.capacity()).c_str(), + human(arena.used()).c_str()); + // and the arena must REFUSE rather than wrap + try { + arena.alloc(bytes * 2); + std::fprintf(stderr, "selftest: over-allocation did NOT throw\n"); + return 1; + } catch (const strata::core::CudaError&) { + std::printf("selftest: over-allocation refused as required\n"); + } + std::printf("strata-device selftest OK\n"); + } + return 0; + } catch (const std::exception& e) { + std::fprintf(stderr, "strata-device: %s\n", e.what()); + return 1; + } +} diff --git a/src/core/expert_cache.cpp b/src/core/expert_cache.cpp new file mode 100644 index 00000000..9f5fc981 --- /dev/null +++ b/src/core/expert_cache.cpp @@ -0,0 +1,297 @@ +// src/core/expert_cache.cpp - R4's slot storage and residency table. Read the header first. +#include "strata/core/expert_cache.hpp" + +#include + +#include +#include +#include + +namespace strata::core { + +bool read_expert_profile(const std::string& path, int64_t n_layers, int64_t n_expert, + std::vector>& ranked, int64_t& slots, std::string& err) { + std::FILE* f = std::fopen(path.c_str(), "rb"); + if (f == nullptr) { + err = "read_expert_profile: cannot open " + path; + return false; + } + char magic[4] = {0, 0, 0, 0}; + uint32_t hdr[5] = {0, 0, 0, 0, 0}; + if (std::fread(magic, 1, 4, f) != 4 || std::fread(hdr, 4, 5, f) != 5) { + std::fclose(f); + err = "read_expert_profile: " + path + " is too short to hold a header"; + return false; + } + if (std::memcmp(magic, "STRP", 4) != 0) { + std::fclose(f); + err = "read_expert_profile: " + path + " does not start with STRP"; + return false; + } + const uint32_t version = hdr[0], nl = hdr[1], ne = hdr[2], want = hdr[3], n_ranked = hdr[4]; + if ((int64_t) nl != n_layers || (int64_t) ne != n_expert) { + std::fclose(f); + char buf[256]; + std::snprintf(buf, sizeof buf, + "read_expert_profile: %s is %ux%u but this model is %lldx%lld - it is a profile for a " + "different artifact", path.c_str(), nl, ne, (long long) n_layers, (long long) n_expert); + err = buf; + return false; + } + if (n_ranked > want) { + std::fclose(f); + err = "read_expert_profile: the header claims more ranked pairs than slots"; + return false; + } + ranked.assign(n_ranked, {0, 0}); + std::vector raw((size_t) n_ranked * 2); + if (n_ranked > 0 && std::fread(raw.data(), 2, (size_t) n_ranked * 2, f) != (size_t) n_ranked * 2) { + std::fclose(f); + err = "read_expert_profile: the ranked list is truncated"; + return false; + } + std::fclose(f); + for (uint32_t i = 0; i < n_ranked; ++i) { + const int32_t l = (int32_t) raw[(size_t) i * 2], e = (int32_t) raw[(size_t) i * 2 + 1]; + if (l < 0 || l >= n_layers || e < 0 || e >= n_expert) { + char buf[256]; + std::snprintf(buf, sizeof buf, "read_expert_profile: pair %u is (layer %d, expert %d), out of range", + i, l, e); + err = buf; + return false; + } + ranked[(size_t) i] = {l, e}; + } + slots = (int64_t) want; + (void) version; // a future format bumps it; the layout check above is what protects this reader today + return true; +} + +ExpertCache::~ExpertCache() { close(); } + +bool ExpertCache::open(int64_t n_slots, int64_t n_layers, int64_t n_expert, int64_t blob_bytes, + std::string& err) { + close(); + if (n_slots <= 0) { + err = "ExpertCache: n_slots must be positive"; + return false; + } + if (n_layers <= 0 || n_expert <= 0 || blob_bytes <= 0) { + err = "ExpertCache: n_layers, n_expert and blob_bytes must all be positive"; + return false; + } + + const uint64_t want = (uint64_t) n_slots * (uint64_t) blob_bytes; + + // ---- **THE ALLOCATION IS CHECKED AGAINST THE CARD, NOT AGAINST THE REQUEST.** + // + // `cudaMalloc` failing is the easy case. The one that matters is a machine where the weights already own + // most of VRAM: the cache then takes what is left and `slots()` would report the number ASKED FOR while + // `device_slot()` walks off the end. So the free-VRAM figure is read and compared BEFORE the allocation, + // and the two numbers are named in the refusal. + size_t free_b = 0, total_b = 0; + if (cudaMemGetInfo(&free_b, &total_b) == cudaSuccess) { + if ((uint64_t) free_b < want) { + char buf[320]; + std::snprintf(buf, sizeof buf, + "ExpertCache: %lld slots x %lld B = %.2f GiB, but only %.2f GiB of VRAM is free " + "(%.2f GiB of %.2f GiB total). Lower --expert-cache.", + (long long) n_slots, (long long) blob_bytes, (double) want / 1073741824.0, + (double) free_b / 1073741824.0, (double) (total_b - free_b) / 1073741824.0, + (double) total_b / 1073741824.0); + err = buf; + return false; + } + } + + if (cudaMalloc((void**) &base_, (size_t) want) != cudaSuccess) { + base_ = nullptr; + char buf[256]; + std::snprintf(buf, sizeof buf, "ExpertCache: cudaMalloc(%.2f GiB) failed: %s", + (double) want / 1073741824.0, cudaGetErrorString(cudaGetLastError())); + err = buf; + return false; + } + // Zeroed so a slot read before it is filled is a DETERMINISTIC wrong answer rather than whatever the + // allocator handed back. A stale block of a previous process's memory would still sum to finite floats. + if (cudaMemset(base_, 0, (size_t) want) != cudaSuccess) { + err = "ExpertCache: cudaMemset of the slot arena failed"; + close(); + return false; + } + + residency_.assign((size_t) (n_layers * n_expert), kNotResident); + slots_ = n_slots; + n_layers_ = n_layers; + n_expert_ = n_expert; + blob_ = blob_bytes; + next_free_ = 0; + fills_ = 0; + admitted_ = 0; + // R4.2g: each layer starts at the bottom of its own range. Built here rather than lazily so `admit` + // stays allocation-free on the token path. + layer_next_.assign((size_t) (n_layers > 0 ? n_layers : 0), 0); + for (int64_t l = 0; l < n_layers; ++l) { + int64_t lo = 0, hi = 0; + layer_slot_range(l, lo, hi); + layer_next_[(size_t) l] = (int32_t) lo; + } + return true; +} + +bool ExpertCache::open_sized(const std::vector& slot_bytes, int64_t n_layers, int64_t n_expert, + std::string& err) { + if (slot_bytes.empty()) { err = "ExpertCache: no slots"; return false; } + int64_t mx = 0; + std::vector off(slot_bytes.size() + 1, 0); + for (size_t i = 0; i < slot_bytes.size(); ++i) { + // 256-byte aligned slots, so every blob starts where the kernels' vector loads expect it + off[i + 1] = off[i] + ((uint64_t) slot_bytes[i] + 255) / 256 * 256; + mx = slot_bytes[i] > mx ? slot_bytes[i] : mx; + } + // one allocation of the summed size, through the uniform path's checks: n "slots" of 1 byte + if (!open((int64_t) off.back(), n_layers, n_expert, 1, err)) return false; + slots_ = (int64_t) slot_bytes.size(); + blob_ = mx; + off_ = std::move(off); + layer_next_.assign((size_t) (n_layers > 0 ? n_layers : 0), 0); + return true; +} + +void ExpertCache::close() { + off_.clear(); + if (base_ != nullptr) { + cudaFree(base_); + base_ = nullptr; + } + residency_.clear(); + slots_ = 0; + n_layers_ = 0; + n_expert_ = 0; + blob_ = 0; + next_free_ = 0; + fills_ = 0; + admitted_ = 0; + layer_next_.clear(); +} + +/// R4.2g. Layer `l` owns `[l*q, (l+1)*q)` with `q = slots_ / n_layers_`; the LAST layer takes whatever is +/// left over, so the ranges always cover `0..slots_` exactly and no slot is orphaned by the division. +void ExpertCache::layer_slot_range(int64_t layer, int64_t& lo, int64_t& hi) const { + lo = 0; + hi = 0; + if (n_layers_ <= 0 || slots_ <= 0 || layer < 0 || layer >= n_layers_) return; + const int64_t q = slots_ / n_layers_; + lo = layer * q; + hi = (layer == n_layers_ - 1) ? slots_ : (layer + 1) * q; +} + +int32_t ExpertCache::slot_of(int64_t layer, int64_t expert) const { + if (layer < 0 || layer >= n_layers_ || expert < 0 || expert >= n_expert_) return kNotResident; + return residency_[(size_t) (layer * n_expert_ + expert)]; +} + +int32_t ExpertCache::admit(int64_t layer, int64_t expert) { + if (layer < 0 || layer >= n_layers_ || expert < 0 || expert >= n_expert_) return kNotResident; + const size_t at = (size_t) (layer * n_expert_ + expert); + if (residency_[at] != kNotResident) return residency_[at]; + // R4.2g: THE PER-LAYER PATH. Same "no eviction" rule, but the ceiling is this layer's own range rather + // than one counter shared by all 48 - which is what confined the measured hit rate to 2.97%. + if (per_layer_) { + if (layer_next_.empty()) return kNotResident; + int64_t lo = 0, hi = 0; + layer_slot_range(layer, lo, hi); + if ((int64_t) layer_next_[(size_t) layer] >= hi) return kNotResident; // this layer's quota is full + residency_[at] = layer_next_[(size_t) layer]++; + ++admitted_; + return residency_[at]; + } + if (next_free_ >= slots_) return kNotResident; // full: no eviction, deliberately - see the header + residency_[at] = (int32_t) next_free_; + return (int32_t) next_free_++; +} + +uint8_t* ExpertCache::device_slot(int32_t slot) { + if (slot < 0 || slot >= slots_) return nullptr; + if (!off_.empty()) return base_ + off_[(size_t) slot]; + return base_ + (size_t) slot * (size_t) blob_; +} + +const uint8_t* ExpertCache::device_slot(int32_t slot) const { + if (slot < 0 || slot >= slots_) return nullptr; + if (!off_.empty()) return base_ + off_[(size_t) slot]; + return base_ + (size_t) slot * (size_t) blob_; +} + +bool ExpertCache::fill_slot(int32_t slot, const uint8_t* host_blob, void* stream, std::string& err, int64_t bytes) { + const size_t n = (size_t) (bytes > 0 && bytes <= blob_ ? bytes : blob_); + uint8_t* dst = device_slot(slot); + if (dst == nullptr) { + err = "ExpertCache::fill_slot: slot " + std::to_string(slot) + " is outside 0.." + + std::to_string(slots_ - 1); + return false; + } + if (host_blob == nullptr) { + err = "ExpertCache::fill_slot: the host blob is null"; + return false; + } + const cudaError_t e = cudaMemcpyAsync(dst, host_blob, n, cudaMemcpyHostToDevice, + (cudaStream_t) stream); + if (e != cudaSuccess) { + err = std::string("ExpertCache::fill_slot: ") + cudaGetErrorString(e); + return false; + } + ++fills_; + return true; +} + +bool ExpertCache::fill_slot_blocking(int32_t slot, const uint8_t* host_blob, std::string& err, int64_t bytes) { + const size_t n = (size_t) (bytes > 0 && bytes <= blob_ ? bytes : blob_); + uint8_t* dst = device_slot(slot); + if (dst == nullptr) { + err = "ExpertCache::fill_slot_blocking: slot outside the arena"; + return false; + } + if (host_blob == nullptr) { + err = "ExpertCache::fill_slot_blocking: the host blob is null"; + return false; + } + const cudaError_t e = cudaMemcpy(dst, host_blob, n, cudaMemcpyHostToDevice); + if (e != cudaSuccess) { + err = std::string("ExpertCache::fill_slot_blocking: ") + cudaGetErrorString(e); + return false; + } + ++fills_; + return true; +} + +bool ExpertCache::verify_slot(int32_t slot, const uint8_t* host_blob, std::string& err, int64_t bytes) { + const int64_t nb = bytes > 0 && bytes <= blob_ ? bytes : blob_; + const uint8_t* src = device_slot(slot); + if (src == nullptr) { + err = "ExpertCache::verify_slot: slot outside the arena"; + return false; + } + // `cudaMemcpy` and not `cudaMemcpyAsync`: this is a startup check, and a check that can be read before it + // has happened is not a check. It also synchronises the fills queued before it, which is what makes the + // comparison meaningful. + std::vector got((size_t) nb); + const cudaError_t e = cudaMemcpy(got.data(), src, (size_t) nb, cudaMemcpyDeviceToHost); + if (e != cudaSuccess) { + err = std::string("ExpertCache::verify_slot: ") + cudaGetErrorString(e); + return false; + } + if (std::memcmp(got.data(), host_blob, (size_t) nb) != 0) { + size_t first = 0; + while (first < (size_t) nb && got[first] == host_blob[first]) ++first; + char buf[256]; + std::snprintf(buf, sizeof buf, + "ExpertCache::verify_slot: slot %d differs from the arena at byte %llu (of %lld)", + (int) slot, (unsigned long long) first, (long long) blob_); + err = buf; + return false; + } + return true; +} + +} // namespace strata::core diff --git a/src/core/expert_source.cpp b/src/core/expert_source.cpp new file mode 100644 index 00000000..db4465d6 --- /dev/null +++ b/src/core/expert_source.cpp @@ -0,0 +1,709 @@ +// src/core/expert_source.cpp - the adapter. See the header for the three clauses of the contract. +#include "strata/core/expert_source.hpp" +#include "strata/kernels/cpu/expert_layout.hpp" + +#include "strata/core/pinned.hpp" +#include "strata/kernels/elementwise.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/s2_expert_grouped.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#include +#endif + +namespace strata::core { + +// ================================ THE FILE-BACKED SOURCE ================================ + +FileExpertSource::~FileExpertSource() { close(); } + +bool FileExpertSource::open(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, std::string& err) { + close(); + if (n_layers <= 0 || n_expert <= 0) { err = "FileExpertSource: the geometry is empty"; return false; } + n_expert_ = n_expert; + blobs_ = n_layers * n_expert; + const uint64_t want = (uint64_t) blobs_ * (uint64_t) strata::kernels::cpu::BLOB; + const std::string path = pack_dir + "/experts.bin"; + +#if defined(_WIN32) + // UTF-8 -> UTF-16: the pack may live under a path with non-ASCII characters, and `CreateFileA` would + // silently mangle it into a file-not-found. + const int wide = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0); + std::vector wpath((size_t) (wide > 0 ? wide : 1)); + if (wide > 0) MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, wpath.data(), wide); + // **`FILE_FLAG_RANDOM_ACCESS` WAS HERE AND IT COST 14x.** + // + // The design depends on the OS page cache holding the whole 34 GB expert set, because this machine has + // 64 GB of DDR5 and `L9` measured the CPU path at 44.14 GB/s from DRAM. `FILE_FLAG_RANDOM_ACCESS` tells + // the cache manager the opposite: it disables read-ahead AND it lets the manager drop the pages again + // quickly, on the assumption that a large randomly-accessed file will not be re-read. Measured, on + // `strata generate --max-new 24`: **1.93 GB/s** - disk speed, 344 ms/token, and it never warmed up over 25 + // tokens, because the pages were being evicted as fast as they were faulted in. + // + // The correct flag is NO flag. The access pattern IS random (10 of 512 experts per layer, a different 10 + // each layer), but every byte read is read again on the next token, so retention is the whole game. + HANDLE f = CreateFileW(wpath.data(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (f == INVALID_HANDLE_VALUE) { + err = "FileExpertSource: cannot open " + path; + return false; + } + LARGE_INTEGER sz{}; + if (!GetFileSizeEx(f, &sz)) { + CloseHandle(f); + err = "FileExpertSource: cannot size " + path; + return false; + } + if ((uint64_t) sz.QuadPart != want) { + char buf[400]; + std::snprintf(buf, sizeof buf, + "FileExpertSource: %s is %llu B but %lld layers x %lld experts x %d B is %llu B - this " + "is not the pack this geometry came from", + path.c_str(), (unsigned long long) sz.QuadPart, (long long) n_layers, + (long long) n_expert, (int) strata::kernels::cpu::BLOB, (unsigned long long) want); + CloseHandle(f); + err = buf; + return false; + } + HANDLE m = CreateFileMappingW(f, nullptr, PAGE_READONLY, 0, 0, nullptr); + if (m == nullptr) { + CloseHandle(f); + err = "FileExpertSource: CreateFileMapping failed on " + path; + return false; + } + void* view = MapViewOfFile(m, FILE_MAP_READ, 0, 0, 0); + if (view == nullptr) { + CloseHandle(m); + CloseHandle(f); + err = "FileExpertSource: MapViewOfFile failed on " + path; + return false; + } + file_ = f; + mapping_ = m; + base_ = (const uint8_t*) view; +#else + const int fd = ::open(path.c_str(), O_RDONLY); + if (fd < 0) { err = "FileExpertSource: cannot open " + path; return false; } + struct stat st{}; + if (fstat(fd, &st) != 0) { ::close(fd); err = "FileExpertSource: cannot stat " + path; return false; } + if ((uint64_t) st.st_size != want) { + char buf[400]; + std::snprintf(buf, sizeof buf, + "FileExpertSource: %s is %llu B but %lld layers x %lld experts x %d B is %llu B - this " + "is not the pack this geometry came from", + path.c_str(), (unsigned long long) st.st_size, (long long) n_layers, (long long) n_expert, + (int) strata::kernels::cpu::BLOB, (unsigned long long) want); + ::close(fd); + err = buf; + return false; + } + void* view = mmap(nullptr, (size_t) want, PROT_READ, MAP_SHARED, fd, 0); + if (view == MAP_FAILED) { ::close(fd); err = "FileExpertSource: mmap failed on " + path; return false; } + fd_ = fd; + base_ = (const uint8_t*) view; +#endif + return true; +} + +void FileExpertSource::close() { +#if defined(_WIN32) + if (base_ != nullptr) UnmapViewOfFile((LPCVOID) base_); + if (mapping_ != nullptr) CloseHandle((HANDLE) mapping_); + if (file_ != nullptr) CloseHandle((HANDLE) file_); + mapping_ = nullptr; + file_ = nullptr; +#else + if (base_ != nullptr) munmap((void*) base_, (size_t) blobs_ * (size_t) strata::kernels::cpu::BLOB); + if (fd_ >= 0) ::close(fd_); + fd_ = -1; +#endif + base_ = nullptr; + blobs_ = 0; + reads_ = 0; +} + +const uint8_t* FileExpertSource::blob(int64_t layer, int64_t expert) { + if (base_ == nullptr) return nullptr; + if (layer < 0 || expert < 0) return nullptr; + // **`expert >= n_expert_` IS CHECKED SEPARATELY, AND THE FLAT INDEX ALONE DOES NOT CATCH IT.** The blob + // index is `layer * n_expert + expert`, so `blob(0, 512)` has flat index 512 - which is in range, and is + // `blob(1, 0)`. A router id one past the end of a layer would then read the NEXT LAYER's first expert: + // finite, correctly sized, and wrong. Layer and expert are separate axes and are validated as such. + if (expert >= n_expert_) return nullptr; + const int64_t i = layer * n_expert_ + expert; + if (i >= blobs_) return nullptr; + ++reads_; + return base_ + (size_t) i * strata::kernels::cpu::BLOB; +} + +// ================================ THE ADAPTER ================================ + +void expert_pool_dispatch(void* user, const float* x_f, const int32_t* ids, const float* weights, int64_t n_embd, + int64_t k, float* out) { + (void) weights; // clause 2: `moe_combine` applies it on the device. Not an oversight. + ExpertDispatch& d = *(ExpertDispatch*) user; + if (d.failed) return; // a previous layer already failed; do not make it worse + + using namespace strata::kernels::cpu; + // Clause 3: the blob's internal offsets are compile-time constants, so a mismatched geometry does not + // produce a wrong answer - it produces a walk off the end of the blob into the next expert's bytes, which + // is finite and plausible. Refuse, name the number, and let the driver report it. + if (n_embd != H) { + d.failed = true; + d.fail = "the expert kernel is compiled for a 2560-wide activation"; + d.fail_layer = d.layers; + return; + } + if (expert_layout().native) { + // plan v0.3 P6: a native pack runs its experts in verify windows only (the driver guarantees it) + d.failed = true; + d.fail = "the single-token expert path does not take a native (IQ) pack"; + d.fail_layer = d.layers; + return; + } + if (k > (int64_t) d.jobs.size()) d.jobs.resize((size_t) k); + + d.src->begin_layer(d.layers, ids, k); + + // Clause 1: rebuilt from `x_f` on EVERY call. `x_f` is mapped pinned memory whose address never changes, + // so anything cached against it would be layer 0's activation reused 48 times. + act_quant_q8_1(x_f, H, d.act); + + // ---- R4.2c: THE POOL'S HALF OF THE SPLIT. **IT DOES NOT DECIDE ANYTHING - `Launch` ALREADY DID.** + // + // The decision has to be made on THIS layer's ids, and `Launch` is the only callback that runs before the + // pool while the ids are known (the doorbell publishes them when the ring fires). So `expert_hit_run` + // decides, and this consumes `d.is_hit`. The first version decided here instead, which meant `Launch` + // computed the PREVIOUS layer's experts into this layer's rows: C1 went from mean KL 9.69e-02 to 1.03e+00. + // + // `njobs` indexes the JOB ARRAY and `i` indexes the OUTPUT - they are the same only when nothing is a hit. + const bool graph_hits = d.host_res != nullptr; + const bool use_hits = graph_hits || (d.hits_ready() && d.decided); + int64_t njobs = 0; + + for (int64_t i = 0; i < k; ++i) { + const int64_t e = ids[i]; + if (e < 0 || e >= d.n_expert) { + d.failed = true; + d.fail = "a routed expert id is out of range"; + d.fail_layer = d.layers; + d.fail_expert = e; + return; + } + const uint8_t* b = d.src->blob(d.layers, e); + if (b == nullptr) { + // The one failure the loop cannot see. Leaving `out` at its previous contents would feed the NEXT + // layer a stale expert vector, which `moe_combine` would weight and add - the token would still be + // finite and would still be wrong, 48 layers deep. + d.failed = true; + d.fail = "the expert source could not produce a blob"; + d.fail_layer = d.layers; + d.fail_expert = e; + ++d.missing; + return; + } + // A hit's row was zeroed by `Launch` and belongs to the GPU; the pool must not touch it. + if (use_hits && (graph_hits ? d.host_res[(size_t) d.layers * (size_t) d.n_expert + (size_t) e] >= 0 + : d.is_hit[(size_t) i] != 0)) { + if (graph_hits) ++d.cache_hits; + // The GPU owns this row and `hit_out` is zeroed, so the CPU's contribution is zero - but + // `y_miss` is a REUSED pinned buffer, so the row must be written, not merely skipped. + std::memset(out + (size_t) i * (size_t) n_embd, 0, (size_t) n_embd * sizeof(float)); + continue; + } + if (graph_hits) ++d.cache_refused; // token graph: a miss (nothing is admitted during a token) + + // `njobs` indexes the JOB ARRAY and `i` indexes the OUTPUT - they are the same only when nothing is a + // hit, and using one for the other is how a hit's row would get two experts summed into it. + ExpertJob& j = d.jobs[(size_t) njobs++]; + j.blob = b; + j.act = &d.act; // SHARED across the batch: one conversion serves all ten experts + j.out = out + (size_t) i * (size_t) n_embd; + j.weight = 1.0f; // clause 2: a diagnostic field, NOT the router weight + j.slot = (int) i; + } + + // Plan v0.3 P4: rows of every expert across all threads (bitwise the same as `run`). + if (d.split_rows) d.pool->run_split(d.jobs.data(), (int) njobs); + else d.pool->run(d.jobs.data(), (int) njobs); + ++d.layers; + d.experts += k; +} + +void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32_t* ids, int64_t n_tok, int64_t k, + float* out) { + using namespace strata::kernels::cpu; + if (d.failed) return; + if (n_tok < 1 || n_tok > MAXT) { + d.failed = true; + d.fail = "a verify window has more tokens than the multi-token expert kernel takes"; + d.fail_layer = d.layers; + return; + } + if ((int64_t) d.act_multi.size() < n_tok) d.act_multi.resize((size_t) MAXT); + const ExpertLayout& lay = expert_layout(); + const bool native = lay.native; + if (native && d.nact_multi.size() < (size_t) MAXT * kNativeActBytes) d.nact_multi.resize((size_t) MAXT * kNativeActBytes); + if (d.job_of.size() != (size_t) d.n_expert) d.job_of.assign((size_t) d.n_expert, (int16_t) -1); + if (d.jobs_multi.size() < (size_t) (n_tok * k)) d.jobs_multi.resize((size_t) (MAXT * k)); + const auto c0 = std::chrono::steady_clock::now(); + d.src->begin_layer(d.layers, ids, n_tok * k); + if (!d.usage.empty()) + for (int64_t i = 0; i < n_tok * k; ++i) + if (ids[i] >= 0 && ids[i] < d.n_expert) d.usage[(size_t) d.layers * (size_t) d.n_expert + (size_t) ids[i]] += 1.0f; + // ---- plan v0.3 P6: the GPU's share, decided and published FIRST so the GPU starts while the CPU works. + // Distinct experts in routing order; resident ones and the last pcie_num/256 of the missed ones go to the GPU. + const int64_t n = n_tok * k; + int32_t kind[128]; // per entry: -1 CPU, 0 VRAM, 1 PCIe + if (d.plan != nullptr && n <= 128 && n <= d.plan->cap) { + int64_t distinct[128], first_of[128]; + int nd = 0, nmiss = 0; + for (int64_t i = 0; i < n; ++i) { + first_of[i] = i; + for (int64_t j = 0; j < i; ++j) + if (ids[j] == ids[i]) { first_of[i] = first_of[j]; break; } + if (first_of[i] == i) { + distinct[nd++] = i; + const int32_t e = ids[i]; + if (e >= 0 && e < d.n_expert && d.host_res[(size_t) d.layers * (size_t) d.n_expert + (size_t) e] < 0) ++nmiss; + } + } + const bool pcie_ok = d.pcie_num > 0 && d.src->device_alias(d.layers, 0) != nullptr; + const int m = pcie_ok ? (nmiss * d.pcie_num) >> 8 : 0; + int miss_rank = 0, groups = 0, entries = 0, fetches = 0; + GpuPlanSink& P = *d.plan; + const uint8_t* dma_src[64]; + int64_t pcie_i0[64]; + for (int q = 0; q < nd; ++q) { + const int64_t i0 = distinct[q]; + const int32_t e = ids[i0]; + int kd = -1; + unsigned long long ptr = 0; + if (e >= 0 && e < d.n_expert) { + const int32_t slot = d.host_res[(size_t) d.layers * (size_t) d.n_expert + (size_t) e]; + if (slot >= 0) { + kd = 0; + ptr = (unsigned long long) (d.cache_base + (d.cache_slot_off ? (size_t) d.cache_slot_off[slot] + : (size_t) slot * (size_t) d.cache_blob)); + } else { + if (miss_rank >= nmiss - m && fetches < P.staging_cap && fetches < 64) { + const uint8_t* src = d.src->blob(d.layers, e); + if (src != nullptr && d.src->pinned(d.layers, e)) { + kd = 1; + dma_src[fetches] = src; + pcie_i0[fetches] = i0; + ++fetches; + } + } + ++miss_rank; + } + } + for (int64_t i = i0; i < n; ++i) + if (first_of[i] == i0) kind[i] = kd; + if (kd != 0) continue; // the VRAM groups first; the PCIe groups below + P.ptr[groups] = ptr; + P.start[groups] = entries; + for (int64_t i = i0; i < n; ++i) + if (first_of[i] == i0) { + P.dst[entries] = (int32_t) i; + P.tok[entries] = (int32_t) (i / k); + ++entries; + } + ++groups; + } + P.start[groups] = entries; + const uint64_t bb = lay.blob_bytes(d.layers); + for (int q = 0; q < fetches; ++q) { // the PCIe groups: staging slot q, entries after the VRAM ones + const int64_t i0 = pcie_i0[q]; + P.ptr2[q] = P.pcie_mode != 0 ? (unsigned long long) d.src->device_alias(d.layers, ids[i0]) + : P.staging + (unsigned long long) q * (unsigned long long) bb; + P.start2[q] = entries; + for (int64_t i = i0; i < n; ++i) + if (first_of[i] == i0) { + P.dst[entries] = (int32_t) i; + P.tok[entries] = (int32_t) (i / k); + ++entries; + } + ++d.pcie_experts; + } + P.start2[fetches] = entries; + P.counts[0] = groups; + P.counts[1] = entries; + P.counts[2] = fetches; + std::atomic_thread_fence(std::memory_order_seq_cst); + if (P.publish) P.publish(P.ctx); + if (P.fetch) P.fetch(P.ctx, dma_src, P.pcie_mode != 0 ? 0 : fetches, (size_t) bb); // the copy engine, beside the CPU's work + } else { + for (int64_t i = 0; i < n; ++i) { + const int32_t e = ids[i]; + kind[i] = (e >= 0 && e < d.n_expert && d.host_res != nullptr && + d.host_res[(size_t) d.layers * (size_t) d.n_expert + (size_t) e] >= 0) ? 0 : -1; + } + } + const auto c1 = std::chrono::steady_clock::now(); + if (native && lay.fmt[(size_t) d.layers].gu_type == 42) // a native Q2_0 pack: the Q2_0 kernels' activations + for (int64_t t = 0; t < n_tok; ++t) act_quant_any(x_f + (size_t) t * H, H, d.act_multi[(size_t) t]); + else if (native) + for (int64_t t = 0; t < n_tok; ++t) + native_quant_act(lay.fmt[(size_t) d.layers], x_f + (size_t) t * H, d.nact_multi.data() + (size_t) t * kNativeActBytes); + else + for (int64_t t = 0; t < n_tok; ++t) act_quant_q8_1(x_f + (size_t) t * H, H, d.act_multi[(size_t) t]); + const auto c2 = std::chrono::steady_clock::now(); + int njobs = 0; + for (int64_t t = 0; t < n_tok; ++t) + for (int64_t j = 0; j < k; ++j) { + const int64_t i = t * k + j; + const int64_t e = ids[i]; + float* row = out + (size_t) i * H; + if (e < 0 || e >= d.n_expert) { + d.failed = true; + d.fail = "a routed expert id is out of range"; + d.fail_layer = d.layers; + d.fail_expert = e; + return; + } + if (kind[i] >= 0) { // the GPU computes this entry (a VRAM hit or a PCIe read) + if (kind[i] == 0) ++d.cache_hits; + std::memset(row, 0, (size_t) H * sizeof(float)); + continue; + } + ++d.cache_refused; + int16_t& jo = d.job_of[(size_t) e]; + if (jo < 0) { + const uint8_t* b = d.src->blob(d.layers, e); + if (b == nullptr) { + d.failed = true; + d.fail = "the expert source could not produce a blob"; + d.fail_layer = d.layers; + d.fail_expert = e; + ++d.missing; + return; + } + jo = (int16_t) njobs++; + ExpertJobMulti& nj = d.jobs_multi[(size_t) jo]; + nj.blob = b; + nj.nt = 0; + } + ExpertJobMulti& jb = d.jobs_multi[(size_t) jo]; + jb.act[jb.nt] = &d.act_multi[(size_t) t]; + jb.nact[jb.nt] = native ? d.nact_multi.data() + (size_t) t * kNativeActBytes : nullptr; + jb.out[jb.nt] = row; + ++jb.nt; + ++d.multi_entries; + } + const auto c3 = std::chrono::steady_clock::now(); + if (native) d.pool->run_split_multi_native(lay.fmt[(size_t) d.layers], d.jobs_multi.data(), njobs); + else d.pool->run_split_multi(d.jobs_multi.data(), njobs); + const auto c4 = std::chrono::steady_clock::now(); + auto ms = [](auto a, auto b) { return std::chrono::duration(b - a).count(); }; + d.ms_plan += ms(c0, c1); + d.ms_actq += ms(c1, c2); + d.ms_jobs += ms(c2, c3); + d.ms_run += ms(c3, c4); + for (int64_t i = 0; i < n_tok * k; ++i) { + const int64_t e = ids[i]; + if (e >= 0 && e < d.n_expert) d.job_of[(size_t) e] = -1; + } + d.multi_misses += njobs; + ++d.layers; + d.experts += n_tok * k; +} + +void expert_hit_run(void* user, void* stream, HitPhase phase, const int32_t* ids, int64_t k) { + ExpertDispatch& d = *(ExpertDispatch*) user; + if (d.failed) return; + cudaStream_t cs = (cudaStream_t) stream; + + if (phase == HitPhase::Launch) { + d.decided = false; + d.hit_pending = false; + if (!d.hits_ready() || ids == nullptr || k <= 0) return; + if ((int64_t) d.is_hit.size() < k) d.is_hit.resize((size_t) k); + + // ================================ THE DECISION, ONCE, ON THIS LAYER'S IDS ================================ + // + // Every routed expert is asked of the cache. Resident -> the GPU computes it. Not resident -> it is + // admitted and filled if there is room (which makes it a hit on THIS call, because the fill and the + // kernel are on one stream in that order), and otherwise it stays a miss for the CPU. + d.n_hits = 0; + for (int64_t i = 0; i < k; ++i) { + const int64_t e = ids[i]; + d.is_hit[(size_t) i] = 0; + if (e < 0 || e >= d.n_expert) continue; // out of range: the pool refuses it, with a message + int32_t slot = d.cache->slot_of(d.layers, e); + if (slot == kNotResident) { + const int32_t cand = d.cache->admit(d.layers, e); + if (cand == kNotResident) { + ++d.cache_refused; + continue; + } + // `blob` is asked ONLY for an expert about to be filled, so the source's read counter stays a + // count of distinct experts moved rather than of looks. + const uint8_t* b = d.src->blob(d.layers, e); + std::string ferr; + if (b == nullptr || !d.cache->fill_slot(cand, b, cs, ferr, (int64_t) strata::kernels::cpu::expert_layout().blob_bytes(d.layers))) { + d.failed = true; + d.fail = "the expert cache could not fill a slot"; + d.fail_layer = d.layers; + d.fail_expert = e; + return; + } + ++d.cache_admitted; + slot = cand; + } else { + ++d.cache_hits; + } + d.is_hit[(size_t) i] = 1; + d.h_slot[(size_t) d.n_hits] = slot; + d.h_dst[(size_t) d.n_hits] = (int32_t) i; + ++d.n_hits; + } + d.decided = true; + if (d.n_hits <= 0) return; // nothing resident yet: no GPU work, and nothing for `Combine` to add + + const size_t list_bytes = (size_t) d.n_hits * sizeof(int32_t); + // `hit_out` is ZEROED rather than overwritten: the kernel writes only the rows this layer's hits own, + // so a row that was a hit last layer and a miss this one would still hold last layer's expert and + // `add_inplace` would sum it in. Finite, plausible, wrong. + if (cudaMemsetAsync(d.hit_out, 0, (size_t) d.parts_elems * sizeof(float), cs) != cudaSuccess || + cudaMemcpyAsync(d.d_slot, d.h_slot.data(), list_bytes, cudaMemcpyHostToDevice, cs) != cudaSuccess || + cudaMemcpyAsync(d.d_dst, d.h_dst.data(), list_bytes, cudaMemcpyHostToDevice, cs) != cudaSuccess) { + d.hit_fail = "the hit list could not be staged"; + d.failed = true; + d.fail = d.hit_fail; + return; + } + // The activation is quantized HERE rather than reused from `s.moe.x_q8_0`, which `post[l-1]` wrote from + // the PREVIOUS layer's `mixed`. `pre[l]` has since overwritten `mixed`, so that buffer is a layer stale + // - and a stale activation produces a perfectly finite expert for the wrong input. + // **R4.2h: THE SCALED QUANTIZER, SO A HIT REPRODUCES A MISS.** The CPU pool quantizes this same + // activation with `act_quant_q8_1` and multiplies by the fp32 `ActQ::scale`; `quantize_q8_0` writes + // an fp16 `d` instead, and `bench/micro/act_quant_parity.cu` measured **80 of 80 chunks differing by + // up to 4.761e-04 relative**. `quantize_q8_0_scaled` adopts the CPU's rule and scale, and the kernel + // takes the fp32 array. Falling back to the old path would silently reintroduce the divergence, so + // the scales are required here rather than optional. + if (d.x_q8_0_hit_scale == nullptr) { + d.failed = true; + d.fail = "the hit path has no fp32 activation scales (R4.2h)"; + return; + } + strata::kernels::quantize_q8_0_scaled(d.mixed, d.x_q8_0_hit, d.x_q8_0_hit_scale, strata::kernels::cpu::H, + cs); + if (d.hit_cpu_order) + strata::kernels::moe_hit_grouped_s2_cpu_order(d.cache_base, d.d_slot, d.d_dst, d.n_hits, + d.cache_blob, d.x_q8_0_hit, d.hit_scratch, d.hit_out, cs, d.x_q8_0_hit_scale); + else + strata::kernels::moe_hit_grouped_s2(d.cache_base, d.d_slot, d.d_dst, d.n_hits, d.cache_blob, + d.x_q8_0_hit, d.hit_scratch, d.hit_out, cs, d.x_q8_0_hit_scale); + d.hit_pending = true; + if (d.hit_done != nullptr) cudaEventRecord((cudaEvent_t) d.hit_done, cs); + // The A/B arm: ONE driver entry here, and nothing else changes. If the work was waiting for the host + // to enter the driver, this is what lets it start while the pool runs. + if (d.hit_poke && d.hit_done != nullptr) (void) cudaEventQuery((cudaEvent_t) d.hit_done); + return; + } + + // Combine: `parts += hit_out`, stream-ordered after the misses were copied into `parts`. + if (!d.hit_pending) return; + d.hit_pending = false; + // Did the GPU get the hit work done while the CPU was in the pool? This query is itself a driver entry, + // so it is the LAST chance to observe a late start: a NOT-READY here means the work had not finished by the + // time the pool returned, and with no poke in front of it that can only be because it began after. + if (d.hit_done != nullptr) { + if (cudaEventQuery((cudaEvent_t) d.hit_done) == cudaSuccess) ++d.hit_ready; + else ++d.hit_late; + } + strata::kernels::add_inplace(d.parts_out, d.hit_out, d.parts_elems, cs); +} + +// ================================ THE RESIDENT ARENA (R2.1) ================================ + +// Plan v0.3 P6: the arena from the model's shard 1. Each layer's gate, up and down tensors hold the 512 experts +// one after another; they are read in chunks and each expert's slice lands at its place in the blob +// [gate rows | up rows | down rows] - the layout tools/iq_pack.py would have written to experts.bin. +LoadStats load_experts_gguf(const std::string& gguf, uint8_t* dst, const strata::kernels::cpu::ExpertLayout& lay, + int threads) { + LoadStats st; + st.layers = (uint64_t) lay.n_layers; + const auto t0 = std::chrono::steady_clock::now(); + std::atomic next{0}; + std::atomic bad{false}; + auto worker = [&]() { + std::ifstream f(gguf, std::ios::binary); + if (!f) { bad = true; return; } + std::vector buf; + for (;;) { + const int64_t l = next.fetch_add(1); + if (l >= lay.n_layers || bad) break; + const auto& fm = lay.fmt[(size_t) l]; + const uint64_t blob = lay.bytes[(size_t) l]; + const uint64_t per[3] = {fm.up_off, fm.up_off, blob - fm.down_off}; + const uint64_t at[3] = {0, fm.up_off, fm.down_off}; + for (int r = 0; r < 3; ++r) { + const uint64_t src = lay.gguf_off[(size_t) (3 * l + r)]; + const uint64_t total = per[r] * (uint64_t) lay.n_expert; + const uint64_t chunk = per[r] * 16; // 16 experts per read + buf.resize((size_t) chunk); + for (uint64_t done = 0; done < total; done += chunk) { + const uint64_t n = std::min(chunk, total - done); + f.seekg((std::streamoff) (src + done)); + f.read((char*) buf.data(), (std::streamsize) n); + if ((uint64_t) f.gcount() != n) { bad = true; return; } + for (uint64_t k = 0; k < n / per[r]; ++k) { + const uint64_t e = done / per[r] + k; + std::memcpy(dst + lay.blob_offset(l, (int64_t) e) + at[r], buf.data() + k * per[r], (size_t) per[r]); + } + } + } + } + }; + std::vector pool; + for (int i = 1; i < threads; ++i) pool.emplace_back(worker); + worker(); + for (auto& t : pool) t.join(); + if (bad) { + st.seconds = -1.0; + return st; + } + st.bytes = lay.total; + st.seconds = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + return st; +} + +ArenaExpertSource::~ArenaExpertSource() { close(); } + +bool ArenaExpertSource::open(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, int threads, + std::string& err) { + close(); + const std::string path = pack_dir + "/experts.bin"; + // plan v0.3 P6: the layout (canonical, or a native pack's per-layer blobs) was loaded by the driver + const strata::kernels::cpu::ExpertLayout& lay = strata::kernels::cpu::expert_layout(); + if (lay.n_layers != n_layers || lay.n_expert != n_expert) { + err = "ArenaExpertSource: the expert layout was loaded for a different geometry"; + return false; + } + const int64_t blob = (int64_t) lay.max_blob; + const uint64_t want = lay.total; + + // plan v0.3 P6: no experts.bin in a native pack -> the experts come straight from the GGUF + const bool from_gguf = !std::ifstream(path, std::ios::binary) && lay.native && !lay.gguf_off.empty() && !gguf_.empty(); + // SIZE CHECK BEFORE THE ALLOCATION, not after. A wrong pack should name the two numbers rather than spend + // 34 GB and a minute of loading first. + if (!from_gguf) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) { err = "ArenaExpertSource: cannot open " + path; return false; } + const uint64_t got = (uint64_t) f.tellg(); + if (got != want) { + char buf[400]; + std::snprintf(buf, sizeof buf, + "ArenaExpertSource: %s is %llu B but %lld layers x %lld experts (blobs up to %lld B) " + "make %llu B - this is not the pack this geometry came from", + path.c_str(), (unsigned long long) got, (long long) n_layers, (long long) n_expert, + (long long) blob, (unsigned long long) want); + err = buf; + return false; + } + } + + // one layer per registration slice, so no expert straddles two registrations. The arena is one blob + // longer than the file: a copy of a whole VRAM slot (the largest blob) may then start at any expert. + std::vector bounds, loff, lbytes; + for (int64_t l = 0; l < n_layers; ++l) { + bounds.push_back(lay.layer_offset(l)); + loff.push_back(lay.layer_offset(l)); + lbytes.push_back(lay.blob_bytes(l) * (uint64_t) n_expert); + } + bounds.push_back(want); + PinnedArena* a = new PinnedArena(want + (uint64_t) blob, bounds); + if (!a->valid()) { + delete a; + err = "ArenaExpertSource: the arena could not be reserved (" + std::to_string(want) + " B)"; + return false; + } + const LoadStats st = from_gguf ? load_experts_gguf(gguf_, a->data(), lay, threads) + : load_experts_ranges(path, a->data(), loff, lbytes, threads, /*chunk=*/8u << 20); + if (st.bytes != want) { + delete a; + err = "ArenaExpertSource: the load read " + std::to_string(st.bytes) + " B of " + std::to_string(want); + return false; + } + arena_ = a; + base_ = a->data(); + pinned_bytes_ = a->registered_bytes; + // plan v0.3 P6: device aliases of the mapped registration, for the PCIe share of the misses + dev_slice_.clear(); + slice_bytes_ = a->slice_bytes; + if (a->registered_bytes > 0) { + std::vector starts = a->slice_bytes > 0 ? a->slice_starts : std::vector{0}; + for (uint64_t off : starts) { + void* d = nullptr; + if (cudaHostGetDevicePointer(&d, (void*) (base_ + off), 0) != cudaSuccess) { + (void) cudaGetLastError(); + dev_slice_.clear(); + break; + } + dev_slice_.push_back((const uint8_t*) d); + } + } + blobs_ = n_layers * n_expert; + n_expert_ = n_expert; + reads_ = 0; + note_ = a->note; + gib_per_s_ = st.gib_per_second(); + return true; +} + +void ArenaExpertSource::close() { + if (arena_ != nullptr) { + delete (PinnedArena*) arena_; + arena_ = nullptr; + } + base_ = nullptr; + blobs_ = 0; + n_expert_ = 0; +} + +bool ArenaExpertSource::pinned(int64_t layer, int64_t expert) const { + if (base_ == nullptr || layer < 0 || expert < 0 || expert >= n_expert_) return false; + const auto& lay = strata::kernels::cpu::expert_layout(); + return lay.blob_offset(layer, expert) + lay.blob_bytes(layer) <= pinned_bytes_; +} + +const uint8_t* ArenaExpertSource::device_alias(int64_t layer, int64_t expert) const { + if (dev_slice_.empty() || !pinned(layer, expert)) return nullptr; + const auto& lay = strata::kernels::cpu::expert_layout(); + if (slice_bytes_ == 0) return dev_slice_[0] + lay.blob_offset(layer, expert); + // one registration slice per layer + if ((size_t) layer >= dev_slice_.size()) return nullptr; + return dev_slice_[(size_t) layer] + (uint64_t) expert * lay.blob_bytes(layer); +} + +const uint8_t* ArenaExpertSource::blob(int64_t layer, int64_t expert) { + if (base_ == nullptr) return nullptr; + if (layer < 0 || expert < 0 || expert >= n_expert_) return nullptr; + const int64_t idx = layer * n_expert_ + expert; + if (idx < 0 || idx >= blobs_) return nullptr; + ++reads_; + // Pointer arithmetic into resident memory. No fault, no copy, no mapping - which is the entire point of + // this class over `FileExpertSource`. + return base_ + strata::kernels::cpu::expert_layout().blob_offset(layer, expert); +} + +} // namespace strata::core diff --git a/src/core/graph.cpp b/src/core/graph.cpp new file mode 100644 index 00000000..2e9c7e70 --- /dev/null +++ b/src/core/graph.cpp @@ -0,0 +1,146 @@ +// src/core/graph.cpp - P2.S5: the GraphRegistry implementation. +#include "strata/core/graph.hpp" + +#include + +#include +#include + +namespace strata::core { +namespace { + +/// Fills `err` from the CUDA runtime, naming the call that failed. A bare "invalid argument" with no call +/// site is the least useful error this API can produce and the easiest to avoid. +bool fail(std::string& err, const char* what, cudaError_t e) { + err = std::string(what) + ": " + cudaGetErrorString(e); + return false; +} + +} // namespace + +CapturedGraph& CapturedGraph::operator=(CapturedGraph&& o) noexcept { + if (this != &o) { + reset(); + graph_ = o.graph_; + exec_ = o.exec_; + done_ = o.done_; + nodes_ = o.nodes_; + o.graph_ = nullptr; + o.exec_ = nullptr; + o.done_ = nullptr; + o.nodes_ = 0; + } + return *this; +} + +void CapturedGraph::reset() { + // ORDER MATTERS: destroying a graph exec BLOCKS until the launch it is destroying has completed, so + // destroying it before the event is harmless but destroying it after a spin that was supposed to observe + // completion would silently become the thing that caused it. `bench/micro/graph_capture.cu` was fooled by + // exactly this once - it looked like captured graphs made a doorbell visible and direct launches did not. + if (exec_) { cudaGraphExecDestroy(exec_); exec_ = nullptr; } + if (graph_) { cudaGraphDestroy(graph_); graph_ = nullptr; } + if (done_) { cudaEventDestroy(done_); done_ = nullptr; } + nodes_ = 0; +} + +bool CapturedGraph::begin(void* stream, std::string& err) { + if (graph_ || exec_) { err = "begin: this CapturedGraph is already recorded"; return false; } + const cudaError_t e = cudaStreamBeginCapture((cudaStream_t) stream, cudaStreamCaptureModeThreadLocal); + if (e != cudaSuccess) return fail(err, "cudaStreamBeginCapture", e); + return true; +} + +bool CapturedGraph::end(void* stream, std::string& err) { + cudaError_t e = cudaStreamEndCapture((cudaStream_t) stream, &graph_); + if (e != cudaSuccess) { graph_ = nullptr; return fail(err, "cudaStreamEndCapture", e); } + + nodes_ = 0; + e = cudaGraphGetNodes(graph_, nullptr, &nodes_); + if (e != cudaSuccess) return fail(err, "cudaGraphGetNodes", e); + // A capture that recorded NOTHING is a wiring mistake, and a graph that replays nothing produces no error + // and no output - the silent kind of failure this project keeps paying for. + if (nodes_ == 0) { err = "end: the capture recorded ZERO nodes - the body launched nothing"; reset(); return false; } + + e = cudaGraphInstantiate(&exec_, graph_, nullptr, nullptr, 0); + if (e != cudaSuccess) return fail(err, "cudaGraphInstantiate", e); + + // The completion event is recorded ONCE and reused: `launch` records it again after each replay, which is + // what makes `wait_ms` a query rather than a sync. + e = cudaEventCreateWithFlags(&done_, cudaEventDisableTiming); + if (e != cudaSuccess) return fail(err, "cudaEventCreateWithFlags", e); + return true; +} + +bool CapturedGraph::launch(void* stream, std::string& err) const { + if (!exec_) { err = "launch: not recorded"; return false; } + cudaError_t e = cudaGraphLaunch(exec_, (cudaStream_t) stream); + if (e != cudaSuccess) return fail(err, "cudaGraphLaunch", e); + e = cudaEventRecord(done_, (cudaStream_t) stream); + if (e != cudaSuccess) return fail(err, "cudaEventRecord", e); + return true; +} + +bool CapturedGraph::wait_ms(int timeout_ms) const { + if (!done_) return false; + // A BOUNDED wait, and the bound is the point: an unbounded spin turns a protocol bug into a hung run, and + // a hung run says nothing about which side is stuck. `cudaEventQuery` is a QUERY - it does not block and + // it does not synchronise - so this satisfies P2.X3 while still giving the driver the call it needs to + // flush the submission. See NOTE 1 in the header: without a driver call here the work never starts. + // + // The deadline is a REAL CLOCK, not a count of pause instructions: `_mm_pause` is a few cycles, so + // counting pauses as microseconds would make the timeout tens of times longer than the caller asked for - + // a timeout that does not time out is worse than none, because it reports a hang as a pass. + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + for (;;) { + const cudaError_t q = cudaEventQuery(done_); + if (q == cudaSuccess) return true; + if (q != cudaErrorNotReady) return false; // a real error, not "not finished" + if (std::chrono::steady_clock::now() >= deadline) return false; + for (int i = 0; i < 64; ++i) _mm_pause(); + } +} + +bool GraphRegistry::record(LayerType type, int n_tokens, const std::function& body, std::string& err) { + const Key k{(int) type, n_tokens}; + if (graphs_.count(k)) return true; // already recorded: the point of a registry + + CapturedGraph g; + if (!g.begin(stream_, err)) return false; + body(); // the caller launches into the captured stream + // The body must not have failed silently. An error state left on the stream would make EndCapture + // succeed with a broken graph, so it is checked and cleared first. + const cudaError_t body_err = cudaGetLastError(); + if (body_err != cudaSuccess) { + // Abandon the capture without instantiating anything. + cudaGraph_t junk = nullptr; + cudaStreamEndCapture((cudaStream_t) stream_, &junk); + if (junk) cudaGraphDestroy(junk); + return fail(err, "the capture body left a CUDA error", body_err); + } + if (!g.end(stream_, err)) return false; + + graphs_.emplace(k, std::move(g)); + ++captures_; + return true; +} + +const CapturedGraph* GraphRegistry::find(LayerType type, int n_tokens) const { + const auto it = graphs_.find(Key{(int) type, n_tokens}); + return it == graphs_.end() ? nullptr : &it->second; +} + +bool GraphRegistry::launch(LayerType type, int n_tokens, int timeout_ms, std::string& err) const { + const CapturedGraph* g = find(type, n_tokens); + if (!g) { + char buf[128]; + std::snprintf(buf, sizeof buf, "launch: no graph recorded for %s n=%d", to_string(type), n_tokens); + err = buf; + return false; + } + if (!g->launch(stream_, err)) return false; + if (!g->wait_ms(timeout_ms)) { err = "launch: timed out waiting for the graph to complete"; return false; } + return true; +} + +} // namespace strata::core diff --git a/src/core/layer.cpp b/src/core/layer.cpp new file mode 100644 index 00000000..c662b179 --- /dev/null +++ b/src/core/layer.cpp @@ -0,0 +1,1063 @@ + +// src/core/layer.cpp - the GDN layer, composed. See the header for the operation order and its traps. +#include "strata/core/layer.hpp" +#include "strata/core/native_head.hpp" +#include "strata/kernels/bf16_bits.hpp" +#include "strata/kernels/bf16_gemv.hpp" +#include "strata/kernels/elementwise.hpp" +#include "strata/kernels/gdn.hpp" +#include "strata/kernels/qsa.hpp" +#include "strata/kernels/kv_q8.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/rope.hpp" +#include "strata/kernels/router_top10.hpp" +#include "strata/kernels/s2_gemv_q8.hpp" +#include "strata/kernels/s_gemv.hpp" +#include "strata/kernels/shared_expert.hpp" +#include "strata/kernels/native_mmvq.hpp" +#include "strata/kernels/native_moe.hpp" +#include "strata/kernels/native_gdn.hpp" +#include "strata/kernels/native_gdn_preprocess.hpp" +#include "strata/kernels/native_router.hpp" +#include "strata/kernels/native_qsa.hpp" +#include "strata/kernels/native_qsa_indexer.hpp" +#include "strata/kernels/native_rope.hpp" +#include "strata/kernels/native_flash_attn.hpp" +#include "strata/kernels/fused_gr.hpp" +#include "strata/kernels/qsa_decode_attn.hpp" +#include "strata/kernels/fused_gdn.hpp" +#include "strata/kernels/qsa_select.hpp" +#include +#include +#include +#include +#include +#include +namespace strata::core { +namespace { bool g_shared_early = true; bool g_fused_gr = false; bool g_fast_attn = true; bool g_publish_kernel = true; bool g_fused_gdn = true; bool g_fast_select = true; } +namespace {constexpr int Q8K_BYTES_PER_BLOCK = 292; +constexpr int Q8K_ELEMS_PER_BLOCK = 256; +constexpr float RMS_EPS = 1e-6f; +const std::string EMBEDDING_NAME = "token_embd.weight"; +// qwen4exp.attention.layer_norm_rms_epsilon, used for BOTH norms here +/// A Q8_K buffer needs `n` a multiple of 256 and `n/256` blocks of 292 bytes. +uint64_t q8k_bytes(int64_t n) { return (uint64_t) (n / Q8K_ELEMS_PER_BLOCK) * Q8K_BYTES_PER_BLOCK; } +/// `s_gemv_q8k` takes the canonical-form attributes; a `WeightRef` carries them, and a tensor that is NOT +/// quantized has none. Returns false and names the tensor rather than building a form out of zeroes - which +/// would decode every code as `0 + bias` and produce a perfectly finite wrong answer. +bool sform_of(const WeightRef& r, strata::kernels::SForm& f, const std::string& name, std::string& err) { if (!r.quantized()) { err = name + " is not a quantized tensor, so it has no S-form"; return false; } f.code_bits = r.code_bits; f.code_bias = r.code_bias; f.group_elems = r.group_elems; f.codebook = r.codebook_iq4nl ? strata::kernels::Codebook::Iq4Nl : strata::kernels::Codebook::Affine; f.has_offset = r.has_offset; f.act_kind = r.act_kind; +// carried, not derived - see the note on `SForm::act_kind` +return true;} +/// The three canonical planes of a quantized tensor, located INSIDE the loaded region. +// +// +// **THIS FUNCTION USED TO RE-DERIVE THE LAYOUT, AND THAT IS HOW A WIDTH BUG SURVIVED A ROUND.** It computed +/// `n_groups = n_in / group_elems` and then `scales_bytes = n_out * n_groups * sizeof(float)` - 4 bytes per +/// scale for every tensor. 90 of the 303 quantized tensors hold fp16 scales in the pack (all 58 Q2_0 expert +/// tensors, plus Q4_0, Q5_0, Q8_0 and IQ4_NL), so for those the computed plane was TWICE the real one and the +/// three planes did not add up to the tensor. The first diagnosis was "the manifest's `group_elems` must be +/// wrong, the real group is 64"; it is not, and an audit of all 303 tensors against `scales_fp16` found zero +/// inconsistencies. **The unexamined input was the scale WIDTH, not the group COUNT.** +// +// +// The loader now widens fp16 scales to f32 on the way into the arena and records the resulting plane sizes in +/// the `WeightRef`, so this function has nothing left to derive - it reads what was loaded. The redundancy is +/// deliberate: the sizes come from ONE place (the index, checked against the manifest by `pack_index.py` and +/// against the span by the loader), and this function only checks that they describe the tensor it was given. +// +// +// **THE PLANE KEY IS `offsets`, NOT `mins`, AND THAT COST 136 MiB.** `tools/pack_index.py` and +/// `tools/pack_budget.py` both looked for `mins` and both therefore omitted the offset plane of 54 Q4_K/Q5_K +/// tensors - so the arena was sized without it AND the bytes were never copied. Two tools agreeing is only +/// evidence when they do not share the assumption that is wrong. This function refuses rather than guessing, +/// which is what caught it. +struct Planes { const uint8_t* codes = nullptr; const float* scales = nullptr; const float* offset = nullptr; +///< null when the form has none +}; +bool plane_ptrs(const WeightRef& r, const std::string& name, Planes& out, std::string& err) { +// S2, S4 AND S8 ALL SPLIT THE SAME WAY. The plane LOCATION does not depend on the code width - the three +// sizes come from the index and are checked against the tensor below - so the guard is here to catch a +// tensor that is not quantized at all, not to pick a decoder. WHICH KERNEL reads the planes is the +// caller's choice and the two differ: an S2 tensor's activation contract is Q8_0 (`s2_gemv_q8`) while a +// K-quant's is Q8_K (`s_gemv_q8k`). This used to accept only 4 and 8, which refused `attn_q` - a Q2_0 +// tensor and the reason the QSA layer could not be composed at all. +if (r.code_bits != 2 && r.code_bits != 4 && r.code_bits != 8) { err = name + ": code_bits " + std::to_string(r.code_bits) + " is not an S2/S4/S8 form"; return false; } const uint64_t end = r.codes_bytes + r.scales_bytes + r.offset_bytes; if (r.codes_bytes == 0 || r.scales_bytes == 0 || end != r.bytes) { char buf[320]; std::snprintf(buf, sizeof buf, "%s: the planes add up to %llu B but the tensor is %llu B (codes %llu, scales %llu, " "offsets %llu) - what the loader recorded is not the layout that was loaded", name.c_str(), (unsigned long long) end, (unsigned long long) r.bytes, (unsigned long long) r.codes_bytes, (unsigned long long) r.scales_bytes, (unsigned long long) r.offset_bytes); err = buf; return false; } if (r.has_offset != (r.offset_bytes != 0)) { err = name + ": has_offset is " + std::to_string(r.has_offset ? 1 : 0) + " but the offset plane is " + std::to_string(r.offset_bytes) + " B"; return false; } const uint8_t* base = (const uint8_t*) r.data; out.codes = base; out.scales = (const float*) (base + r.codes_bytes); out.offset = r.offset_bytes ? (const float*) (base + r.codes_bytes + r.scales_bytes) : nullptr; return true;} +constexpr int TPR = 32; +bool native_bf16_projections = false; +bool native_flash_attn_short = false; + +void project_bf16(const float* x, const uint16_t* x_bf16, const uint16_t* weights, float* out, + int64_t n_in, int64_t n_out, bool split, void* stream) { + using namespace strata::kernels; + if (native_bf16_projections) bf16_gemv_fp32_mmvf(x, weights, out, n_in, n_out, stream); + else if (split) bf16_gemv_split(x_bf16, weights, out, n_in, n_out, TPR, stream); + else bf16_gemv(x_bf16, weights, out, n_in, n_out, stream); +} +///< threads per row for the row-split GEMVs. +/// **MEASURED, NOT ASSUMED, AND 64 IS NOT BETTER.** `dense_pass.exe` drives the same split kernels at +/// threads_per_row 64 and reports 248.5 GB/s, so matching it looked like free performance. It is not: +/// TPR=64 gives 11.75 tok/s against 32's 11.99 with experts, and 20.19 against 20.26 without - identical +/// inside noise and marginally worse on both. That is also what the stage table predicts, because the GEMVs +/// are only ~16% of a token, so a few percent there cannot move the total. Kept at 32; the sweep that would +/// actually settle it is per-tensor, not global, and belongs with the fusion work rather than before it. +///< threads per row for the row-split GEMVs +/// Writes the sequence number into MAPPED PINNED memory. A KERNEL, not cudaEventRecord - round 199 found +/// that an event record inside a capture is silently dropped, while this is captured normally and the host can +/// read its result MID-GRAPH. One thread: it is a store. +// (the ring kernel lives in src/kernels/cuda/elementwise.cu; this file is HOST code) +/// WHICH ACTIVATION A QUANTIZED WEIGHT WANTS, AND IT IS A PROPERTY OF THE **TENSOR**, NOT OF ITS ROLE. +// +// +// Rounds 205-215 ran `gdn_layer` and `qsa_layer` on the assumption that a name implies a family: `attn_qkv` is +/// a K-quant, `attn_q` is Q2_0, and so on. **The pack does not work that way.** The same name is quantized +/// per LAYER, and the spread is wide: +// +// +// attn_qkv IQ4_XS x13, Q3_K x18, Q2_0 x1, Q4_K x4 (36 GDN layers) +/// attn_gate Q3_K x25, IQ4_XS x4, Q4_K x3, Q2_0 x4 +/// attn_q Q3_K x6, IQ4_XS x4, Q2_0 x2 (12 QSA layers) +// +// +// `docs/activation-contract.md` fixes the rule per TYPE, and it is short: +// +// +// code_bits == 2 -> Q8_0, via `quantize_q8_0` + `s2_gemv_q8` +/// anything else quantized -> Q8_K, via `quantize_q8_K` + `s_gemv_q8k_split` +// +// +// Getting it wrong is worth 0.6-1.4% on the GEMV - a plausible vector, not a broken one - which is exactly +/// what Gate C1 exists to find and what nothing before C1 would have. `s_gemv_q8k`'s own guard is what caught +/// it here (`code_bits 2 has no Q8_K contract`). +// +// +// `x80` and `xq8k` are the two quantized images of the SAME activation; a caller produces both once and this +/// picks. Producing only the one it thinks it needs is how the assumption gets baked in again. +bool gemv_quantized(const WeightRef& w, const Planes& p, const strata::kernels::SForm& f, const uint8_t* x80, const uint8_t* xq8k, float* y, int64_t n_in, int64_t n_out, const std::string& name, void* stream, std::string& err, const float* x_f32 = nullptr, bool x_q8_1_ready = false) { + using namespace strata::kernels; + if (w.native_data) { + if (!x_f32 || !w.native_q8_1 || !stream || n_in != w.ne0 || n_out != w.ne1) { + err = name + ": native projection requires matching FP32 input and session scratch"; + return false; + } + try { + // Plan v0.3 P3: a caller whose previous native projection quantized the SAME x into the shared + // scratch, with no native projection in between, passes x_q8_1_ready and the quantize is skipped. + if (!x_q8_1_ready) native_quantize_q8_1(x_f32, w.native_q8_1, (int) n_in, 1, stream); + native_mmvq(w.native_type, w.native_data, w.native_q8_1, y, + (int) n_in, (int) n_out, 1, stream); + } catch (const std::exception& error) { + err = name + ": " + error.what(); + return false; + } + return true; + } + if ((w.code_bits == 2 || !w.wants_q8k()) ? !x80 : !xq8k) { + err = name + ": missing canonical quantized activation"; + return false; + } + if (w.code_bits == 2) { +// S2 carries no offset plane and no SForm: its attributes are fixed (2 bits, group 64, bias -1), which +// is why `s2_gemv_q8` takes neither. +if (w.has_offset || p.offset != nullptr) { err = name + ": an S2 form must have no offset plane"; return false; } if (w.group_elems != 64 || w.code_bias != -1) { err = name + ": an S2 form must be group 64 with bias -1, this one is group " + std::to_string(w.group_elems) + " with bias " + std::to_string(w.code_bias); return false; } s2_gemv_q8(x80, p.codes, p.scales, y, n_in, n_out, TPR, stream); return true; } +// **A LEGACY 4/8-BIT FORM WANTS Q8_0, NOT Q8_K, AND `code_bits` CANNOT TELL YOU WHICH.** This used to be +// `code_bits == 2 ? Q8_0 : Q8_K`, which is CORRECT ONLY BY LUCK: every legacy tensor in this pack apart +// from `ffn_down_shexp` is Q2_0, whose attributes happen to be S2's. Q5_0 and Q5_K are both 8-bit with +// bias -16 and differ only in `has_offset`; IQ4_NL and IQ4_XS differ in NOTHING the S-form carries. The +// kind therefore comes from the manifest's `source_type`, through the index (LEDGER L54/L55). +if (!w.wants_q8k()) { s_gemv_q8_0_split(x80, p.codes, p.scales, p.offset, y, n_in, n_out, f, stream); return true; } s_gemv_q8k_split(xq8k, p.codes, p.scales, p.offset, y, n_in, n_out, f, stream); return true;}} +// namespace +void layer_set_native_bf16(bool enabled) { native_bf16_projections = enabled; } +void layer_set_native_flash_attn_short(bool enabled) { native_flash_attn_short = enabled; } +namespace { bool g_kv_int8 = false; } +void qsa_set_kv_int8(bool enabled) { g_kv_int8 = enabled; } +bool qsa_kv_int8() { return g_kv_int8; } +uint64_t gdn_buffers_bytes(const ModelGeometry& g) { const int64_t C = g.ssm_conv_channels; const int64_t V = g.ssm_value_dim; const uint64_t parts[] = { q8k_bytes(g.n_embd), +// x_q8k +(uint64_t) (g.n_embd / 32) * 34, +// x_q8_0 +(uint64_t) g.n_embd * 2, +// x_bf16 +(uint64_t) C * 4, +// qkv +(uint64_t) C * 4, +// conv_out +(uint64_t) C * 4, +// h +(uint64_t) g.ssm_v_heads * 4, +// alpha +(uint64_t) g.ssm_v_heads * 4, +// beta +(uint64_t) g.ssm_v_heads * 4, +// gate +(uint64_t) g.ssm_v_heads * g.ssm_state_size * 4, +// o +(uint64_t) V * 4, +// z +(uint64_t) V * 4, +// y +q8k_bytes(V), +// y_q8k +(uint64_t) (V / 32) * 34, +// y_q8_0 +(uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size * 4, +// state +(uint64_t) C * (g.ssm_d_conv - 1) * 4, +// conv_state +}; uint64_t total = 0; for (uint64_t v : parts) total += (v + 15) & ~(uint64_t) 15; return total;} +uint64_t gdn_buffers_init(const ModelGeometry& g, void* base, GdnBuffers& b) { const int64_t C = g.ssm_conv_channels; const int64_t V = g.ssm_value_dim; const uint64_t parts[] = { q8k_bytes(g.n_embd), (uint64_t) (g.n_embd / 32) * 34, (uint64_t) g.n_embd * 2, (uint64_t) C * 4, (uint64_t) C * 4, (uint64_t) C * 4, (uint64_t) g.ssm_v_heads * 4, (uint64_t) g.ssm_v_heads * 4, (uint64_t) g.ssm_v_heads * 4, (uint64_t) g.ssm_v_heads * g.ssm_state_size * 4, (uint64_t) V * 4, (uint64_t) V * 4, q8k_bytes(V), (uint64_t) (V / 32) * 34, (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size * 4, (uint64_t) C * (g.ssm_d_conv - 1) * 4, }; uint8_t* p = (uint8_t*) base; void* ptr[16]; uint64_t total = 0; for (int i = 0; i < 16; ++i) { ptr[i] = p; const uint64_t al = (parts[i] + 15) & ~(uint64_t) 15; p += al; total += al; } b.x_q8k = (uint8_t*) ptr[0]; b.x_q8_0 = (uint8_t*) ptr[1]; b.x_bf16 = (uint16_t*) ptr[2]; b.qkv = (float*) ptr[3]; b.conv_out = (float*) ptr[4]; b.h = (float*) ptr[5]; b.alpha = (float*) ptr[6]; b.beta = (float*) ptr[7]; b.gate = (float*) ptr[8]; b.o = (float*) ptr[9]; b.z = (float*) ptr[10]; b.y = (float*) ptr[11]; b.y_q8k = (uint8_t*) ptr[12]; b.y_q8_0 = (uint8_t*) ptr[13]; b.state = (float*) ptr[14]; b.conv_state = (float*) ptr[15]; return total;} +void gdn_buffers_zero_state(const GdnBuffers& b, const ModelGeometry& g, void* stream) { const uint64_t st = (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size * sizeof(float); const uint64_t cs = (uint64_t) g.ssm_conv_channels * (g.ssm_d_conv - 1) * sizeof(float); cudaMemsetAsync(b.state, 0, st, (cudaStream_t) stream); cudaMemsetAsync(b.conv_state, 0, cs, (cudaStream_t) stream);} +// Forward-declared because `gdn_layer` and `qsa_layer` are both defined above the timer's own definition, and +// the sub-stage marks live inside them. +static void st_begin(int64_t layer, int slot, void* stream); +static void st_end(int64_t layer, int slot, void* stream); +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) { const LayerView v(tables, layer); const int64_t C = g.ssm_conv_channels; +// ---- resolve. Every one of these was shape-checked by `check_layer` at load time, so a miss here is a +// NAME problem and not a geometry one, and the message says which name. +const WeightRef* w_qkv = v.get("attn_qkv.weight"); const WeightRef* w_gate = v.get("attn_gate.weight"); const WeightRef* w_out = v.get("ssm_out.weight"); const WeightRef* w_alpha = v.get("ssm_alpha.weight"); const WeightRef* w_beta = v.get("ssm_beta.weight"); const WeightRef* w_conv = v.get("ssm_conv1d.weight"); const WeightRef* w_norm = v.get("ssm_norm.weight"); const WeightRef* w_dt = v.get("ssm_dt.bias"); const WeightRef* w_a = v.get("ssm_a"); const char* missing = !w_qkv ? "attn_qkv.weight" : !w_gate ? "attn_gate.weight" : !w_out ? "ssm_out.weight" : !w_alpha ? "ssm_alpha.weight" : !w_beta ? "ssm_beta.weight" : !w_conv ? "ssm_conv1d.weight" : !w_norm ? "ssm_norm.weight" : !w_dt ? "ssm_dt.bias" : !w_a ? "ssm_a" : nullptr; if (missing) { err = v.name(missing) + " is missing"; return false; } +// The conv kernel and the two norms are F32 source types, and the loader copied them verbatim - so their +// device bytes ARE f32 and can be handed straight to the kernels that want `const float*`. +const float* conv_kernel = (const float*) w_conv->data; const float* ssm_norm = (const float*) w_norm->data; const float* dt = (const float*) w_dt->data; const float* ssm_a = (const float*) w_a->data; strata::kernels::SForm f_qkv, f_gate, f_out; if (!sform_of(*w_qkv, f_qkv, v.name("attn_qkv.weight"), err)) return false; if (!sform_of(*w_gate, f_gate, v.name("attn_gate.weight"), err)) return false; if (!sform_of(*w_out, f_out, v.name("ssm_out.weight"), err)) return false; Planes p_qkv, p_gate, p_out; if (!plane_ptrs(*w_qkv, v.name("attn_qkv.weight"), p_qkv, err)) return false; if (!plane_ptrs(*w_gate, v.name("attn_gate.weight"), p_gate, err)) return false; if (!plane_ptrs(*w_out, v.name("ssm_out.weight"), p_out, err)) return false; using namespace strata::kernels; cudaStream_t st = (cudaStream_t) stream; +// ---- 1. the activation, in EVERY format a weight on this layer might ask for. BOTH quantized images are +// produced, because which one is wanted is a property of the TENSOR and the pack mixes them by +// layer - producing only the "right" one is how the per-role assumption gets baked back in. +st_begin(layer, 8, stream); + // Plan v0.3 P3: the canonical activation images only when a canonical projection reads them. + if (!w_qkv->native_data || !w_gate->native_data) { + quantize_q8_K(mixed, b.x_q8k, g.n_embd, stream); + quantize_q8_0(mixed, b.x_q8_0, g.n_embd, stream); + } + st_end(layer, 8, stream); +// ---- 2. qkv = wqkv @ x, with the activation THIS layer's `attn_qkv` asks for +st_begin(layer, 9, stream); if (!gemv_quantized(*w_qkv, p_qkv, f_qkv, b.x_q8_0, b.x_q8k, b.qkv, g.n_embd, C, v.name("attn_qkv.weight"), stream, err, mixed)) return false; st_end(layer, 9, stream); +// ---- 3. conv, then SiLU on the WHOLE conv output - before the q|k|v split, which is what +// `ref/gdn.py` L262 says and what makes the split a reshape rather than a copy. +st_begin(layer, 10, stream); +// Plan v0.3 P3: under the native contract, conv + SiLU + both L2 norms are one kernel, and alpha/beta with their +// epilogues another (`fused_gdn.hpp`). +const bool fused_pre = g_fused_gdn && native_gdn_enabled() && native_bf16_projections && g.ssm_d_conv == 4 && + g.ssm_state_size == 128; +if (fused_pre) { + fused_gdn_conv_l2(b.conv_state, b.qkv, conv_kernel, b.h, (int) C, (int) (2 * g.ssm_k_heads), RMS_EPS, stream); +} else +try { + if (native_gdn_enabled()) native_gdn_conv_silu(b.conv_state, b.qkv, conv_kernel, b.conv_out, b.h, C, g.ssm_d_conv, stream); + else { + gdn_conv_step(b.conv_state, b.qkv, conv_kernel, b.conv_out, C, g.ssm_d_conv, stream); + cudaMemcpyAsync(b.h, b.conv_out, (size_t) C * 4, cudaMemcpyDeviceToDevice, st); + silu_inplace(b.h, C, stream); + } +} catch (const std::exception& error) { err = v.name("gdn_conv_silu") + ": " + error.what(); return false; } +st_end(layer, 10, stream); +// ---- 4. q,k = l2_norm; v is NOT normalised. The three views are contiguous runs of the same buffer: +// q at [0, 2048), k at [2048, 4096), v at [4096, 10240), each 128-wide heads. const +st_begin(layer, 11, stream); int64_t qk = g.ssm_state_size * g.ssm_k_heads; +if (!fused_pre) try { + if (native_gdn_enabled()) { + native_gdn_l2_norm(b.h, g.ssm_k_heads, g.ssm_state_size, RMS_EPS, stream); + native_gdn_l2_norm(b.h + qk, g.ssm_k_heads, g.ssm_state_size, RMS_EPS, stream); + } else { + gdn_l2_norm(b.h, g.ssm_k_heads, g.ssm_state_size, RMS_EPS, stream); + gdn_l2_norm(b.h + qk, g.ssm_k_heads, g.ssm_state_size, RMS_EPS, stream); + } +} catch (const std::exception& error) { err = v.name("gdn_l2_norm") + ": " + error.what(); return false; } +// The recurrence needs another 1/sqrt(S) beyond Q/K normalization. The legacy +// recurrence consumes prescaled Q; pinned CUDA applies it after the readout dot. +// Keep the scale at that backend-specific boundary to preserve rounding order. +if (!native_gdn_enabled()) scale_inplace(b.h, qk, 1.0f / std::sqrt((float) g.ssm_state_size), stream); +st_end(layer, 11, stream); +// ---- 5. beta and the gate. The weights are BF16. Native single-token CUDA MMVF +// consumes F32 activations; the legacy path rounds them to BF16 first. +// +// THE ROW-SPLIT FORM, and here the case is stronger than the router's: `ssm_v_heads` is **48**, so +// the plain one-thread-per-row form puts 48 threads on a 48-SM GPU and each walks 2,560 elements +// alone. Measured 0.1072 ms each, against 0.0277 for the split - and there are two of them, in all +// 36 GDN layers. +st_begin(layer, 12, stream); + if (fused_pre) { + fused_gdn_ab(mixed, (const uint16_t*) w_alpha->data, (const uint16_t*) w_beta->data, dt, ssm_a, b.gate, b.beta, + (int) g.n_embd, (int) g.ssm_v_heads, stream); + } else { + if (!native_bf16_projections) f32_to_bf16_bulk(mixed, b.x_bf16, g.n_embd, stream); + project_bf16(mixed, b.x_bf16, (const uint16_t*) w_alpha->data, b.alpha, g.n_embd, g.ssm_v_heads, true, stream); + project_bf16(mixed, b.x_bf16, (const uint16_t*) w_beta->data, b.beta, g.n_embd, g.ssm_v_heads, true, stream); + } + if (!fused_pre) try { + if (native_gdn_enabled()) { + native_gdn_beta_gate(b.beta, g.ssm_v_heads, stream); + native_gdn_gate(b.alpha, dt, ssm_a, b.gate, g.ssm_v_heads, stream); + } else { + gdn_beta_gate(b.beta, g.ssm_v_heads, stream); + gdn_gate(b.alpha, dt, ssm_a, b.gate, 1, g.ssm_v_heads, stream); + } + } catch (const std::exception& error) { err = v.name("gdn_gate") + ": " + error.what(); return false; } + st_end(layer, 12, stream); st_begin(layer, 13, stream); + GdnShapes gs{g.ssm_state_size, g.ssm_k_heads, g.ssm_v_heads}; + // Plan v0.3 P3: with the native contract the step and the output norm are one kernel (after the z GEMV). + const bool fused_gdn = g_fused_gdn && native_gdn_enabled() && g.ssm_state_size == 128; + if (!fused_gdn) try { + if (native_gdn_enabled()) native_gdn_step(b.state, b.h, b.h + qk, b.h + 2 * qk, b.gate, b.beta, b.o, gs, stream); + else gdn_step(b.state, b.h, b.h + qk, b.h + 2 * qk, b.gate, b.beta, b.o, gs, stream); + } catch (const std::exception& error) { + err = v.name("gdn_step") + ": " + error.what(); + return false; + } + st_end(layer, 13, stream); st_begin(layer, 14, stream); +// ---- 6. the recurrence. q and k are the (16, 128) views; v is the (48, 128) view. The stage marks for +// it live at the end of the alpha/beta/gate line above, because the gate and the step are one sequence. +// ---- 7. z, then y = rms_norm(o) * ssm_norm * sigmoid(z). SIGMOID, not SiLU. +// qkv's native quantize of `mixed` is still in the shared q8_1 scratch: nothing native ran in between. +if (!gemv_quantized(*w_gate, p_gate, f_gate, b.x_q8_0, b.x_q8k, b.z, g.n_embd, g.ssm_value_dim, v.name("attn_gate.weight"), stream, err, mixed, + w_qkv->native_data != nullptr && w_gate->native_data != nullptr)) return false; +try { + if (fused_gdn) fused_gdn_step_norm(b.state, b.h, b.h + qk, b.h + 2 * qk, b.gate, b.beta, b.z, ssm_norm, RMS_EPS, b.y, + (int) g.ssm_k_heads, (int) g.ssm_v_heads, stream); + else if (native_gdn_enabled()) native_gdn_out_norm(b.o, b.z, ssm_norm, b.y, g.ssm_v_heads, g.ssm_state_size, RMS_EPS, stream); + else gdn_out_norm(b.o, b.z, ssm_norm, b.y, g.ssm_v_heads, g.ssm_state_size, RMS_EPS, stream); +} catch (const std::exception& error) { err = v.name("gdn_out_norm") + ": " + error.what(); return false; } +st_end(layer, 14, stream); +// ---- 8. out = ssm_out @ y, whose activation is whatever THIS layer's `ssm_out` asks for +st_begin(layer, 15, stream); + if (!w_out->native_data) { + quantize_q8_K(b.y, b.y_q8k, g.ssm_value_dim, stream); + quantize_q8_0(b.y, b.y_q8_0, g.ssm_value_dim, stream); + } if (!gemv_quantized(*w_out, p_out, f_out, b.y_q8_0, b.y_q8k, out, g.ssm_value_dim, g.n_embd, v.name("ssm_out.weight"), stream, err, b.y)) return false; st_end(layer, 15, stream); return true;} +// ================================ the MoE block ================================ +uint64_t moe_buffers_bytes(const ModelGeometry& g, int64_t k) { const uint64_t parts[] = { (uint64_t) g.n_embd * 2, +// x_bf16 +(uint64_t) g.n_embd * 2, +// x_f16 +(uint64_t) g.n_expert * 4, +// logits +(uint64_t) k * 4, +// ids (int32) +(uint64_t) k * 4, +// weights +(uint64_t) g.n_embd * 4, +// shared +strata::kernels::shared_expert_scratch_bytes(g.n_ff), +// the shared expert's own scratch +(uint64_t) (g.n_embd / 32) * 34, +// x_q8_0 +q8k_bytes(g.n_embd), +// x_q8k +}; uint64_t total = 0; for (uint64_t v : parts) total += (v + 15) & ~(uint64_t) 15; return total;} +uint64_t moe_buffers_init(const ModelGeometry& g, int64_t k, void* base, MoEBuffers& b) { const uint64_t parts[] = { (uint64_t) g.n_embd * 2, (uint64_t) g.n_embd * 2, (uint64_t) g.n_expert * 4, (uint64_t) k * 4, (uint64_t) k * 4, (uint64_t) g.n_embd * 4, strata::kernels::shared_expert_scratch_bytes(g.n_ff), (uint64_t) (g.n_embd / 32) * 34, q8k_bytes(g.n_embd), }; uint8_t* p = (uint8_t*) base; void* ptr[9]; uint64_t total = 0; for (int i = 0; i < 9; ++i) { ptr[i] = p; const uint64_t al = (parts[i] + 15) & ~(uint64_t) 15; p += al; total += al; } b.x_bf16 = (uint16_t*) ptr[0]; b.x_f16 = (uint16_t*) ptr[1]; b.logits = (float*) ptr[2]; b.ids = (int*) ptr[3]; b.weights = (float*) ptr[4]; b.shared = (float*) ptr[5]; b.sh_scratch = (float*) ptr[6]; b.x_q8_0 = (uint8_t*) ptr[7]; b.x_q8k = (uint8_t*) ptr[8]; return total;} +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) { using namespace strata::kernels; const LayerView v(tables, layer); const WeightRef* w_router = v.get("ffn_gate_inp.weight"); if (w_router == nullptr) { err = v.name("ffn_gate_inp.weight") + " is missing"; return false; } if (k < 1 || k > 64) { err = "moe_route: k must be 1..64"; return false; } +// ---- the router's activation. The router's weight is BF16 and that is the one `ref/moe.py` singles out. +if (!native_bf16_projections) f32_to_bf16_bulk(x, b.x_bf16, g.n_embd, stream); +// ---- logits = ffn_gate_inp @ bf16(x). The weight is BF16, so this is `bf16_gemv` and not `s_gemv` - +// and using the fp16 activation here instead is the 8.100e-03 error that flips a selection. +// +// **THE ROW-SPLIT VARIANT, BECAUSE THE PLAIN ONE'S PARALLELISM IS THE OUTPUT WIDTH.** `bf16_gemv` is +// one thread per output row: for the router that is 512 threads over 48 SMs, each walking 2,560 +// elements sequentially, and it measured **0.2711 ms for a 2.6 MB weight** - about 65x its +// memory-bound floor and the largest single item left inside the MoE after the top-10 was fixed. +// `bf16_gemv_split` exists for precisely this case; its own header says so. Measured here: +// LEDGER L41 -> L42. +project_bf16(x, b.x_bf16, (const uint16_t*) w_router->data, b.logits, g.n_embd, g.n_expert, true, stream); +// ---- routing: softmax over ALL experts, stable descending argsort with ties by index, gather, renormalise +if (native_router_enabled()) { + if (g.n_expert != 512 || k != 10) { + err = v.name("router") + ": native router requires 512 experts and k=10"; + return false; + } + try { native_router_top10(b.logits, b.ids, b.weights, stream); } + catch (const std::exception& error) { err = v.name("router") + ": " + error.what(); return false; } +} else router_top10(b.logits, 1, (int) g.n_expert, (int) k, b.ids, b.weights, stream); +// ---- THE DOORBELL, AND IT IS THE WHOLE POINT OF THE PROTOCOL. The routed experts' INPUT (`x`) and the +// miss list (`ids`, `weights`) are published to the host HERE, before anything that depends on them, +// so a CPU pool can start on them while the GPU keeps working. Both the copy and the ring are +// captured into `G[l]` like anything else - a kernel writing mapped pinned memory, which round 209 +// measured the host seeing 0.050 ms into a 39.8 ms graph. +if (db != nullptr && g_publish_kernel) { + strata::kernels::doorbell_publish(x, b.ids, b.weights, g.n_embd, k, db->d_x_f, db->d_ids, db->d_weights, db->d_seq, + stream); +} else if (db != nullptr) { if (cudaMemcpyAsync(db->d_x_f, x, (size_t) g.n_embd * 4, cudaMemcpyDeviceToDevice, (cudaStream_t) stream) != cudaSuccess || cudaMemcpyAsync(db->d_ids, b.ids, (size_t) k * 4, cudaMemcpyDeviceToDevice, (cudaStream_t) stream) != cudaSuccess || cudaMemcpyAsync(db->d_weights, b.weights, (size_t) k * 4, cudaMemcpyDeviceToDevice, (cudaStream_t) stream) != cudaSuccess) { err = "moe_route: the doorbell handoff copy failed"; return false; } +// THE RING IS LAST, so a host that sees it knows every byte above is in place. Ordering within a +// stream is what makes that true; it is not a timing assumption. +// THE RING LIVES IN A .cu: this file is compiled by the HOST compiler, where `__global__` and `<<<>>>` do +// not exist. The sequence value is read from the HOST copy and incremented, which is what makes the +// write idempotent across replays of the same graph - a captured literal would ring the same number +// forever and the host would never see a change. +strata::kernels::doorbell_ring(db->d_seq, stream); } return true;} +bool moe_shared(const WeightTable& tables, const ModelGeometry& g, int64_t layer, const MoEBuffers& b, const float* x, void* stream, std::string& err) { using namespace strata::kernels; const LayerView v(tables, layer); const WeightRef* w_ginp = v.get("ffn_gate_inp_shexp.weight"); const WeightRef* w_sgate = v.get("ffn_gate_shexp.weight"); const WeightRef* w_sup = v.get("ffn_up_shexp.weight"); const WeightRef* w_sdown = v.get("ffn_down_shexp.weight"); const char* missing = !w_ginp ? "ffn_gate_inp_shexp.weight" : !w_sgate ? "ffn_gate_shexp.weight" : !w_sup ? "ffn_up_shexp.weight" : !w_sdown ? "ffn_down_shexp.weight" : nullptr; if (missing) { err = v.name(missing) + " is missing"; return false; } +// ---- the shared expert. Its three weights are quantized, so they need their planes. +SForm f_gate, f_up, f_down; if (!sform_of(*w_sgate, f_gate, v.name("ffn_gate_shexp.weight"), err)) return false; if (!sform_of(*w_sup, f_up, v.name("ffn_up_shexp.weight"), err)) return false; if (!sform_of(*w_sdown, f_down, v.name("ffn_down_shexp.weight"), err)) return false; Planes p_gate, p_up, p_down; if (!plane_ptrs(*w_sgate, v.name("ffn_gate_shexp.weight"), p_gate, err)) return false; if (!plane_ptrs(*w_sup, v.name("ffn_up_shexp.weight"), p_up, err)) return false; if (!plane_ptrs(*w_sdown, v.name("ffn_down_shexp.weight"), p_down, err)) return false; +// `ffn_gate_inp_shexp` is a 1-D BF16 vector, so THE ARENA HOLDS 2 BYTES PER ELEMENT, not 4 - the loader +// re-rounded it to the engine form like any other BF16 tensor. Reading it as f32 is 5120 bytes past the +// end of a 5120-byte tensor inside a 4.5 GiB arena, where nothing faults. Refuse here rather than trust +// the layout check to have run: this function is reachable without it. +if (w_ginp->kind != WeightKind::Bf16InF32) { err = v.name("ffn_gate_inp_shexp.weight") + " is engine form " + std::to_string((int) w_ginp->kind) + " (" + std::to_string(w_ginp->bytes) + " B); the scalar gate reads it as bf16, so it must be form 1 (" + std::to_string((uint64_t) g.n_embd * 2) + " B)"; return false; } +// THE SHARED EXPERT'S WEIGHTS ARE NOT ONE FAMILY: `ffn_gate_shexp` is Q2_0 on 21 layers and a K-quant on +// the rest, `ffn_up_shexp` Q2_0 on 13, and `ffn_down_shexp` is LEGACY in every layer (IQ4_NL/Q4_0/Q5_0/ +// Q8_0/Q2_0, `n_in` 640 so Q8_K is impossible). So BOTH quantized images of the activation are produced +// and each projection takes the one its own form asks for. +f32_to_bf16_bulk(x, b.x_bf16, g.n_embd, stream); + if (!w_sgate->native_data || !w_sup->native_data) { + quantize_q8_K(x, b.x_q8k, g.n_embd, stream); + quantize_q8_0(x, b.x_q8_0, g.n_embd, stream); + } + NativeSharedWeights native; + native.gate_type = w_sgate->native_type; native.gate_data = w_sgate->native_data; + native.up_type = w_sup->native_type; native.up_data = w_sup->native_data; + native.down_type = w_sdown->native_type; native.down_data = w_sdown->native_data; + // The NativeDense owner publishes one shared workspace for this ordered session stream. + native.q8_1 = w_sgate->native_q8_1 ? w_sgate->native_q8_1 : + w_sup->native_q8_1 ? w_sup->native_q8_1 : w_sdown->native_q8_1; + try { + shared_expert(b.x_q8_0, b.x_q8k, b.x_bf16, f_gate, p_gate.codes, p_gate.scales, p_gate.offset, + f_up, p_up.codes, p_up.scales, p_up.offset, f_down, p_down.codes, p_down.scales, + p_down.offset, (const uint16_t*) w_ginp->data, b.sh_scratch, b.shared, + g.n_embd, g.n_ff, /*tpr=*/32, stream, x, &native); + } catch (const std::exception& error) { + err = v.name("shared_expert") + ": " + error.what(); + return false; + } + return true; +} +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) { + using namespace strata::kernels; + if (k < 1 || k > 64) { err = "moe_finish: k must be 1..64"; return false; } + // ---- the combination: sum of w[i] * parts[i], plus the shared output ADDED PLAIN. +// +// **`parts` IS COMBINED WITH `b.weights`, WHICH `moe_route` WROTE FOR *THIS* LAYER.** That is the whole +// reason this is a separate function: a caller that passes the previous layer's expert vectors gets +// `sum_j w_l[j] * expert_{ids_{l-1},j}(x_{l-1})`, which is finite, fluent and not the model. +try { + if (native_moe_combine_enabled()) native_moe_combine(parts, b.weights, b.shared, out, g.n_embd, k, stream); + else moe_combine(parts, b.weights, b.shared, out, g.n_embd, k, stream); +} catch (const std::exception& error) { + err = "blk." + std::to_string(layer) + ".moe_combine" + ": " + error.what(); + return false; +} +return true;} +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) { + if (k < 1 || k > 64) { err = "moe_finish: k must be 1..64"; return false; } + if (!moe_shared(tables, g, layer, b, x, stream, err)) return false; + return moe_combine_parts(g, layer, k, b, parts, out, stream, err); +} +bool layer_verify_compatible(std::string& why) { + // Plan v0.3 P6: the verify window reproduces exactly this configuration's per-token arithmetic. + if (!native_bf16_projections) why = "the native BF16 projections are off"; + else if (!g_fused_gr) why = "the fused hyper-connection read is off"; + else if (!g_fused_gdn || !strata::kernels::native_gdn_enabled()) why = "the fused native GDN kernels are off"; + else if (!g_fast_attn || native_flash_attn_short) why = "the split-K decode attention is off"; + else if (!g_fast_select) why = "the block top-k selection is off"; + else if (!strata::kernels::native_qsa_indexer_enabled()) why = "the native QSA indexer is off"; + else return true; + return false; +} +void layer_set_publish_kernel(bool enabled) { g_publish_kernel = enabled; } +void layer_set_fused_gdn(bool enabled) { g_fused_gdn = enabled; } +void layer_set_fast_select(bool enabled) { g_fast_select = enabled; } +void layer_set_fast_attn(bool enabled) { g_fast_attn = enabled; } +void layer_set_fused_gr(bool enabled) { g_fused_gr = enabled; } +bool layer_fused_gr() { return g_fused_gr; } +void layer_set_shared_early(bool enabled) { g_shared_early = enabled; } +bool layer_shared_early() { return g_shared_early; } +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) { +// The two halves in sequence. `moe_route` publishes `ids`/`weights` for THIS layer and `moe_finish` +// combines `parts` with them, so a caller of this wrapper must have `parts` ready BEFORE the call - which +// is what `session_token` (the uncaptured path) does and what the captured loop CANNOT, which is why the +// loop uses the halves separately. +if (!moe_route(tables, g, layer, k, b, x, stream, err, db)) return false; return moe_finish(tables, g, layer, k, b, x, parts, out, stream, err);} +// ================================ the QSA mixer ================================ +namespace {using strata::kernels::QsaIndexerBuffers;using strata::kernels::QsaShapes; +/// The geometry the QSA kernels want, from the one place that defines it. `ModelGeometry` carries the widths +/// the LAYOUT needs; `QsaShapes` adds `n_rot`, `idx_block` and `idx_top_k`, which are kernel contracts. +QsaShapes qsa_shapes(const ModelGeometry& g) { QsaShapes s = strata::kernels::qsa_real_shapes(); s.n_head = g.n_head; s.n_head_kv = g.n_head_kv; s.head_dim = g.head_dim; s.idx_n_head = g.idx_q_heads; s.idx_dim = g.idx_key_dim; return s;} +uint64_t align_up16(uint64_t n) { return (n + 15) & ~15ull; } +/// One cursor over an arena, so every region is 16-byte aligned without a list of hand-added offsets. +struct Cursor { uint8_t* p; uint64_t used = 0; template T* take(uint64_t count) { T* r = (T*) (p + used); used = align_up16(used + count * sizeof(T)); return r; } uint8_t* take_bytes(uint64_t n) { uint8_t* r = p + used; used = align_up16(used + n); return r; }}; +// `TPR` and `gemv_quantized` live in the FIRST anonymous namespace, above, because `gdn_layer` needs the +// dispatch too and the two must not be able to drift. +} +// namespace +uint64_t qsa_state_bytes(const ModelGeometry& g, int64_t max_cells, bool with_rope) { const QsaShapes s = qsa_shapes(g); const int64_t pages = (max_cells + s.page_size - 1) / s.page_size; uint64_t n = 0; n += g_kv_int8 ? (uint64_t) pages * s.page_size * strata::kernels::kv_q8_bytes_per_cell(s) + 64 : (uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim * 2 * 2; +// k_pool + v_pool, fp16 +n += (uint64_t) pages * 4; +// page_table +n += (uint64_t) (s.idx_block - 1) * s.idx_dim * 4; +// tail +n += (uint64_t) s.idx_dim * 4; +// dead +n += (uint64_t) ((max_cells / s.idx_block) + 2) * s.idx_dim * 4; +// pooled +n += 16; +// block_pos +if (with_rope) n += (uint64_t) max_cells * (s.n_rot / 2) * 4 * 2; +// cos + sin tables +n += strata::kernels::qsa_step_bytes() + 16; // counts and aligned attention status +// the per-token counts +n += (uint64_t) s.n_head * 4; +// pos_dev +return align_up16(n) + 256;} +uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaState& st, const QsaState* share_rope) { const QsaShapes s = qsa_shapes(g); const int64_t pages = (max_cells + s.page_size - 1) / s.page_size; Cursor c{(uint8_t*) base}; st.kv_int8 = g_kv_int8; if (g_kv_int8) { const uint64_t cells = (uint64_t) pages * s.n_head_kv * s.page_size; st.k_q = c.take(cells * s.head_dim); st.v_q = c.take(cells * s.head_dim); st.k_scale = c.take(cells * (s.head_dim / strata::kernels::KV_Q8_GROUP)); st.v_scale = c.take(cells * (s.head_dim / strata::kernels::KV_Q8_GROUP)); } else { st.k_pool = c.take((uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim); st.v_pool = c.take((uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim); } st.page_table = c.take((uint64_t) pages); st.n_pages = pages; st.max_cells = max_cells; st.idx_tail = c.take((uint64_t) (s.idx_block - 1) * s.idx_dim); st.idx_dead = c.take((uint64_t) s.idx_dim); st.idx_pooled = c.take((uint64_t) ((max_cells / s.idx_block) + 2) * s.idx_dim); st.idx_block_pos = c.take(1); if (share_rope != nullptr) { st.cos_tab = share_rope->cos_tab; st.sin_tab = share_rope->sin_tab; } else { st.cos_tab = c.take((uint64_t) max_cells * (s.n_rot / 2)); st.sin_tab = c.take((uint64_t) max_cells * (s.n_rot / 2)); } st.step = c.take((uint64_t) strata::kernels::kStepCount); st.attention_status = c.take(1); st.pos_dev = c.take((uint64_t) s.n_head); +// the pinned staging the uploads copy FROM - see the note on `host_step` in the header +if (cudaHostAlloc((void**) &st.host_step, strata::kernels::qsa_step_bytes() + sizeof(int32_t), cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess || cudaHostAlloc((void**) &st.host_pos, (size_t) s.n_head * 4, cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess) { return 0; +// the caller sees a zero byte count; a half-built state is worse than none +} +st.host_step[strata::kernels::kStepCount] = 0; +// THE ROPE TABLE IS BUILT ON THE HOST IN FLOAT64 and uploaded once, because the reference computes its +// frequencies in float64 and reproducing that on device means double-precision `pow`/`cos` that need not +// agree with the host's libm. A table shorter than the sequence would have the rotation read past it. +if (share_rope == nullptr) { std::vector hc((size_t) max_cells * (s.n_rot / 2)), hs((size_t) max_cells * (s.n_rot / 2)); strata::kernels::build_rope_table((int) s.n_rot, strata::kernels::qsa_freq_base(), (int) max_cells, hc.data(), hs.data()); cudaMemcpy(st.cos_tab, hc.data(), hc.size() * 4, cudaMemcpyHostToDevice); cudaMemcpy(st.sin_tab, hs.data(), hs.size() * 4, cudaMemcpyHostToDevice); } +// the page table starts as the IDENTITY, which is the simplest legal mapping and what a caller that does +// not page at all wants. A real allocator re-points it; nothing outside `kv_append`/`kv_gather` may care. +std::vector tab((size_t) pages); for (int64_t i = 0; i < pages; ++i) tab[(size_t) i] = (int32_t) i; cudaMemcpy(st.page_table, tab.data(), tab.size() * 4, cudaMemcpyHostToDevice); return c.used;} +void qsa_state_zero(const QsaState& st, const ModelGeometry& g, void* stream) { const QsaShapes s = qsa_shapes(g); cudaStream_t cs = (cudaStream_t) stream; if (st.kv_int8) { const size_t cells = (size_t) st.n_pages * s.n_head_kv * s.page_size; cudaMemsetAsync(st.k_q, 0, cells * s.head_dim, cs); cudaMemsetAsync(st.v_q, 0, cells * s.head_dim, cs); cudaMemsetAsync(st.k_scale, 0, cells * (s.head_dim / strata::kernels::KV_Q8_GROUP) * 2, cs); cudaMemsetAsync(st.v_scale, 0, cells * (s.head_dim / strata::kernels::KV_Q8_GROUP) * 2, cs); } else { cudaMemsetAsync(st.k_pool, 0, (size_t) st.n_pages * s.n_head_kv * s.page_size * s.head_dim * 2, cs); cudaMemsetAsync(st.v_pool, 0, (size_t) st.n_pages * s.n_head_kv * s.page_size * s.head_dim * 2, cs); } cudaMemsetAsync(st.idx_tail, 0, (size_t) (s.idx_block - 1) * s.idx_dim * 4, cs); cudaMemsetAsync(st.idx_dead, 0, (size_t) s.idx_dim * 4, cs); cudaMemsetAsync(st.idx_pooled, 0, (size_t) ((st.max_cells / s.idx_block) + 2) * s.idx_dim * 4, cs);} +uint64_t qsa_buffers_bytes(const ModelGeometry& g, int64_t max_cells) { const QsaShapes s = qsa_shapes(g); const int64_t cap = strata::kernels::qsa_selection_width(strata::kernels::kTopkMaxCells, s); uint64_t n = 0; n += (uint64_t) q8k_bytes(g.n_embd); n += (uint64_t) (g.n_embd / 32) * 34; +// block_q8_0 +n += (uint64_t) g.n_embd * 2; n += (uint64_t) g.n_head * 2 * g.head_dim * 4; n += (uint64_t) g.n_head * g.head_dim * 4; n += (uint64_t) g.n_head_kv * g.head_dim * 4 * 2; n += (uint64_t) g.idx_key_dim * 4; n += (uint64_t) g.idx_q_heads * g.idx_key_dim * 4; n += (uint64_t) max_cells * 4; n += (uint64_t) cap * 4; n += (uint64_t) cap * g.n_head_kv * g.head_dim * 2 * 2; n += (uint64_t) g.n_head * g.head_dim * 4; +// attn +n += (uint64_t) g.n_head * g.head_dim * 2; +// attn16 +n += (uint64_t) g.n_head * g.head_dim * 4; +// attn32 +n += (uint64_t) q8k_bytes(g.n_head * g.head_dim); +// attn_q8k +n += strata::kernels::qsa_decode_attn_scratch_floats(cap, s) * 4 + 16; +// attn_scratch +return align_up16(n) + 256;} +uint64_t qsa_buffers_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaBuffers& b) { const QsaShapes s = qsa_shapes(g); const int64_t cap = strata::kernels::qsa_selection_width(strata::kernels::kTopkMaxCells, s); Cursor c{(uint8_t*) base}; b.x_q8k = c.take_bytes(q8k_bytes(g.n_embd)); b.x_q8_0 = c.take_bytes((uint64_t) (g.n_embd / 32) * 34); b.x_bf16 = c.take((uint64_t) g.n_embd); b.q_full = c.take((uint64_t) g.n_head * 2 * g.head_dim); b.qcur = c.take((uint64_t) g.n_head * g.head_dim); b.kcur = c.take((uint64_t) g.n_head_kv * g.head_dim); b.vcur = c.take((uint64_t) g.n_head_kv * g.head_dim); b.idx_raw = c.take((uint64_t) g.idx_key_dim); b.q_idx = c.take((uint64_t) g.idx_q_heads * g.idx_key_dim); b.cell_scores = c.take((uint64_t) max_cells); b.ids = c.take((uint64_t) cap); b.k_scratch = c.take((uint64_t) cap * g.n_head_kv * g.head_dim); b.v_scratch = c.take((uint64_t) cap * g.n_head_kv * g.head_dim); b.attn = c.take((uint64_t) g.n_head * g.head_dim); b.attn16 = c.take((uint64_t) g.n_head * g.head_dim); b.attn32 = c.take((uint64_t) g.n_head * g.head_dim); b.attn_q8k = c.take_bytes(q8k_bytes(g.n_head * g.head_dim)); b.attn_scratch = c.take(strata::kernels::qsa_decode_attn_scratch_floats(cap, s)); return c.used;} +// ================================ PER-STAGE TIMING, DEBUG ONLY ================================ +// +// **THE ENGINE SPENDS 1.047 ms PER LAYER WITH THE EXPERTS OFF, AND EVERY COST MODEL IN `bench/` PREDICTS LESS +// THAN HALF OF THAT.** `dense_pass.exe` puts all 302 dense GEMVs at 11.64 ms/token and the 1.377 GiB of +// BF16/F32 weights they exclude at another ~5.9, so ~17.5 ms of the 50.25 ms captured no-pool floor is +// accounted for and ~33 ms is not. Arithmetic has already discounted the obvious candidates - the GDN +// recurrence moves 6.2 MB of state per token, the QSA attention at 1,024 tokens is 75M MACs over twelve +// layers, and `gr_norm_kernel`'s 4-block launch moves 40 KB - so the attribution has to come from the engine. +// +// Event pairs are recorded per (layer, stage) and read once at the end, so **NOTHING IS SYNCHRONISED DURING +// THE RUN** and the measurement does not perturb what it measures. That matters here more than usual: a +// per-stage `cudaEventSynchronize` would serialise the pipeline and report the serialised time. +// +// **IT CANNOT BE USED WITH CAPTURED GRAPHS.** An event record inside a stream capture is silently dropped +// (round 199: 2 nodes for two kernels plus an event record, and the event never completed across a 41 ms +// graph), so this is gated on the `--no-capture` path where the layers are ordinary launches. +constexpr int STAGE_SLOTS = 20; + +struct StageTimer { + bool on = false; + cudaEvent_t a[64][STAGE_SLOTS] = {}; + cudaEvent_t b[64][STAGE_SLOTS] = {}; + const char* names[STAGE_SLOTS] = {}; + int nslots = 0; +}; + +static StageTimer g_st; + +bool stage_timing_enable() { + if (g_st.on) return true; + for (int l = 0; l < 64; ++l) { + for (int s = 0; s < STAGE_SLOTS; ++s) { + if (cudaEventCreate(&g_st.a[l][s]) != cudaSuccess) return false; + if (cudaEventCreate(&g_st.b[l][s]) != cudaSuccess) return false; + } + } + g_st.on = true; + return true; +} + +void stage_timing_name(int slot, const char* name) { + if (slot >= 0 && slot < STAGE_SLOTS) { + g_st.names[slot] = name; + if (slot + 1 > g_st.nslots) g_st.nslots = slot + 1; + } +} + +void stage_mark_begin(int64_t layer, int slot, void* stream) { st_begin(layer, slot, stream); } +void stage_mark_end(int64_t layer, int slot, void* stream) { st_end(layer, slot, stream); } + +static void st_begin(int64_t layer, int slot, void* stream) { + if (g_st.on && layer < 64) cudaEventRecord(g_st.a[layer][slot], (cudaStream_t) stream); +} + +static void st_end(int64_t layer, int slot, void* stream) { + if (g_st.on && layer < 64) cudaEventRecord(g_st.b[layer][slot], (cudaStream_t) stream); +} + +void stage_timing_report(int64_t n_layers) { + if (!g_st.on) return; + cudaDeviceSynchronize(); + double tot[STAGE_SLOTS] = {}; + for (int s = 0; s < g_st.nslots; ++s) { + for (int64_t l = 0; l < n_layers && l < 64; ++l) { + float ms = 0.0f; + if (cudaEventElapsedTime(&ms, g_st.a[l][s], g_st.b[l][s]) == cudaSuccess) tot[s] += (double) ms; + } + } + double sum = 0; + for (int s = 0; s < g_st.nslots; ++s) sum += tot[s]; + std::fprintf(stderr, "\nper-stage GPU time, summed over %lld layers (one token):\n", (long long) n_layers); + for (int s = 0; s < g_st.nslots; ++s) { + std::fprintf(stderr, " %-22s %8.3f ms/token %6.3f ms/layer %5.1f%%\n", + g_st.names[s] ? g_st.names[s] : "(unnamed)", tot[s], tot[s] / (double) n_layers, + sum > 0 ? 100.0 * tot[s] / sum : 0.0); + } + std::fprintf(stderr, " %-22s %8.3f ms/token\n", "sum of stages", sum); + // ---- THE PER-LAYER SPLIT, because the attention block is 55% of the token and the engine mixes two + // entirely different attention implementations: GDN on 36 layers and QSA on the twelve where + // `full_attention_interval` divides the index. A single total cannot say which one is expensive, and the + // two have nothing in common to optimise. + std::fprintf(stderr, "\nper-layer, the attention block (ms). A `*` marks a QSA layer:\n"); + double gdn = 0, qsa = 0; + int ng = 0, nq = 0; + for (int64_t l = 0; l < n_layers && l < 64; ++l) { + float ms = 0.0f; + if (cudaEventElapsedTime(&ms, g_st.a[l][1], g_st.b[l][1]) != cudaSuccess) continue; + const bool is_qsa = (l % 4) == 3; + if (is_qsa) { qsa += ms; ++nq; } else { gdn += ms; ++ng; } + std::fprintf(stderr, " %s%2lld %7.3f%s", is_qsa ? "*" : " ", (long long) l, (double) ms, + ((l + 1) % 8 == 0) ? "\n" : ""); + } + std::fprintf(stderr, "\n GDN: %d layers, %.3f ms total, %.4f ms/layer\n", ng, gdn, ng ? gdn / ng : 0.0); + std::fprintf(stderr, " QSA: %d layers, %.3f ms total, %.4f ms/layer\n", nq, qsa, nq ? qsa / nq : 0.0); +} + +// Declared here because `qsa_layer` is defined above `block_layer_pre`, where the body lives. The dump buffer +// is a plain host pointer and the copy is a graph node of whichever layer's capture is running, so the helper +// needs nothing from the layer it is called for beyond its index. +static void dump_slot(float* dump, const ModelGeometry& g, int64_t layer, const float* src, uint64_t off, + uint64_t n, void* stream); +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) { using namespace strata::kernels; const QsaShapes s = qsa_shapes(g); const LayerView v(tables, layer); const int64_t n_kv = pos + 1; /* P7 audit: RoPE reads cos/sin row pos_base + pos, and the table holds max_cells rows. */ if ((int64_t) pos_base + pos >= st.max_cells || pos_base < 0) { err = "qsa_layer: position " + std::to_string((long long) pos_base + pos) + " is outside the RoPE table (" + std::to_string((long long) st.max_cells) + " rows)"; return false; } const int64_t n_bid = n_kv / s.idx_block; const int64_t width = qsa_selection_width(n_kv, s); const int64_t cap = qsa_selection_width(kTopkMaxCells, s); +const auto normalize_rotate = [&](float* data, const WeightRef* norm, int rows, int cols) { + try { + if (native_qsa_enabled()) native_qsa_rms_norm_weighted(data, (const float*) norm->data, data, cols, rows, RMS_EPS, stream); + else rms_norm_weighted(data, (const float*) norm->data, rows, cols, RMS_EPS, stream); + if (native_rope_enabled()) native_rope_apply(data, data, rows, cols, (int) s.n_rot, (float) qsa_freq_base(), st.pos_dev, stream); + else rope_neox_apply(data, data, rows, cols, (int) s.n_rot, st.cos_tab, st.sin_tab, st.pos_dev, stream); + return true; + } catch (const std::exception& error) { + err = v.name("qsa_norm_rope") + ": " + error.what(); + return false; + } +}; +// ---- resolve every tensor by name, and REFUSE rather than reading a null. `check_layer` has already +// asserted the shapes at load time; a missing name here is a wiring mistake. +struct Req { const char* suf; }; const WeightRef* w_idxk = v.get("indexer.k_proj.weight"); const WeightRef* w_attnq = v.get("attn_q.weight"); const WeightRef* w_attnk = v.get("attn_k.weight"); const WeightRef* w_attnv = v.get("attn_v.weight"); const WeightRef* w_attno = v.get("attn_output.weight"); const WeightRef* w_idxq = v.get("indexer.q_proj.weight"); const WeightRef* w_qn = v.get("attn_q_norm.weight"); const WeightRef* w_kn = v.get("attn_k_norm.weight"); const WeightRef* w_iqn = v.get("indexer.q_norm.weight"); const WeightRef* w_ikn = v.get("indexer.k_norm.weight"); const char* missing = !w_idxk ? "indexer.k_proj.weight" : !w_attnq ? "attn_q.weight" : !w_attnk ? "attn_k.weight" : !w_attnv ? "attn_v.weight" : !w_attno ? "attn_output.weight" : !w_idxq ? "indexer.q_proj.weight" : !w_qn ? "attn_q_norm.weight" : !w_kn ? "attn_k_norm.weight" : !w_iqn ? "indexer.q_norm.weight" : !w_ikn ? "indexer.k_norm.weight" : nullptr; if (missing) { err = v.name(missing) + " is missing"; return false; } if (pos < 0 || pos >= st.max_cells) { err = "qsa_layer: pos " + std::to_string(pos) + " is outside the state's 0.." + std::to_string(st.max_cells - 1); return false; } +// THE TWO BF16 PROJECTIONS: the arena holds them re-rounded to 2 B/elem, which is what `bf16_gemv` wants. +// Reading one as f32 would walk 2x its length inside the arena without faulting. +if (w_idxk->kind != WeightKind::Bf16InF32 || w_idxq->kind != WeightKind::Bf16InF32) { err = v.name("indexer.*_proj.weight") + " must be engine form 1 (bf16); they are " + std::to_string((int) w_idxk->kind) + " and " + std::to_string((int) w_idxq->kind); return false; } +// ---- 1. the three activation formats, once each +if (!w_attnk->native_data || !w_attnv->native_data || !w_attnq->native_data) { + quantize_q8_K(x, b.x_q8k, g.n_embd, stream); + quantize_q8_0(x, b.x_q8_0, g.n_embd, stream); + } + if (!native_bf16_projections) f32_to_bf16_bulk(x, b.x_bf16, g.n_embd, stream); +// ---- 2. the step state: ONE H2D carries every per-token count the kernels need. Nothing below passes a +// per-token scalar as an argument, which is what keeps this layer capturable. +// +// THE SOURCES ARE `st.host_step`/`st.host_pos`, WHICH LIVE AS LONG AS THE STATE DOES. A stack array or a +// local vector here would be captured as a dangling POINTER and replayed as garbage - see the note in +// `layer.hpp`. That is a silent failure, not a fault: the copy succeeds, the counts are wrong, and the +// token that comes out is plausible. +{ qsa_step_fill(st.host_step, pos, s); for (int64_t h = 0; h < g.n_head; ++h) st.host_pos[h] = (int32_t) (pos_base + pos); + int32_t* m_step = nullptr; + int32_t* m_pos = nullptr; + if (g_publish_kernel && cudaHostGetDevicePointer((void**) &m_step, st.host_step, 0) == cudaSuccess && + cudaHostGetDevicePointer((void**) &m_pos, st.host_pos, 0) == cudaSuccess) { + strata::kernels::copy_i32_from_mapped(st.step, m_step, strata::kernels::kStepCount, stream); + strata::kernels::copy_i32_from_mapped(st.pos_dev, m_pos, g.n_head, stream); + } else + if (cudaMemcpyAsync(st.step, st.host_step, qsa_step_bytes(), cudaMemcpyHostToDevice, (cudaStream_t) stream) != cudaSuccess || cudaMemcpyAsync(st.pos_dev, st.host_pos, (size_t) g.n_head * 4, cudaMemcpyHostToDevice, (cudaStream_t) stream) != cudaSuccess) { err = "qsa_layer: the step-state upload failed"; return false; } } +// ---- 3. the indexer's RAW key: appended before any norm, pooled later once per block +project_bf16(x, b.x_bf16, (const uint16_t*) w_idxk->data, b.idx_raw, g.n_embd, g.idx_key_dim, false, stream); +// ---- 4. K and V, in Q8_K, then norm and rotate K only +SForm f_k, f_v, f_o, f_q; if (!sform_of(*w_attnk, f_k, v.name("attn_k.weight"), err)) return false; if (!sform_of(*w_attnv, f_v, v.name("attn_v.weight"), err)) return false; if (!sform_of(*w_attno, f_o, v.name("attn_output.weight"), err)) return false; if (!sform_of(*w_attnq, f_q, v.name("attn_q.weight"), err)) return false; Planes p_k, p_v, p_o, p_q; if (!plane_ptrs(*w_attnk, v.name("attn_k.weight"), p_k, err)) return false; if (!plane_ptrs(*w_attnv, v.name("attn_v.weight"), p_v, err)) return false; if (!plane_ptrs(*w_attno, v.name("attn_output.weight"), p_o, err)) return false; if (!plane_ptrs(*w_attnq, v.name("attn_q.weight"), p_q, err)) return false; +// k and v, with the activation THIS layer's tensors ask for. Both are K-quants in every QSA layer of this +// artifact, but the dispatch is here for the same reason it is in `gdn_layer`: the pack decides per tensor, +// and "it happens to be uniform here" is the assumption that was wrong for `attn_q`. +if (!gemv_quantized(*w_attnk, p_k, f_k, b.x_q8_0, b.x_q8k, b.kcur, g.n_embd, g.n_head_kv * g.head_dim, v.name("attn_k.weight"), stream, err, x)) return false; if (!gemv_quantized(*w_attnv, p_v, f_v, b.x_q8_0, b.x_q8k, b.vcur, g.n_embd, g.n_head_kv * g.head_dim, v.name("attn_v.weight"), stream, err, x, w_attnk->native_data && w_attnv->native_data)) return false; dump_slot(dump, g, layer, b.vcur, (uint64_t) 2 * g.n_embd + 2 * g.hc + (uint64_t) g.n_head * g.head_dim, (uint64_t) g.n_head_kv * g.head_dim, stream); if (!normalize_rotate(b.kcur, w_kn, (int) g.n_head_kv, (int) g.head_dim)) return false; +// ---- 5. into the cache, and the indexer's append (which pools AND rotates on a block completion). +// EVERY ENTRY POINT FROM HERE ON IS THE CAPTURABLE ONE: the per-token counts come from `st.step` and every +// launch is sized from a capacity in `st`/`b`, so this sequence can be captured and replayed. The +// host-scalar wrappers would be correct here today and silently wrong in a graph. +if (st.kv_int8) kv_append_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, st.step, b.kcur, b.vcur, s, stream); else kv_append_step(st.k_pool, st.v_pool, st.page_table, st.step, b.kcur, b.vcur, s, stream); { const uint64_t nvk = (uint64_t) g.n_head_kv * g.head_dim; const uint64_t base = (uint64_t) 2 * g.n_embd + 2 * g.hc + (uint64_t) g.n_head * g.head_dim + 2 * nvk + 8; if (!st.kv_int8) dump_slot(dump, g, layer, (const float*) st.k_pool, base, nvk / 2, stream); if (!st.kv_int8) dump_slot(dump, g, layer, (const float*) st.v_pool, base + nvk / 2, nvk / 2, stream); dump_slot(dump, g, layer, b.vcur, base + nvk, nvk, stream); dump_slot(dump, g, layer, b.kcur, base + 2 * nvk, nvk, stream); } { const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos}; if (native_qsa_indexer_enabled()) { + try { + native_qsa_indexer_append(b.idx_raw, st.step + kStepPos, pos_base, + (const float*) w_ikn->data, RMS_EPS, ib, s, st.max_cells, (float) qsa_freq_base(), stream); + } catch (const std::exception& error) { err = v.name("native_indexer") + ": " + error.what(); return false; } +} else indexer_key_append(b.idx_raw, st.pos_dev, pos_base, (const float*) w_ikn->data, RMS_EPS, ib, s, + st.cos_tab, st.sin_tab, stream); } +// ---- 6. the query projection, and the HALF-SPLIT into q and gate. +// +// **`attn_q` IS *NOT* ALWAYS Q2_0.** It is Q2_0 on TWO of the twelve QSA layers, Q3_K on six and IQ4_XS on +// four, and this line used to call `s2_gemv_q8` unconditionally - so TEN of the twelve layers decoded an +// S4 code plane with a Q8_0 activation. Both are "a quantized activation", both produce a plausible +// vector, and the difference is 0.6-1.4%. +if (!gemv_quantized(*w_attnq, p_q, f_q, b.x_q8_0, b.x_q8k, b.q_full, g.n_embd, g.n_head * 2 * g.head_dim, v.name("attn_q.weight"), stream, err, x, w_attnv->native_data && w_attnq->native_data)) return false; +// `per_head[:, :head_dim]` - the FIRST half of each head's 2*head_dim block, copied out contiguously so +// the norm and the rotation see whole rows. A 2-D copy is a memcpy node, which captures (`pinned_capture` +// case A) and needs no kernel. +if (cudaMemcpy2DAsync(b.qcur, (size_t) g.head_dim * 4, b.q_full, (size_t) g.head_dim * 2 * 4, (size_t) g.head_dim * 4, (size_t) g.n_head, cudaMemcpyDeviceToDevice, (cudaStream_t) stream) != cudaSuccess) { err = "qsa_layer: the q/gate split failed"; return false; } if (!normalize_rotate(b.qcur, w_qn, (int) g.n_head, (int) g.head_dim)) return false; +// ---- 7. the indexer's query: BF16, then norm and rotate +project_bf16(x, b.x_bf16, (const uint16_t*) w_idxq->data, b.q_idx, g.n_embd, g.idx_q_heads * g.idx_key_dim, false, stream); +if (!normalize_rotate(b.q_idx, w_iqn, (int) g.idx_q_heads, (int) g.idx_key_dim)) return false; +// ---- 8. score, select, gather, attend. `max_blocks` and `cap` are CAPACITIES from the state, not this +// token's counts: a grid or a shared-memory size that follows the sequence length is baked into a captured +// graph, and the kernels guard for the surplus. const +int64_t max_blocks = (st.max_cells / s.idx_block) + 2; + if (g_fast_select) { + // Plan v0.3 P7: block-level FP32 scores and a radix selection over blocks (qsa_select.hpp). + qsa_block_scores(st.idx_pooled, st.idx_dead, b.q_idx, st.step, 1, max_blocks, s, b.cell_scores, stream); + qsa_block_topk(b.cell_scores, st.step, 1, max_blocks, cap, s, b.ids, stream); + } else { + qsa_index_step(st.idx_pooled, b.q_idx, nullptr, s, st.step, max_blocks, b.cell_scores, stream); topk_512_step(b.cell_scores, s, cap, st.step, b.ids, stream); + } + if (g_fast_attn && !native_flash_attn_short && dump == nullptr) { + strata::kernels::QsaAttnPools pools; + pools.k_pool = st.k_pool; pools.v_pool = st.v_pool; pools.k_q = st.k_q; pools.v_q = st.v_q; + pools.k_scale = st.k_scale; pools.v_scale = st.v_scale; pools.page_table = st.page_table; + if (!st.kv_int8) { pools.k_q = nullptr; pools.v_q = nullptr; } + strata::kernels::qsa_decode_attn_step(b.qcur, pools, b.ids, st.step, cap, s, b.attn_scratch, b.attn, stream); + } else { + if (st.kv_int8) kv_gather_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream); else kv_gather_step(st.k_pool, st.v_pool, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream); if (native_flash_attn_short) { + if (st.max_cells < 1 || st.max_cells > 256 || !st.attention_status || !st.host_step) { + err = v.name("native_flash_attn") + ": short adapter requires context <=256 and persistent status storage"; + return false; + } + try { + native_flash_attn_short_step(b.qcur, b.k_scratch, b.v_scratch, st.step, cap, + (int) st.max_cells, s, b.attn, st.attention_status, nullptr, stream); + } catch (const std::exception& error) { err = v.name("native_flash_attn") + ": " + error.what(); return false; } + if (cudaMemcpyAsync(st.host_step + kStepCount, st.attention_status, sizeof(int32_t), + cudaMemcpyDeviceToHost, (cudaStream_t) stream) != cudaSuccess) { + err = v.name("native_flash_attn") + ": status readback failed"; return false; + } +} else qsa_attend_step(b.qcur, b.k_scratch, b.v_scratch, st.step, cap, s, b.attn, nullptr, stream); + } + dump_slot(dump, g, layer, b.attn, (uint64_t) 2 * g.n_embd + 2 * g.hc, (uint64_t) g.n_head * g.head_dim, stream); { const uint64_t vs = (uint64_t) 2 * g.n_embd + 2 * g.hc + (uint64_t) g.n_head * g.head_dim + (uint64_t) 2 * g.n_head_kv * g.head_dim; dump_slot(dump, g, layer, (const float*) b.v_scratch, vs, (uint64_t) g.n_head_kv * g.head_dim / 2, stream); dump_slot(dump, g, layer, (const float*) b.ids, vs + (uint64_t) g.n_head_kv * g.head_dim / 2, 4, stream); dump_slot(dump, g, layer, (const float*) st.step, vs + (uint64_t) g.n_head_kv * g.head_dim / 2 + 4, 4, stream); } +// ---- 9. Gate the attention output before its projection. Native CUDA keeps F32 +// sigmoid/multiply arithmetic and uses Q8_1 for the native quantized projection. +// The canonical fallback retains its prior FP64 gate and Q8_K projection. +try { + if (native_qsa_enabled()) native_qsa_gate_apply(b.attn, b.q_full, b.attn32, (int) g.n_head, (int) g.head_dim, stream); + else qsa_gate_apply_f32(b.attn, b.q_full, s, b.attn32, stream); +} catch (const std::exception& error) { err = v.name("qsa_gate") + ": " + error.what(); return false; } + if (!w_attno->native_data) quantize_q8_K(b.attn32, b.attn_q8k, g.n_head * g.head_dim, stream); if (w_attno->native_data) { + if (!gemv_quantized(*w_attno, p_o, f_o, nullptr, b.attn_q8k, out, + g.n_head * g.head_dim, g.n_embd, v.name("attn_output.weight"), stream, err, b.attn32)) return false; +} else { + s_gemv_q8k_split(b.attn_q8k, p_o.codes, p_o.scales, p_o.offset, out, + g.n_head * g.head_dim, g.n_embd, f_o, stream); +} return true;} +// ================================ THE DOORBELL ================================ +namespace {} +// namespace +uint64_t doorbell_init(const ModelGeometry& g, int64_t k, Doorbell& db) { db.n_embd = g.n_embd; db.k = k; uint64_t bytes = 0; +// ONE region per field, each MAPPED PINNED, so the device and the host have different pointers to the same +// bytes and no copy is needed to publish them. +auto alloc = [&](size_t n, void** h, void** d, const char* what) { if (cudaHostAlloc(h, n, cudaHostAllocMapped) != cudaSuccess) { std::fprintf(stderr, "doorbell_init: cudaHostAlloc(%s) failed\n", what); return false; } if (cudaHostGetDevicePointer(d, *h, 0) != cudaSuccess) { std::fprintf(stderr, "doorbell_init: cudaHostGetDevicePointer(%s) failed\n", what); return false; } std::memset(*h, 0, n); bytes += n; return true; }; if (!alloc((size_t) g.n_embd * 4, (void**) &db.h_x_f, (void**) &db.d_x_f, "x_f")) return 0; if (!alloc((size_t) k * 4, (void**) &db.h_ids, (void**) &db.d_ids, "ids")) return 0; if (!alloc((size_t) k * 4, (void**) &db.h_weights, (void**) &db.d_weights, "weights")) return 0; if (!alloc(4, (void**) &db.h_seq, (void**) &db.d_seq, "seq")) return 0; if (!alloc(4, (void**) &db.h_flag, (void**) &db.d_flag, "flag")) return 0; return bytes;} +void doorbell_free(Doorbell& db) { if (db.h_x_f) cudaFreeHost(db.h_x_f); if (db.h_ids) cudaFreeHost(db.h_ids); if (db.h_weights) cudaFreeHost(db.h_weights); if (db.h_seq) cudaFreeHost(db.h_seq); if (db.h_flag) cudaFreeHost(db.h_flag); db = Doorbell{};} +void doorbell_reset(const Doorbell& db) { + if (db.h_seq) *db.h_seq = 0; + if (db.h_flag) *(volatile uint32_t*) db.h_flag = 0; +} +// ================================ THE TWO ENDS OF A TOKEN ================================ +bool embed_row(const WeightTable& tables, const ModelGeometry& g, int64_t token, float* out_dev, + void* stream, std::string& err) { + if (const NativeEmbed* ne = native_embed()) { // plan v0.3 P6: the GGUF-form table (IQ model files) + if (token < 0 || out_dev == nullptr) { err = "embed_row: invalid token or output"; return false; } + ne->gather_one(token, out_dev, stream); + return true; + } + const WeightRef* w = tables.find(EMBEDDING_NAME); + if (w == nullptr) { err = "token_embd.weight is missing"; return false; } + if (w->code_bits != 2 && w->code_bits != 4 && w->code_bits != 8) { + err = "embed_row: token_embd.weight is not an S2/S4/S8 tensor"; + return false; + } + if (w->codebook_iq4nl) { + err = "embed_row: an IQ4NL embedding is not supported"; + return false; + } + if (w->data == nullptr || out_dev == nullptr || w->ne0 <= 0 || w->ne1 <= 0 || + w->ne0 != g.n_embd || w->group_elems <= 0 || + w->ne0 % w->group_elems != 0 || w->ne0 % (8 / w->code_bits) != 0) { + err = "embed_row: invalid embedding pointers, dimensions or group size"; + return false; + } + if (token < 0 || token >= w->ne1) { + err = "embed_row: token " + std::to_string(token) + " is outside 0.." + std::to_string(w->ne1 - 1); + return false; + } + + const uint64_t row_codes = (uint64_t) (w->ne0 / (8 / w->code_bits)); + const uint64_t row_groups = (uint64_t) (w->ne0 / w->group_elems); + const uint64_t rows = (uint64_t) w->ne1; + // Division-based checks avoid overflow in rows * row_bytes and reject partial + // code/group rows before any device pointer arithmetic. + if (w->codes_bytes > w->bytes || w->scales_bytes > w->bytes - w->codes_bytes || + w->offset_bytes != w->bytes - w->codes_bytes - w->scales_bytes || + w->codes_bytes % row_codes != 0 || w->codes_bytes / row_codes != rows || + w->codes_bytes % alignof(float) != 0 || w->scales_bytes % sizeof(float) != 0 || + (w->scales_bytes / sizeof(float)) % row_groups != 0 || + (w->scales_bytes / sizeof(float)) / row_groups != rows || + w->has_offset != (w->offset_bytes != 0) || + (w->has_offset && w->offset_bytes != w->scales_bytes)) { + err = "embed_row: embedding planes do not match the loaded tensor shape"; + return false; + } + + const auto* codes = static_cast(w->data); + const auto* scales = reinterpret_cast(codes + w->codes_bytes); + const auto* offsets = w->has_offset ? reinterpret_cast(codes + w->codes_bytes + w->scales_bytes) + : nullptr; + const uint64_t group_offset = (uint64_t) token * row_groups; + strata::kernels::embedding_gather(codes + (uint64_t) token * row_codes, scales + group_offset, + offsets ? offsets + group_offset : nullptr, w->ne0, + w->code_bits, w->code_bias, w->group_elems, out_dev, stream); + return true; +} +bool lm_head_mix(const WeightTable& tables, const ModelGeometry& g, const BlockBuffers& bb, + void* stream, std::string& err) { + const WeightRef* wn = tables.find("output_hc_norm.weight"); + const WeightRef* wd = tables.find("output_hc_down.weight"); + const WeightRef* wu = tables.find("output_hc_up.weight"); + if (!wn || !wd || !wu) { + err = "lm_head: an output_hc_* weight is missing"; + return false; + } + if (wn->kind != WeightKind::F32 || wd->kind != WeightKind::Bf16InF32 || + wu->kind != WeightKind::Bf16InF32) { + err = "lm_head: the output_hc_* weights have the wrong engine forms"; + return false; + } + const strata::kernels::GrShapes gs{g.n_embd, g.hc, g.hc_lr}; + strata::kernels::gr_read(bb.R, (const float*) wn->data, (const uint16_t*) wd->data, + (const uint16_t*) wu->data, nullptr, RMS_EPS, gs, bb.gr, + bb.mixed, bb.inject, stream); + return true; +} + +bool lm_head(const WeightTable& tables, const ModelGeometry& g, const BlockBuffers& bb, + float* logits, void* stream, std::string& err) { + const WeightRef* wo = tables.find("output.weight"); + if (!wo) { err = "output.weight is missing"; return false; } + strata::kernels::SForm form; + Planes planes; + if (!sform_of(*wo, form, "output.weight", err) || + !plane_ptrs(*wo, "output.weight", planes, err)) return false; + if (g.n_embd % Q8K_ELEMS_PER_BLOCK != 0) { + err = "lm_head: n_embd is not a multiple of 256"; + return false; + } + if (!lm_head_mix(tables, g, bb, stream, err)) return false; + strata::kernels::quantize_q8_K(bb.mixed, bb.head_q8k, g.n_embd, stream); + return gemv_quantized(*wo, planes, form, bb.head_q8k, bb.head_q8k, logits, + g.n_embd, wo->ne1, "output.weight", stream, err); +} +// ================================ ONE WHOLE BLOCK ================================ +uint64_t block_buffers_bytes(const ModelGeometry& g) { const strata::kernels::GrShapes s{g.n_embd, g.hc, g.hc_lr}; uint64_t n = 0; n += (uint64_t) g.hc * g.n_embd * 4; +// R +n += (uint64_t) g.n_embd * 4; +// mixed +n += (uint64_t) g.n_embd * 4; +// block_out +n += (uint64_t) g.hc * 4; +// inject +n += (uint64_t) g.hc * 4 * 2 + 32; +// inject2, gr_rs +n += q8k_bytes(g.n_embd); +// head_q8k +n += strata::kernels::gr_workspace_bytes(s); return align_up16(n) + 256;} +uint64_t block_buffers_init(const ModelGeometry& g, void* base, BlockBuffers& b) { const strata::kernels::GrShapes s{g.n_embd, g.hc, g.hc_lr}; Cursor c{(uint8_t*) base}; b.R = c.take((uint64_t) g.hc * g.n_embd); b.mixed = c.take((uint64_t) g.n_embd); b.block_out = c.take((uint64_t) g.n_embd); b.inject = c.take((uint64_t) g.hc); b.inject2 = c.take((uint64_t) g.hc); b.gr_rs = c.take((uint64_t) g.hc); b.head_q8k = c.take_bytes(q8k_bytes(g.n_embd)); uint8_t* grw = c.take_bytes(strata::kernels::gr_workspace_bytes(s)); strata::kernels::gr_workspace_init(s, grw, b.gr); return c.used;} +// ---- THE HALF-LEVEL C1 ORACLE. `dump` is a HOST buffer of `2 * n_embd + 2 * hc + n_head * head_dim` floats +// per layer; this enqueues one slice of it as a device-to-host copy. **IT RUNS INSIDE THE CAPTURE**, which is +// the point: the copy becomes a node of that layer's graph and replays with it, so the layer functions stay one +// function each and no per-layer plumbing reaches `generate.cpp`. A null `dump` is the default and skips the +// enqueue entirely, so the fast path records no extra node. +uint64_t dump_stride_floats(const ModelGeometry& g) { + // Layout per layer, all in floats: + // [0, 2*n_embd) the two halves' block_out + // [2*n_embd, +2*hc) the two halves' inject + // [+NH*HD) the QSA pre-gate attention + // [+2*NKV*HD) the QSA RAW vcur (as floats) + // [+NKV*HD/2) the gathered v_scratch, as fp16 pairs + // [+4) [+4) ids[0..3], then step[0..3] + // [+NKV*HD/2) the v_pool prefix, as fp16 pairs + // [+NKV*HD) the post-norm POST-ROPE kcur + // The last four are what separate a wrong projection from a wrong pool round trip. + const uint64_t nvk = (uint64_t) g.n_head_kv * g.head_dim; + return (uint64_t) 2 * g.n_embd + (uint64_t) 2 * g.hc + (uint64_t) g.n_head * g.head_dim + 5 * nvk + 8; +} +static void dump_slot(float* dump, const ModelGeometry& g, int64_t layer, const float* src, uint64_t off, + uint64_t n, void* stream) { + if (dump == nullptr || src == nullptr || n == 0) return; + cudaMemcpyAsync(dump + (size_t) layer * dump_stride_floats(g) + off, src, n * sizeof(float), + cudaMemcpyDeviceToHost, (cudaStream_t) stream); +} +static void dump_half(const BlockBuffers& bb, const ModelGeometry& g, int64_t layer, const float* src, + uint64_t off, uint64_t n, void* stream) { + dump_slot(bb.dump, g, layer, src, off, n, stream); +} +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, const PleRun* ple, int half, int stage_prefix) { + // ================================ THE PLE, AT LAYER 1 ONLY ================================ + // + // **THIS IS THE CALL WHOSE ABSENCE FAILED GATE C1.** Layer 1 carries the six `blk.1.ple_*` tensors and no + // other layer does; every part of the module was built and parity-tested and none of it was reachable from + // here. The block's output is `hidden + gated + silu(conv)` on the RESIDUAL STACK ITSELF, so it is a + // residual update applied BEFORE layer 1's own `gr_read` - which is what the architecture means by the + // gathered rows arriving "before layer 2". + // + // **THE HASH AND THE GATHER ARE *NOT* HERE, AND THAT IS THE WHOLE REASON `ple_stage_token` EXISTS.** Both + // are HOST operations - the hash is 64-bit multiply/xor that `qwen4exp.cpp` says is host-side because "ggml + // has no int64 and no xor", and the table is a host mapping. This function is CAPTURED, so anything host + // side in it runs ONCE, at capture time, and the graph then replays that result for every token: the engine + // would gather the capture-time token's rows forever and produce a perfectly finite, perfectly stable, + // completely wrong answer. It is the same bug as the doorbell's cloned literal, and it is silent. + // + // So the driver calls `ple_stage_token` once per token, BEFORE the graphs, and what is captured here is only + // the device half: `ple_block` reading `emb_dev`, and the history shift. + const bool fused = g_fused_gr && stage_prefix == 0 && half == 0 && + strata::kernels::fused_gr_supported(g.n_embd, g.hc, g.hc_lr); + bool pending_ffn = fused && layer > 0; // layer-1's FFN write has not been applied to R yet + if (ple != nullptr && ple->ready() && layer == 1 && (half == 0 || half == 1)) { + if (pending_ffn) { + const strata::kernels::GrShapes gs0{g.n_embd, g.hc, g.hc_lr}; + gr_write(bb.R, bb.block_out, bb.inject2, gs0, bb.R, stream); + pending_ffn = false; + } + const int64_t hcd = strata::kernels::NG_HC_DIM; + strata::kernels::PleOut po; + // Exports must not alias the block's internal workspace. The previous diagnostic views used a + // different layout inside that workspace: exporting gated values overwrote normalized values + // before the latter were copied to history. Allocate only the one export the recurrence needs. + po.normalized = (float*) ((uint8_t*) ple->scratch + strata::kernels::ple_block_scratch_bytes()); + // `result` IS THE RESIDUAL, in place: the block computes `hidden[i] + gated[i] + conv[i]` elementwise, + // so reading and writing `R` at the same index is well-defined. + po.result = bb.R; + try { + strata::kernels::ple_block(ple->emb_dev, bb.R, ple->hist, ple->w, po, ple->scratch, stream); + } catch (const std::exception& error) { + err = std::string("block_layer_pre PLE: ") + error.what(); + return false; + } + + // ---- AND THE HISTORY ADVANCES, ONCE PER TOKEN, WHICH IS WHAT MAKES IT A CONV HISTORY. + // + // `ple_block` deliberately does NOT write back to the history - "the caller keeps ownership of its + // state and this stays a pure function of its inputs, which is what makes the chunked-versus-single-shot + // property testable" - so the shift is here. The layout is ROW-FASTEST (`hist[row + NG_HIST*channel]`, + // from `ggml_reshape_3d(state, d_conv-1, conv_channels, n_seqs)`), so a row shift is a STRIDED copy of + // NG_HIST-1 elements per channel and an append is a strided copy of one - not a flat memmove, which + // would be the natural reading and would scramble the channels. + strata::kernels::ple_history_advance(ple->hist, po.normalized, stream); + if (cudaPeekAtLastError() != cudaSuccess) { + err = "block_layer_pre: the PLE history shift failed"; + return false; + } + } + const bool qsa = is_qsa_layer(g, layer); const LayerView v(tables, layer); const strata::kernels::GrShapes gs{g.n_embd, g.hc, g.hc_lr}; +// ---- the two halves' GR tensors. Both halves have the same four names with a different prefix, and the +// prefix is the ONLY thing that distinguishes them - so it is built rather than written twice. +const char* pre[2] = {"hc_attn_", "hc_ffn_"}; const WeightRef* w_norm[2]; const WeightRef* w_down[2]; const WeightRef* w_up[2]; const WeightRef* w_inject[2]; for (int h = 0; h < 2; ++h) { const std::string a = std::string(pre[h]) + "norm.weight"; const std::string d = std::string(pre[h]) + "down.weight"; const std::string u = std::string(pre[h]) + "up.weight"; const std::string i = std::string(pre[h]) + "inject.weight"; w_norm[h] = v.get(a.c_str()); w_down[h] = v.get(d.c_str()); w_up[h] = v.get(u.c_str()); w_inject[h] = v.get(i.c_str()); if (!w_norm[h] || !w_down[h] || !w_up[h] || !w_inject[h]) { err = v.name((std::string(pre[h]) + "{norm,down,up,inject}.weight").c_str()) + " is missing"; return false; } +// The GR weights are BF16 and the arena holds them re-rounded to 2 B/elem. `gr_read` wants exactly +// that; handing it f32 bytes would walk 2x the tensor inside the arena without faulting. +if (w_down[h]->kind != WeightKind::Bf16InF32 || w_up[h]->kind != WeightKind::Bf16InF32 || w_inject[h]->kind != WeightKind::Bf16InF32 || w_norm[h]->kind != WeightKind::F32) { err = v.name(pre[h]) + "has the wrong engine forms (norm must be F32, the other three bf16)"; return false; } } +// ---- half 1: the mixer +float* R = bb.R; + // R0.11: WHICH STAGES TO RUN. `stage_prefix == 0` means "as `half` says", so every caller that predates + // this parameter is bit-for-bit unaffected; `stage_prefix == k` runs stages 0..k-1 and ignores `half`. + const bool run0 = stage_prefix > 0 ? (stage_prefix >= 1) : (half == 0 || half == 1); + const bool run1 = stage_prefix > 0 ? (stage_prefix >= 2) : (half == 0 || half == 1); + const bool run2 = stage_prefix > 0 ? (stage_prefix >= 3) : (half == 0 || half == 1); + const bool run3 = stage_prefix > 0 ? (stage_prefix >= 4) : (half == 0 || half == 2); + const bool run4 = stage_prefix > 0 ? (stage_prefix >= 5) : (half == 0 || half == 2); // R0.9: this is the MIXER half, and `half == 2` skips it entirely. A `half` of 1 or 0 runs it, + // and 0 is what every caller before this used. + if (run0) { +st_begin(layer, 0, stream); + if (fused) { + strata::kernels::FusedGrArgs fa; + fa.R = R; fa.R_out = R; fa.apply = pending_ffn; fa.bo_prev = bb.block_out; fa.inj_prev = bb.inject2; + fa.w_norm = (const float*) w_norm[0]->data; fa.w_down = (const uint16_t*) w_down[0]->data; + fa.w_up = (const uint16_t*) w_up[0]->data; fa.w_inject = (const uint16_t*) w_inject[0]->data; + fa.eps = RMS_EPS; fa.lo = bb.gr.lo; fa.rs = bb.gr_rs; fa.inject_out = bb.inject; fa.mixed = bb.mixed; + strata::kernels::fused_gr_read(fa, stream); + } else { + gr_read(R, (const float*) w_norm[0]->data, (const uint16_t*) w_down[0]->data, (const uint16_t*) w_up[0]->data, (const uint16_t*) w_inject[0]->data, RMS_EPS, gs, bb.gr, bb.mixed, bb.inject, stream); + } + st_end(layer, 0, stream); dump_half(bb, g, layer, bb.inject, 2 * g.n_embd, g.hc, stream); } + if (run1) { + st_begin(layer, 1, stream); if (qsa) { if (!qsa_layer(tables, g, layer, pos, pos_base, qst, qb, bb.mixed, bb.block_out, stream, err, bb.dump)) return false; } else { if (!gdn_layer(tables, g, layer, gb, bb.mixed, bb.block_out, stream, err)) return false; } st_end(layer, 1, stream); dump_half(bb, g, layer, bb.block_out, 0, g.n_embd, stream); } + if (run2) { + st_begin(layer, 2, stream); if (!fused) gr_write(R, bb.block_out, bb.inject, gs, R, stream); st_end(layer, 2, stream); +// ---- half 2, UP TO AND INCLUDING THE ROUTER. `gr_read` leaves the normed activation in `bb.mixed` and +// the per-stream injection in `bb.inject`, and BOTH must survive until `block_layer_post` runs - which is +// the contract the two functions have with each other and with the host loop's ordering. + } + // R0.9: the FFN front and the ROUTER. It reads `bb.mixed`/`bb.inject`, which the mixer left - + // the same contract `block_layer_post` has, and the reason the two are only meaningful in order. + if (run3) { +st_begin(layer, 3, stream); + if (fused) { + strata::kernels::FusedGrArgs fa; + fa.R = R; fa.R_out = R; fa.apply = true; fa.bo_prev = bb.block_out; fa.inj_prev = bb.inject; + fa.w_norm = (const float*) w_norm[1]->data; fa.w_down = (const uint16_t*) w_down[1]->data; + fa.w_up = (const uint16_t*) w_up[1]->data; fa.w_inject = (const uint16_t*) w_inject[1]->data; + fa.eps = RMS_EPS; fa.lo = bb.gr.lo; fa.rs = bb.gr_rs; fa.inject_out = bb.inject2; fa.mixed = bb.mixed; + strata::kernels::fused_gr_read(fa, stream); + } else { + gr_read(R, (const float*) w_norm[1]->data, (const uint16_t*) w_down[1]->data, (const uint16_t*) w_up[1]->data, (const uint16_t*) w_inject[1]->data, RMS_EPS, gs, bb.gr, bb.mixed, bb.inject, stream); + } + st_end(layer, 3, stream); } + if (run4) { + st_begin(layer, 4, stream); { const bool ok = moe_route(tables, g, layer, k, mb, bb.mixed, stream, err, db); st_end(layer, 4, stream); if (!ok) return false; } + // Plan v0.3 P3: after the ring, so the GPU computes the shared expert while the host runs the pool. + if (g_shared_early && !moe_shared(tables, g, layer, mb, bb.mixed, stream, err)) return false; + } + // Every stage this call was asked to run has run. There is no other way out: the old `return ok;` + // lived inside the stage-4 block and restructuring that left the function with no return at all. + return true; +} +bool ple_issue_token(const PleRun& p, std::string& err) { + if (!p.ready()) { err = "ple_issue_token: the PLE run is not ready"; return false; } + uint32_t rows[strata::kernels::PLE_N_HEADS]; + strata::kernels::ngram_rows(p.token, p.prev, 1, p.consts, rows); + if (!p.table->issue(rows)) { err = "ple_issue_token: the previous token's rows were never collected"; return false; } + return true; +} + +bool ple_finish_token(const PleRun& p, void* stream, std::string& err) { + if (!p.ready()) { err = "ple_finish_token: the PLE run is not ready"; return false; } + if (!p.table->collect(p.emb_host, err)) { err = "ple_finish_token: " + err; return false; } + if (cudaMemcpyAsync(p.emb_dev, p.emb_host, (size_t) strata::kernels::NG_N_EMBD * sizeof(float), + cudaMemcpyHostToDevice, (cudaStream_t) stream) != cudaSuccess) { + err = "ple_finish_token: the row upload failed"; + return false; + } + return true; +} + +bool ple_stage_token(const PleRun& p, void* stream, std::string& err) { + if (!ple_issue_token(p, err)) return false; + return ple_finish_token(p, stream, err); +} + +uint64_t ple_run_scratch_bytes() { + // Internal workspace followed by a disjoint normalized export used for the next history row. + return strata::kernels::ple_block_scratch_bytes() + strata::kernels::NG_HC_DIM * sizeof(float); +} + +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) { const strata::kernels::GrShapes gs{g.n_embd, g.hc, g.hc_lr}; +// `bb.mixed` and `bb.inject` are what `block_layer_pre` left, and NOTHING between the two calls may touch +// them - that is what makes `post[l]` safe to launch after the host has run the pool. +st_begin(layer, 5, stream); + if (g_shared_early ? !moe_combine_parts(g, layer, k, mb, parts, bb.block_out, stream, err) + : !moe_finish(tables, g, layer, k, mb, bb.mixed, parts, bb.block_out, stream, err)) return false; + st_end(layer, 5, stream); dump_half(bb, g, layer, bb.block_out, (uint64_t) g.n_embd, g.n_embd, stream); dump_half(bb, g, layer, bb.inject, (uint64_t) 2 * g.n_embd + g.hc, g.hc, stream); st_begin(layer, 6, stream); + if (!(g_fused_gr && strata::kernels::fused_gr_supported(g.n_embd, g.hc, g.hc_lr))) + gr_write(bb.R, bb.block_out, bb.inject, gs, bb.R, stream); + else if (layer == g.n_layers - 1) + gr_write(bb.R, bb.block_out, bb.inject2, gs, bb.R, stream); // materialise R for the head + st_end(layer, 6, stream); return true;} +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, const PleRun* ple) { +// The two halves back to back. `parts` must be THIS layer's experts and must be ready before the call - +// which holds for `session_token`, where the caller supplies them, and does NOT hold for a host loop that +// has only just seen the router. That is why the captured path calls the halves separately. +if (!block_layer_pre(tables, g, layer, pos, pos_base, gb, qst, qb, mb, k, bb, stream, err, db, ple)) return false; return block_layer_post(tables, g, layer, k, mb, bb, parts, stream, err);}} +// namespace strata::core diff --git a/src/core/layout.cpp b/src/core/layout.cpp new file mode 100644 index 00000000..6d1b970b --- /dev/null +++ b/src/core/layout.cpp @@ -0,0 +1,173 @@ +// src/core/layout.cpp - the shape checks. See the header for why this exists. +#include "strata/core/layout.hpp" + +#include + +namespace strata::core { +namespace { + +/// A required 2-D shape. `ne0` is the CONTIGUOUS axis, matching the manifest and `s_gemv`'s convention +/// (`y[o] = sum_i x[i]*W[i][o]`, row o contiguous of length ne0). +struct Want2 { + const char* suffix; + int64_t ne0; + int64_t ne1; + bool qsa_only; + bool gdn_only; +}; + +/// A required element count and ENGINE FORM for a 1-D tensor. +/// +/// `kind` is not decoration. The engine form of a 1-D tensor is whatever `WeightKind` the loader applied - +/// `F32` copies 4 B/elem, `Bf16InF32` re-rounds to 2 - and a consumer that casts either one to `const float*` +/// reads 2x its length. **THAT IS NOT HYPOTHETICAL: `ffn_gate_inp_shexp.weight` is BF16, so the arena holds +/// 5120 B for 2560 elements, and `shared_expert` read it as f32. Every one of the 2560 MoE outputs came out +/// non-finite** - a wrong answer loud enough to notice, which is the lucky version. Reading 5120 B past the +/// end of a tensor inside a 4.5 GiB arena does not fault, so the same mistake on a tensor followed by +/// plausible bytes would have produced plausible logits. +struct Want1 { + const char* suffix; + int64_t elements; + WeightKind kind; + bool qsa_only; + bool gdn_only; +}; + +bool fail(std::string& err, const LayerView& v, const char* suffix, const char* what, int64_t got, + int64_t want) { + char buf[512]; + std::snprintf(buf, sizeof buf, "layer %lld: %s %s is %lld, the kernels require %lld", + (long long) v.layer(), v.name(suffix).c_str(), what, (long long) got, (long long) want); + err = buf; + return false; +} + +bool check_one(const WeightTable& t, const ModelGeometry& g, int64_t layer, std::string& err) { + const LayerView v(t, layer); + const bool qsa = is_qsa_layer(g, layer); + + // ---- the 2-D tensor set, per layer family + const Want2 want2[] = { + // gated residual, EVERY layer - `gr_read` takes w_down (hc_lr, hc_dim) and w_up (hc_lr, hc_dim) + // after its own transpose, so the pack's orientation is [hc_dim, hc_lr] and [hc_lr, hc_dim]. + {"hc_attn_down.weight", g.hc_dim(), g.hc_lr, false, false}, + {"hc_attn_up.weight", g.hc_lr, g.hc_dim(), false, false}, + {"hc_attn_inject.weight", g.hc_dim(), g.hc, false, false}, + {"hc_ffn_down.weight", g.hc_dim(), g.hc_lr, false, false}, + {"hc_ffn_up.weight", g.hc_lr, g.hc_dim(), false, false}, + {"hc_ffn_inject.weight", g.hc_dim(), g.hc, false, false}, + // MoE, EVERY layer + {"ffn_gate_inp.weight", g.n_embd, g.n_expert, false, false}, + {"ffn_gate_shexp.weight", g.n_embd, g.n_ff, false, false}, + {"ffn_up_shexp.weight", g.n_embd, g.n_ff, false, false}, + {"ffn_down_shexp.weight", g.n_ff, g.n_embd, false, false}, + // GDN only + {"attn_qkv.weight", g.n_embd, g.ssm_conv_channels, false, true}, + {"attn_gate.weight", g.n_embd, g.ssm_value_dim, false, true}, + {"ssm_out.weight", g.ssm_value_dim, g.n_embd, false, true}, + {"ssm_conv1d.weight", g.ssm_d_conv, g.ssm_conv_channels, false, true}, + {"ssm_alpha.weight", g.n_embd, g.ssm_v_heads, false, true}, + {"ssm_beta.weight", g.n_embd, g.ssm_v_heads, false, true}, + // QSA only + {"attn_q.weight", g.n_embd, 2 * g.n_head * g.head_dim, true, false}, + {"attn_k.weight", g.n_embd, g.n_head_kv * g.head_dim, true, false}, + {"attn_v.weight", g.n_embd, g.n_head_kv * g.head_dim, true, false}, + {"attn_output.weight", g.n_head * g.head_dim, g.n_embd, true, false}, + // THE INDEXER. `q_proj` is the QUERY count and `k_proj` is the KEY width, and they are different + // numbers - conflating them is what made the planner's indexer term 4x too big in round 194. + {"indexer.q_proj.weight", g.n_embd, g.idx_q_heads * g.idx_key_dim, true, false}, + {"indexer.k_proj.weight", g.n_embd, g.idx_key_dim, true, false}, + }; + for (const Want2& w : want2) { + if (w.qsa_only && !qsa) continue; + if (w.gdn_only && qsa) continue; + const WeightRef* r = v.get(w.suffix); + if (!r) { + err = "layer " + std::to_string(layer) + ": missing " + v.name(w.suffix); + return false; + } + if (r->ne0 != w.ne0) return fail(err, v, w.suffix, "ne0", r->ne0, w.ne0); + if (r->ne1 != w.ne1) return fail(err, v, w.suffix, "ne1", r->ne1, w.ne1); + } + + // ---- the 1-D set. EVERY ONE OF THESE IS `F32` EXCEPT the shared expert's scalar gate, which is BF16 - + // and that single exception is the one a reader would not guess, so it is written down rather than + // inferred from the tensor count. + const Want1 want1[] = { + {"hc_attn_norm.weight", g.hc_dim(), WeightKind::F32, false, false}, + {"hc_ffn_norm.weight", g.hc_dim(), WeightKind::F32, false, false}, + {"ffn_gate_inp_shexp.weight", g.n_embd, WeightKind::Bf16InF32, false, false}, + {"ssm_a", g.ssm_v_heads, WeightKind::F32, false, true}, + {"ssm_dt.bias", g.ssm_v_heads, WeightKind::F32, false, true}, + {"ssm_norm.weight", g.ssm_state_size, WeightKind::F32, false, true}, + {"attn_q_norm.weight", g.head_dim, WeightKind::F32, true, false}, + {"attn_k_norm.weight", g.head_dim, WeightKind::F32, true, false}, + {"indexer.q_norm.weight", g.idx_key_dim, WeightKind::F32, true, false}, + {"indexer.k_norm.weight", g.idx_key_dim, WeightKind::F32, true, false}, + }; + for (const Want1& w : want1) { + if (w.qsa_only && !qsa) continue; + if (w.gdn_only && qsa) continue; + const WeightRef* r = v.get(w.suffix); + if (!r) { + err = "layer " + std::to_string(layer) + ": missing " + v.name(w.suffix); + return false; + } + if (r->elements != w.elements) return fail(err, v, w.suffix, "elements", r->elements, w.elements); + if (r->kind != w.kind) { + // The byte count is what makes this a REAL check and not a label: a 4 B/elem tensor holds + // `elements * 4`, a 2 B/elem one holds `elements * 2`, and a consumer reading it as the wrong one + // walks off the end. + const uint64_t want_bytes = (uint64_t) w.elements * + (w.kind == WeightKind::Bf16InF32 ? 2u : 4u); + char buf[512]; + std::snprintf(buf, sizeof buf, + "layer %lld: %s is engine form %d (%llu B), the kernels read it as form %d (%llu B)", + (long long) layer, v.name(w.suffix).c_str(), (int) r->kind, + (unsigned long long) r->bytes, (int) w.kind, (unsigned long long) want_bytes); + err = buf; + return false; + } + } + return true; +} + +} // namespace + +std::string LayerView::name(const char* suffix) const { + return "blk." + std::to_string(layer_) + "." + suffix; +} + +bool check_layer(const WeightTable& table, const ModelGeometry& g, int64_t layer, std::string& err) { + if (layer < 0 || layer >= g.n_layers) { + err = "layer " + std::to_string(layer) + " is outside 0.." + std::to_string(g.n_layers - 1); + return false; + } + return check_one(table, g, layer, err); +} + +bool check_all(const WeightTable& table, const ModelGeometry& g, std::string& err) { + int64_t n_qsa = 0, n_gdn = 0; + for (int64_t l = 0; l < g.n_layers; ++l) { + if (!check_one(table, g, l, err)) return false; + if (is_qsa_layer(g, l)) { + ++n_qsa; + } else { + ++n_gdn; + } + } + // The split is derived twice over in `docs/semantics.md` - the interval and an explicit ratio array - + // and both give 36 GDN and 12 QSA. Counting them here is the check that the LAYER TYPE PREDICATE and + // the pack agree, which no per-tensor shape check can see. + if (n_qsa != g.n_qsa_layers() || n_gdn != g.n_gdn_layers()) { + char buf[256]; + std::snprintf(buf, sizeof buf, "layer split is %lld QSA / %lld GDN, the geometry says %lld / %lld", + (long long) n_qsa, (long long) n_gdn, (long long) g.n_qsa_layers(), + (long long) g.n_gdn_layers()); + err = buf; + return false; + } + return true; +} + +} // namespace strata::core diff --git a/src/core/load_main.cpp b/src/core/load_main.cpp new file mode 100644 index 00000000..a508ca2f --- /dev/null +++ b/src/core/load_main.cpp @@ -0,0 +1,119 @@ +// src/core/load_main.cpp - `strata-load`: the expert arena load, measurable at any scale. +// +// P2.S1 gives this a target (<= 60 s cold, <= 10 s warm for 33.97 GB) and a method (8 threads, 16 MB chunks, +// a checksum per layer). The size is a FLAG rather than hard-coded because the honest way to measure a 34 GB +// load is to measure a smaller one first and project - and because pinning 34 GB evicts every page of page +// cache on the machine, which is not something a test should do while other work is running. +#include "strata/core/pinned.hpp" + +#include +#include +#include +#include + +int main(int argc, char** argv) { + std::string path; + uint64_t layers = 2; + int threads = 8; + uint64_t chunk = 16ull << 20; + uint64_t blob_bytes = 1382400; // architecture §3.2, asserted by tools/pack_layer.py + uint64_t blobs_per_layer = 512; + bool pin = true; + bool stream = false; + + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto val = [&]() -> const char* { + if (i + 1 >= argc) { std::fprintf(stderr, "%s needs a value\n", a.c_str()); std::exit(2); } + return argv[++i]; + }; + if (a == "--file") path = val(); + else if (a == "--layers") layers = std::strtoull(val(), nullptr, 10); + else if (a == "--threads") threads = std::atoi(val()); + else if (a == "--chunk-mb") chunk = std::strtoull(val(), nullptr, 10) << 20; + else if (a == "--no-pin") pin = false; + else if (a == "--stream") stream = true; + else if (a == "--help" || a == "-h") { + std::printf("usage: strata-load --file experts.bin [--layers N] [--threads N] [--chunk-mb N]\n" + " [--no-pin]\n"); + return 0; + } else { + std::fprintf(stderr, "unknown argument: %s\n", a.c_str()); + return 2; + } + } + if (path.empty()) { std::fprintf(stderr, "--file is required\n"); return 2; } + + const uint64_t bytes = layers * blobs_per_layer * blob_bytes; + std::printf("expert arena: %llu layers x %llu blobs x %llu B = %.3f GiB\n", (unsigned long long) layers, + (unsigned long long) blobs_per_layer, (unsigned long long) blob_bytes, + (double) bytes / (1024.0 * 1024 * 1024)); + + strata::core::PinnedArena arena; + uint8_t* dst = nullptr; + if (pin) { + // constructed in place so the note from the constructor can be printed before the load + new (&arena) strata::core::PinnedArena(bytes); + std::printf("backing: %s\n", arena.note.c_str()); + if (!arena.valid()) { std::fprintf(stderr, "arena allocation failed\n"); return 1; } + dst = arena.data(); + } else { + dst = (uint8_t*) std::malloc((size_t) bytes); + if (!dst) { std::fprintf(stderr, "malloc failed\n"); return 1; } + std::printf("backing: malloc, NOT pinned (--no-pin)\n"); + } + + const strata::core::LoadStats st = + strata::core::load_experts(path, dst, blob_bytes, blobs_per_layer, layers, threads, chunk); + if (st.seconds < 0) return 1; + + std::printf("loaded %.3f GiB in %.3f s with %d threads and %llu MiB chunks -> %.2f GiB/s\n", + (double) st.bytes / (1024.0 * 1024 * 1024), st.seconds, threads, + (unsigned long long) (chunk >> 20), st.gib_per_second()); + std::printf("per-layer checksums (FNV-1a 64):\n"); + for (size_t i = 0; i < st.layer_checksums.size() && i < 4; ++i) { + std::printf(" layer %zu %016llx\n", i, (unsigned long long) st.layer_checksums[i]); + } + if (st.layer_checksums.size() > 4) { + std::printf(" ... %zu more (all layers loaded)\n", st.layer_checksums.size() - 4); + } + if (stream) { + // The bus is the ceiling, so measure it at the granularity the engine actually uses. Printed as a + // sweep rather than one number because "bandwidth" without a transfer size is not a specification. + std::printf("\nexpert stream over the pinned arena (host -> device, one reused destination):\n"); + const uint64_t granularities[] = {blob_bytes, 16 * blob_bytes, 512 * blob_bytes, bytes}; + const char* names[] = {"1 blob (1.38 MB)", "16 blobs (22 MB)", "1 layer (707 MB)", "whole arena"}; + for (int i = 0; i < 4; ++i) { + const uint64_t g = granularities[i]; + if (g > bytes) continue; + const int iters = (int) (g >= bytes ? 3 : (bytes / g >= 8 ? 8 : 3)); + const strata::core::StreamStats ss = + strata::core::stream_bandwidth(dst, (bytes / g) * g, g, iters); + if (ss.seconds < 0) continue; + std::printf(" %-18s chunk %8.2f MB %6.2f GB/s (%.3f GiB in %.3f s)\n", names[i], + (double) g / 1e6, ss.gib_per_second() * 1.073741824, + (double) ss.bytes / (1024.0 * 1024 * 1024), ss.seconds); + } + // And what it means, using the design's own constants, stated with the assumption attached. + const double best = 60.0; // GB/s, replaced below by the measured best if it is larger + (void) best; + std::printf("\nper-token expert stream at E = 663.6 MB and hit rate h (VRAM-resident fraction):\n"); + std::printf(" %-8s %-14s %-14s %s\n", "h", "miss MB/token", "ms at 1 blob", "tok/s ceiling"); + const strata::core::StreamStats one = strata::core::stream_bandwidth(dst, blob_bytes, blob_bytes, 64); + for (double h : {0.0, 0.5, 0.833, 0.9, 0.95}) { + const double miss_mb = (1.0 - h) * 663.6; + const double ms = miss_mb / 1000.0 / (one.gib_per_second() * 1.073741824) * 1000.0; + std::printf(" %-8.3f %-14.1f %-14.2f %.1f\n", h, miss_mb, ms, ms > 0 ? 1000.0 / ms : 0.0); + } + std::printf(" h = 0.167 is the design's constant; h = 0.833 is its complement, shown because the\n" + " plan's E = 663.6 MB is the FULL expert set per token, so which fraction is cached is\n" + " the question the number hinges on.\n"); + } + + // The projection is the number the phase's <= 60 s target is about, and it is printed as a projection + // rather than as the result, because at --layers 2 this program has NOT loaded 33.97 GB. + const double full = 33973862400.0; + std::printf("projection for the full %.2f GiB at this rate: %.1f s\n", full / (1024.0 * 1024 * 1024), + full / (1024.0 * 1024 * 1024) / (st.gib_per_second() > 0 ? st.gib_per_second() : 1e-9)); + return 0; +} diff --git a/src/core/mtp.cpp b/src/core/mtp.cpp new file mode 100644 index 00000000..8c6536d7 --- /dev/null +++ b/src/core/mtp.cpp @@ -0,0 +1,588 @@ +// src/core/mtp.cpp - see include/strata/core/mtp.hpp. +#include "strata/core/mtp.hpp" + +#include "strata/core/native_head.hpp" +#include "strata/kernels/bf16_gemv.hpp" +#include "strata/kernels/cpu/expert.hpp" +#include "strata/kernels/elementwise.hpp" +#include "strata/kernels/fused_gr.hpp" +#include "strata/kernels/gr.hpp" +#include "strata/kernels/kv_q8.hpp" +#include "strata/kernels/native_moe.hpp" +#include "strata/kernels/native_mmvq.hpp" +#include "strata/kernels/native_qsa.hpp" +#include "strata/kernels/native_rope.hpp" +#include "strata/kernels/native_router.hpp" +#include "strata/kernels/qsa.hpp" +#include "strata/kernels/qsa_decode_attn.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/rope.hpp" +#include "strata/kernels/router_top10.hpp" +#include "strata/kernels/s2_expert_grouped.hpp" +#include "strata/kernels/sampler.hpp" +#include "strata/kernels/shared_expert.hpp" +#include "strata/kernels/verify_kernels.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace strata::core { +namespace { + +constexpr float EPS = 1e-6f; +constexpr int GGML_Q8_0 = 8; +using Clock = std::chrono::steady_clock; +double ms_since(Clock::time_point t) { return std::chrono::duration(Clock::now() - t).count(); } + +struct Bump { + uint8_t* base = nullptr; + uint64_t used = 0; + template T* take(uint64_t n) { + T* p = base ? (T*) (base + used) : nullptr; + used += (n * sizeof(T) + 255) & ~255ull; + return p; + } +}; + +bool mapped(size_t bytes, void** h, void** d) { + if (cudaHostAlloc(h, bytes, cudaHostAllocMapped) != cudaSuccess) return false; + std::memset(*h, 0, bytes); + return cudaHostGetDevicePointer(d, *h, 0) == cudaSuccess; +} + +strata::kernels::QsaShapes shapes_of(const ModelGeometry& g) { + strata::kernels::QsaShapes s = strata::kernels::qsa_real_shapes(); + s.n_head = g.n_head; + s.n_head_kv = g.n_head_kv; + s.head_dim = g.head_dim; + s.idx_n_head = g.idx_q_heads; + s.idx_dim = g.idx_key_dim; + return s; +} + +bool read_file(const std::string& path, std::vector& out) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return false; + const std::streamsize n = f.tellg(); + f.seekg(0); + out.resize((size_t) n); + return (bool) f.read((char*) out.data(), n); +} + +} // namespace + +MtpDrafter::~MtpDrafter() { + if (cs_) cudaStreamSynchronize(cs_); + for (auto& e : prefill_exec_) if (e) cudaGraphExecDestroy(e); + for (auto& e : round_exec_) if (e) cudaGraphExecDestroy(e); + for (auto& e : step_exec_) if (e) cudaGraphExecDestroy(e); + if (cs_) cudaStreamDestroy(cs_); + if (dense_) cudaFree(dense_); + if (experts_) cudaFree(experts_); + if (state_arena_) cudaFree(state_arena_); + if (arena_) cudaFree(arena_); + if (head_logits_) cudaFree(head_logits_); + if (dhead_) cudaFree(dhead_); + if (dvocab_) cudaFree(dvocab_); + void* hosts[] = {h_tok_, h_step_, h_pos_, h_row_, h_out_, h_prob_}; + for (void* h : hosts) if (h) cudaFreeHost(h); +} + +const float* MtpDrafter::f32(const char* name) const { + for (const auto& t : tensors_) if (t.name == name && t.kind == "f32") return (const float*) (dense_ + t.off); + return nullptr; +} +const uint16_t* MtpDrafter::bf16(const char* name) const { + for (const auto& t : tensors_) if (t.name == name && t.kind == "bf16") return (const uint16_t*) (dense_ + t.off); + return nullptr; +} +const void* MtpDrafter::q8(const char* name) const { + for (const auto& t : tensors_) if (t.name == name && t.kind == "q8_0") return dense_ + t.off; + return nullptr; +} + +bool MtpDrafter::load(const std::string& rt_dir, const ModelGeometry& g, SessionState& ss, int max_t, std::string& err, + int64_t window) { + g_ = &g; + ss_ = &ss; + max_t_ = max_t; + rt_dir_ = rt_dir; + if (max_t < 1 || max_t > strata::kernels::kVerifyMaxT) { err = "mtp: max_t out of range"; return false; } + // ---- the index and the dense weights + { + std::ifstream idx(rt_dir + "/dense.txt"); + if (!idx) { err = "mtp: cannot open " + rt_dir + "/dense.txt (run tools/mtp_rt.py)"; return false; } + std::string line; + while (std::getline(idx, line)) { + if (line.empty()) continue; + std::istringstream is(line); + Tensor t; + is >> t.name >> t.kind >> t.rows >> t.cols >> t.off >> t.bytes; + if (!is) { err = "mtp: malformed dense.txt line: " + line; return false; } + tensors_.push_back(t); + } + std::vector blob; + if (!read_file(rt_dir + "/dense.bin", blob)) { err = "mtp: cannot read dense.bin"; return false; } + if (cudaMalloc((void**) &dense_, blob.size()) != cudaSuccess) { err = "mtp: dense weights do not fit"; return false; } + cudaMemcpy(dense_, blob.data(), blob.size(), cudaMemcpyHostToDevice); + vram_ += blob.size(); + } + // ---- the 512 routed experts, one blob each + { + const uint64_t bytes = (uint64_t) g.n_expert * strata::kernels::cpu::BLOB; + std::ifstream f(rt_dir + "/experts.bin", std::ios::binary); + if (!f) { err = "mtp: cannot open experts.bin"; return false; } + if (cudaMalloc((void**) &experts_, bytes) != cudaSuccess) { err = "mtp: the 512 experts do not fit in VRAM"; return false; } + std::vector chunk(64u << 20); + for (uint64_t off = 0; off < bytes;) { + const uint64_t n = std::min(chunk.size(), bytes - off); + if (!f.read((char*) chunk.data(), (std::streamsize) n)) { err = "mtp: experts.bin is truncated"; return false; } + cudaMemcpy(experts_ + off, chunk.data(), n, cudaMemcpyHostToDevice); + off += n; + } + vram_ += bytes; + } + const char* required[] = {"fc_embedding.weight", "fc_hidden.weight", "self_attn.q_proj.weight", "self_attn.k_proj.weight", + "self_attn.v_proj.weight", "self_attn.o_proj.weight", "mlp.shared_expert.gate_proj.weight", + "mlp.shared_expert.up_proj.weight", "mlp.shared_expert.down_proj.weight"}; + for (const char* n : required) if (!q8(n)) { err = std::string("mtp: ") + n + " is missing (q8_0)"; return false; } + + // ---- the layer's own K/V (dense attention: no indexer state is read) + const strata::kernels::QsaShapes s = shapes_of(g); + const int64_t max_cells = ss.qsa_states[0].max_cells; + const uint64_t sb = qsa_state_bytes(g, max_cells, false); + if (cudaMalloc(&state_arena_, sb) != cudaSuccess) { err = "mtp: the K/V state does not fit"; return false; } + if (qsa_state_init(g, max_cells, state_arena_, st_, &ss.qsa_states[0]) == 0) { err = "mtp: state init failed"; return false; } + qsa_state_zero(st_, g, nullptr); + cudaDeviceSynchronize(); + vram_ += sb; + + // ---- buffers + window_ = (window > 0 && window < max_cells) ? window : 0; + cap_ = (((window_ > 0 ? window_ : max_cells) + 63) / 64) * 64; + attn_scratch_floats_ = (int64_t) strata::kernels::qsa_decode_attn_scratch_floats(cap_, s); + const uint64_t T = (uint64_t) max_t, N = (uint64_t) g.n_embd, HC = (uint64_t) g.hc, K = (uint64_t) ss.k; + const uint64_t NH = (uint64_t) g.n_head, HD = (uint64_t) g.head_dim, NKV = (uint64_t) g.n_head_kv; + const uint64_t R2 = 2 * T; // step/pos rows: T catch-up rows + up to T-2 chain steps + bool ok = mapped(T * 4 + 64, (void**) &h_tok_, (void**) &m_tok_) && + mapped(R2 * 4 * 4 + 64, (void**) &h_step_, (void**) &m_step_) && + mapped(R2 * NH * 4 + 64, (void**) &h_pos_, (void**) &m_pos_) && + mapped(64, (void**) &h_row_, (void**) &m_row_) && + mapped(T * 4 + 64, (void**) &h_out_, (void**) &m_out_) && + mapped(T * 4 + 64, (void**) &h_prob_, (void**) &m_prob_); + if (!ok) { err = "mtp: mapped staging failed"; return false; } + auto carve = [&](Bump& b) { + tok_ = b.take(T); step_ = b.take(R2 * 4); pos_ = b.take(R2 * NH); row_ = b.take(4); + ident_ = b.take(T * (uint64_t) cap_); + Rin_ = b.take(T * HC * N); R_ = b.take(T * HC * N); + emb_ = b.take(T * N); en_ = b.take(T * N); e2_ = b.take(T * N); + hn_ = b.take(T * HC * N); h2_ = b.take(T * HC * N); + mixed_ = b.take(T * N); inj_ = b.take(T * HC); inj2_ = b.take(T * HC); + lo_ = b.take(T * (uint64_t) g.hc_lr); rs_ = b.take(T * HC); bo_ = b.take(T * N); + xn_ = b.take(T * HC * N); + xq_ = b.take(strata::kernels::native_q8_1_bytes((int) (NH * HD), 8)); + qfull_ = b.take(T * NH * 2 * HD); qcur_ = b.take(T * NH * HD); + kcur_ = b.take(T * NKV * HD); vcur_ = b.take(T * NKV * HD); + attn_ = b.take(T * NH * HD); attn32_ = b.take(T * NH * HD); + attn_scratch_ = b.take((uint64_t) attn_scratch_floats_); // the full layer runs one row at a time + logits_ = b.take(T * (uint64_t) g.n_expert); w_ = b.take(T * K); ids_ = b.take(T * K); + shared_ = b.take(T * N); parts_ = b.take(T * K * N); y_ = b.take(T * N); + sample_ = b.take(T * N); + hit_slot_ = b.take(T * K); hit_dst_ = b.take(T * K); hit_count_ = b.take(4); + grp_ptr_ = b.take(T * K); grp_start_ = b.take(T * K + 1); + grp_counts_ = b.take(4); + hit_xq_ = b.take(T * (N / 32) * 34); hit_xs_ = b.take(T * (N / 32)); + hit_scratch_ = b.take(strata::kernels::moe_hit_grouped_scratch_bytes((int64_t) (T * K), g.n_embd, g.n_ff)); + sh_scratch_ = (float*) b.take(strata::kernels::shared_expert_scratch_bytes(g.n_ff)); + x_bf16_ = b.take(N); + out_ids_ = b.take(T + 4); + probs_ = b.take(T + 4); + dummy_inj_ = b.take(HC); + }; + Bump count; + carve(count); + if (cudaMalloc(&arena_, count.used) != cudaSuccess) { err = "mtp: buffers do not fit"; return false; } + cudaMemset(arena_, 0, count.used); + Bump real; + real.base = (uint8_t*) arena_; + carve(real); + vram_ += count.used; + { + std::vector id((size_t) (T * (uint64_t) cap_)); + for (uint64_t t = 0; t < T; ++t) + for (int64_t i = 0; i < cap_; ++i) id[(size_t) (t * (uint64_t) cap_ + (uint64_t) i)] = (int32_t) i; + cudaMemcpy(ident_, id.data(), id.size() * 4, cudaMemcpyHostToDevice); + } + if (cudaStreamCreateWithFlags(&cs_, cudaStreamNonBlocking) != cudaSuccess) { err = "mtp: stream"; return false; } + std::fprintf(stderr, "strata mtp: draft layer loaded, %.0f MiB of VRAM (experts %.0f, dense %.0f)\n", + (double) vram_ / 1048576.0, (double) g.n_expert * strata::kernels::cpu::BLOB / 1048576.0, + (double) tensors_.back().off / 1048576.0); + return true; +} + +bool MtpDrafter::bind(const WeightTable& wt, const NativeHead* head, const float* window_R, std::string& err) { + wt_ = &wt; + head_ = head; + window_R_ = window_R; + const WeightRef* wo = wt.find("output.weight"); + if (!wo) { err = "mtp: output.weight is missing"; return false; } + n_vocab_ = wo->ne1; + if (head == nullptr || !head->loaded()) { err = "mtp: the draft layer needs the native head (--native)"; return false; } + if (head_logits_ == nullptr && + cudaMalloc((void**) &head_logits_, (size_t) max_t_ * (size_t) n_vocab_ * sizeof(float)) != cudaSuccess) { + err = "mtp: the draft logits do not fit"; + return false; + } + // the draft head's token subset, when tools/draft_vocab.py wrote one + if (dhead_ == nullptr) { + std::vector raw; + if (read_file(rt_dir_ + "/draft_vocab.bin", raw) && raw.size() >= 4 && raw.size() % 4 == 0) { + n_dvocab_ = (int64_t) (raw.size() / 4); + const int64_t row_bytes = (int64_t) head->row_bytes(); // a vocabulary row of the native head + if (cudaMalloc((void**) &dvocab_, raw.size()) != cudaSuccess || + cudaMalloc((void**) &dhead_, (size_t) (n_dvocab_ * row_bytes)) != cudaSuccess) { + err = "mtp: the draft head does not fit"; + return false; + } + cudaMemcpy(dvocab_, raw.data(), raw.size(), cudaMemcpyHostToDevice); + strata::kernels::gather_rows((const uint8_t*) head->weights(), row_bytes, dvocab_, n_dvocab_, dhead_, nullptr); + cudaDeviceSynchronize(); + vram_ += (uint64_t) (n_dvocab_ * row_bytes) + raw.size(); + std::fprintf(stderr, "strata mtp: draft head over %lld tokens (%.1f MiB)\n", (long long) n_dvocab_, + (double) (n_dvocab_ * row_bytes) / 1048576.0); + } + } + return true; +} + +// The layer for T rows. full = false stops after the K/V append (the prompt only needs the cache). +bool MtpDrafter::record_forward(int T, int step_row0, cudaStream_t cs, std::string& err) { + using namespace strata::kernels; + const ModelGeometry& g = *g_; + SessionState& ss = *ss_; + // step_row0 >= 0: the full layer on step rows [step_row0, +T); step_row0 < 0: K/V only on rows [-1 - step_row0, +T) + const bool full = step_row0 >= 0; + const int row0 = full ? step_row0 : -1 - step_row0; + if (full && T != 1) { err = "mtp: the full layer runs one row at a time (its attention scratch is sized for one)"; return false; } + const int64_t N = g.n_embd, HC = g.hc, K = ss.k, NH = g.n_head, HD = g.head_dim, NKV = g.n_head_kv; + const QsaShapes s = shapes_of(g); + const GrShapes gs{g.n_embd, g.hc, g.hc_lr}; + const int32_t* step = step_ + row0 * 4; + const int32_t* pos = pos_ + row0 * NH; + try { + // ---- the two input branches + const WeightRef* we = wt_->find("token_embd.weight"); + if (!we) { err = "mtp: token_embd.weight is missing"; return false; } + if (const NativeEmbed* ne = native_embed()) { // plan v0.3 P6: the GGUF-form table + ne->gather_dev(tok_, T, emb_, cs); + } else { + const auto* codes = (const uint8_t*) we->data; + const auto* scales = (const float*) (codes + we->codes_bytes); + const auto* offsets = we->has_offset ? (const float*) (codes + we->codes_bytes + we->scales_bytes) : nullptr; + embedding_gather_dev(codes, scales, offsets, tok_, T, we->ne0, we->code_bits, we->code_bias, we->group_elems, + (uint64_t) (we->ne0 / (8 / we->code_bits)), (uint64_t) (we->ne0 / we->group_elems), emb_, cs); + } + native_qsa_rms_norm_weighted(emb_, f32("pre_fc_norm_embedding.weight"), en_, (int) N, T, EPS, cs); + native_quantize_q8_1(en_, xq_, (int) N, T, cs); + native_mmvq(GGML_Q8_0, q8("fc_embedding.weight"), xq_, e2_, (int) N, (int) N, T, cs); + native_qsa_rms_norm_weighted(Rin_, f32("pre_fc_norm_hidden.weight"), hn_, (int) (HC * N), T, EPS, cs); + for (int c0 = 0; c0 < T * HC; c0 += 8) { + const int nc = (int) std::min(8, T * HC - c0); + native_quantize_q8_1(hn_ + (size_t) c0 * N, xq_, (int) N, nc, cs); + native_mmvq(GGML_Q8_0, q8("fc_hidden.weight"), xq_, h2_ + (size_t) c0 * N, (int) N, (int) N, nc, cs); + } + add_streams_broadcast(h2_, e2_, R_, N, (int) HC, T, cs); + // ---- the attention hyper-connection + { + FusedGrArgs fa[kFusedGrMaxT]; + for (int t = 0; t < T; ++t) { + fa[t].R = R_ + (size_t) t * HC * N; fa[t].R_out = R_ + (size_t) t * HC * N; fa[t].apply = false; + fa[t].w_norm = f32("attn_hyper_connection.hc_norm.weight"); + fa[t].w_down = bf16("attn_hyper_connection.input_mix_weight_down.weight"); + fa[t].w_up = bf16("attn_hyper_connection.input_mix_weight_up.weight"); + fa[t].w_inject = bf16("attn_hyper_connection.block_inject_weight.weight"); + fa[t].eps = EPS; fa[t].lo = lo_ + t * g.hc_lr; fa[t].rs = rs_ + t * HC; + fa[t].inject_out = inj_ + t * HC; fa[t].mixed = mixed_ + t * N; + } + fused_gr_read_multi(fa, T, xn_, cs); + } + // ---- attention: K/V into the layer's own cache, then (full) dense attention over every cell + auto norm_rope = [&](float* data, const float* gamma, int rows, int cols, const int32_t* p) { + native_qsa_rms_norm_weighted(data, gamma, data, cols, rows, EPS, cs); + if (native_rope_enabled()) native_rope_apply(data, data, rows, cols, (int) s.n_rot, (float) qsa_freq_base(), p, cs); + else rope_neox_apply(data, data, rows, cols, (int) s.n_rot, st_.cos_tab, st_.sin_tab, p, cs); + }; + native_quantize_q8_1(mixed_, xq_, (int) N, T, cs); + native_mmvq(GGML_Q8_0, q8("self_attn.k_proj.weight"), xq_, kcur_, (int) N, (int) (NKV * HD), T, cs); + native_mmvq(GGML_Q8_0, q8("self_attn.v_proj.weight"), xq_, vcur_, (int) N, (int) (NKV * HD), T, cs); + for (int t = 0; t < T; ++t) { + norm_rope(kcur_ + t * NKV * HD, f32("self_attn.k_norm.weight"), (int) NKV, (int) HD, pos + t * NH); + if (st_.kv_int8) + kv_append_q8_step(st_.k_q, st_.v_q, st_.k_scale, st_.v_scale, st_.page_table, step + t * 4, + kcur_ + t * NKV * HD, vcur_ + t * NKV * HD, s, cs); + else + kv_append_step(st_.k_pool, st_.v_pool, st_.page_table, step + t * 4, kcur_ + t * NKV * HD, + vcur_ + t * NKV * HD, s, cs); + } + if (!full) return true; + native_mmvq(GGML_Q8_0, q8("self_attn.q_proj.weight"), xq_, qfull_, (int) N, (int) (NH * 2 * HD), T, cs); + for (int t = 0; t < T; ++t) { + float* qc = qcur_ + t * NH * HD; + if (cudaMemcpy2DAsync(qc, (size_t) HD * 4, qfull_ + t * NH * 2 * HD, (size_t) HD * 2 * 4, (size_t) HD * 4, + (size_t) NH, cudaMemcpyDeviceToDevice, cs) != cudaSuccess) { + err = "mtp: q split failed"; + return false; + } + norm_rope(qc, f32("self_attn.q_norm.weight"), (int) NH, (int) HD, pos + t * NH); + } + QsaAttnPools pools; + pools.page_table = st_.page_table; + if (st_.kv_int8) { pools.k_q = st_.k_q; pools.v_q = st_.v_q; pools.k_scale = st_.k_scale; pools.v_scale = st_.v_scale; } + else { pools.k_pool = st_.k_pool; pools.v_pool = st_.v_pool; } + if (window_ > 0) window_ids(const_cast(step), T, (int) window_, ident_, cap_, cs); + qsa_decode_attn_batch(qcur_, pools, ident_, step, cap_, s, attn_scratch_, attn_, T, cs); + for (int t = 0; t < T; ++t) + native_qsa_gate_apply(attn_ + t * NH * HD, qfull_ + t * NH * 2 * HD, attn32_ + t * NH * HD, (int) NH, (int) HD, cs); + native_quantize_q8_1(attn32_, xq_, (int) (NH * HD), T, cs); + native_mmvq(GGML_Q8_0, q8("self_attn.o_proj.weight"), xq_, bo_, (int) (NH * HD), (int) N, T, cs); + // ---- the MLP hyper-connection (the attention write folded in) + { + FusedGrArgs fa[kFusedGrMaxT]; + for (int t = 0; t < T; ++t) { + fa[t].R = R_ + (size_t) t * HC * N; fa[t].R_out = R_ + (size_t) t * HC * N; fa[t].apply = true; + fa[t].bo_prev = bo_ + t * N; fa[t].inj_prev = inj_ + t * HC; + fa[t].w_norm = f32("mlp_hyper_connection.hc_norm.weight"); + fa[t].w_down = bf16("mlp_hyper_connection.input_mix_weight_down.weight"); + fa[t].w_up = bf16("mlp_hyper_connection.input_mix_weight_up.weight"); + fa[t].w_inject = bf16("mlp_hyper_connection.block_inject_weight.weight"); + fa[t].eps = EPS; fa[t].lo = lo_ + t * g.hc_lr; fa[t].rs = rs_ + t * HC; + fa[t].inject_out = inj2_ + t * HC; fa[t].mixed = mixed_ + t * N; + } + fused_gr_read_multi(fa, T, xn_, cs); + } + // ---- MoE: router, the 512 resident experts, the shared expert, the combine, the write + for (int t = 0; t < T; ++t) { + bf16_gemv_fp32_mmvf(mixed_ + t * N, bf16("mlp.gate.weight"), logits_ + t * g.n_expert, (int) N, (int) g.n_expert, cs); + if (native_router_enabled()) native_router_top10(logits_ + t * g.n_expert, ids_ + t * K, w_ + t * K, cs); + else router_top10(logits_ + t * g.n_expert, 1, (int) g.n_expert, (int) K, ids_ + t * K, w_ + t * K, cs); + } + moe_group_resident(ids_, (int) (T * K), (int) K, experts_, (int64_t) strata::kernels::cpu::BLOB, grp_ptr_, + grp_start_, grp_counts_, hit_dst_, hit_slot_, cs); + quantize_q8_0_scaled(mixed_, hit_xq_, hit_xs_, (int64_t) T * N, cs); + moe_grouped_s2(grp_ptr_, grp_start_, grp_counts_, hit_dst_, hit_slot_, (int64_t) T * K, (int64_t) T * K, hit_xq_, + hit_xs_, hit_scratch_, parts_, cs); + NativeSharedWeights nsw; + nsw.gate_type = GGML_Q8_0; nsw.gate_data = q8("mlp.shared_expert.gate_proj.weight"); + nsw.up_type = GGML_Q8_0; nsw.up_data = q8("mlp.shared_expert.up_proj.weight"); + nsw.down_type = GGML_Q8_0; nsw.down_data = q8("mlp.shared_expert.down_proj.weight"); + nsw.q8_1 = xq_; + const SForm none{}; + for (int t = 0; t < T; ++t) { + f32_to_bf16_bulk(mixed_ + t * N, x_bf16_, N, cs); + shared_expert(nullptr, nullptr, x_bf16_, none, nullptr, nullptr, nullptr, none, nullptr, nullptr, nullptr, none, + nullptr, nullptr, nullptr, bf16("mlp.shared_expert_gate.weight"), sh_scratch_, shared_ + t * N, + N, g.n_ff, 32, cs, mixed_ + t * N, &nsw); + if (native_moe_combine_enabled()) + native_moe_combine(parts_ + (size_t) t * K * N, w_ + t * K, shared_ + t * N, y_ + t * N, N, K, cs); + else + moe_combine(parts_ + (size_t) t * K * N, w_ + t * K, shared_ + t * N, y_ + t * N, N, K, cs); + gr_write(R_ + (size_t) t * HC * N, y_ + t * N, inj2_ + t * HC, gs, R_ + (size_t) t * HC * N, cs); + } + // ---- the final mixer and the main model's head + for (int t = 0; t < T; ++t) + gr_read(R_ + (size_t) t * HC * N, f32("hyper_connection_mixer.hc_norm.weight"), + bf16("hyper_connection_mixer.input_mix_weight_down.weight"), + bf16("hyper_connection_mixer.input_mix_weight_up.weight"), nullptr, EPS, gs, ss.block.gr, + sample_ + t * N, dummy_inj_, cs); + native_quantize_q8_1(sample_, xq_, (int) N, T, cs); + const bool sub = dhead_ != nullptr; + const int64_t nv = sub ? n_dvocab_ : n_vocab_; + native_mmvq(head_->type(), sub ? dhead_ : head_->weights(), xq_, head_logits_, (int) N, (int) nv, T, cs); + SamplerParams sp; + sp.greedy = true; + sp.temperature = 0.0f; + sample_tokens(head_logits_, T, (int) nv, nullptr, 0, sp, out_ids_, cs); + row_top_prob(head_logits_, T, (int) nv, out_ids_, probs_, cs); + if (sub) map_ids(out_ids_, dvocab_, T, cs); + } catch (const std::exception& e) { + err = std::string("mtp: ") + e.what(); + return false; + } + return true; +} + +namespace { +bool finish_capture(cudaStream_t cs, bool ok, cudaGraphExec_t& exec, const char* what, std::string& err) { + cudaGraph_t graph = nullptr; + const cudaError_t ce = cudaStreamEndCapture(cs, &graph); + if (!ok) { + if (graph) cudaGraphDestroy(graph); + return false; + } + if (ce != cudaSuccess || cudaGraphInstantiate(&exec, graph, 0) != cudaSuccess) { + if (graph) cudaGraphDestroy(graph); + err = std::string("mtp: ") + what + " capture: " + cudaGetErrorString(ce); + return false; + } + cudaGraphDestroy(graph); + // an explicit upload: the first launch's implicit one blocked behind a device-side spin (verify.cpp) + cudaGraphUpload(exec, cs); + cudaStreamSynchronize(cs); + return true; +} +} // namespace + +bool MtpDrafter::capture_prefill(int T, std::string& err) { + if (prefill_exec_[T]) return true; + using namespace strata::kernels; + if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { err = "mtp: begin capture"; return false; } + copy_i32_from_mapped(tok_, m_tok_, T, cs_); + copy_i32_from_mapped(step_, m_step_, (int64_t) T * 4, cs_); + copy_i32_from_mapped(pos_, m_pos_, (int64_t) T * g_->n_head, cs_); + const bool ok = record_forward(T, -1, cs_, err); // K/V only, rows [0, T) + return finish_capture(cs_, ok, prefill_exec_[T], "prefill", err); +} + +bool MtpDrafter::capture_round(int T, std::string& err) { + if (round_exec_[T]) return true; + using namespace strata::kernels; + const int64_t HCN = g_->hc * g_->n_embd; + if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { err = "mtp: begin capture"; return false; } + bool ok = true; + copy_i32_from_mapped(tok_, m_tok_, T, cs_); + copy_i32_from_mapped(step_, m_step_, (int64_t) 2 * T * 4, cs_); + copy_i32_from_mapped(pos_, m_pos_, (int64_t) 2 * T * g_->n_head, cs_); + copy_i32_from_mapped(row_, m_row_, 2, cs_); + copy_from_mapped(Rin_, window_R_, (int64_t) T * HCN, cs_); + // the catch-up: K/V for the window's T cells, then the full layer for row a only (its cell's K/V is written + // again, identically), staged by the host in step row 2*max_t - 1; the draft chain is one graph per step + // (`capture_step`) so the host can stop it when a draft is unlikely + const int ra = 2 * max_t_ - 1; + ok = record_forward(T, -1, cs_, err); + if (ok) mtp_select(Rin_, HCN, tok_, row_, Rin_, tok_, nullptr, 0, cs_); + if (ok) { + copy_i32_from_mapped(step_ + ra * 4, m_step_ + ra * 4, 4, cs_); + copy_i32_from_mapped(pos_ + ra * g_->n_head, m_pos_ + ra * g_->n_head, g_->n_head, cs_); + ok = record_forward(1, ra, cs_, err); + } + if (ok) mtp_select(R_, HCN, out_ids_, row_ + 1, Rin_, tok_, m_out_, 0, cs_, probs_, m_prob_); + return finish_capture(cs_, ok, round_exec_[T], "round", err); +} + +// Chain step j (1..max_t-2): one row at the cell staged in step row `max_t + j - 1`, from the previous step's +// residual and token (left in Rin_[0] / tok_[0] by mtp_select); draft j and its probability to the mapped outputs. +bool MtpDrafter::capture_step(int j, std::string& err) { + if (step_exec_[j]) return true; + using namespace strata::kernels; + const int64_t HCN = g_->hc * g_->n_embd; + const int row = max_t_ + j - 1; + if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { err = "mtp: begin capture"; return false; } + copy_i32_from_mapped(step_ + row * 4, m_step_ + row * 4, 4, cs_); + copy_i32_from_mapped(pos_ + row * g_->n_head, m_pos_ + row * g_->n_head, g_->n_head, cs_); + bool ok = record_forward(1, row, cs_, err); + if (ok) mtp_select(R_, HCN, out_ids_, row_ + 1, Rin_, tok_, m_out_, j, cs_, probs_, m_prob_); + return finish_capture(cs_, ok, step_exec_[j], "step", err); +} + +bool MtpDrafter::prefill(const float* R_rows, const int32_t* next_tokens, int64_t n, int64_t cell0, std::string& err) { + const Clock::time_point t0 = Clock::now(); + const int64_t HCN = g_->hc * g_->n_embd; + // cells the window can never reach again need no K/V + const int64_t first_needed = (window_ > 0 && prompt_len_ > 0) ? prompt_len_ - window_ - 64 : 0; + for (int64_t c = 0; c < n; c += max_t_) { + const int T = (int) std::min(max_t_, n - c); + if (cell0 + c + T <= first_needed) continue; + if (!capture_prefill(T, err)) return false; + for (int t = 0; t < T; ++t) { + const int64_t cell = cell0 + c + t; + h_tok_[t] = next_tokens[c + t]; + h_step_[t * 4 + 0] = (int32_t) cell; + h_step_[t * 4 + 1] = (int32_t) (cell + 1); + h_step_[t * 4 + 2] = (int32_t) ((cell + 1) / 4); + h_step_[t * 4 + 3] = (int32_t) (cell + 1); + for (int64_t h = 0; h < g_->n_head; ++h) h_pos_[t * g_->n_head + h] = (int32_t) cell; + } + if (cudaMemcpyAsync(Rin_, R_rows + (size_t) c * HCN, (size_t) T * HCN * sizeof(float), cudaMemcpyDeviceToDevice, + cs_) != cudaSuccess || + cudaGraphLaunch(prefill_exec_[T], cs_) != cudaSuccess || + cudaStreamSynchronize(cs_) != cudaSuccess) { + err = std::string("mtp prefill: ") + cudaGetErrorString(cudaGetLastError()); + return false; + } + } + ms_prefill += ms_since(t0); + return true; +} + +bool MtpDrafter::draft(int T, const int32_t* tokens, int64_t p, int a, int32_t* drafts, std::string& err, + float* probs, float min_p, int* n_drafts) { + if (T < 1 || T > max_t_ || a < 0 || a >= T) { err = "mtp: draft arguments out of range"; return false; } + if (!capture_round(T, err)) return false; + const Clock::time_point t0 = Clock::now(); + const int64_t NH = g_->n_head; + auto put = [&](int row, int64_t cell) { + h_step_[row * 4 + 0] = (int32_t) cell; + h_step_[row * 4 + 1] = (int32_t) (cell + 1); + h_step_[row * 4 + 2] = (int32_t) ((cell + 1) / 4); + h_step_[row * 4 + 3] = (int32_t) (cell + 1); + for (int64_t h = 0; h < NH; ++h) h_pos_[row * NH + h] = (int32_t) cell; + }; + for (int t = 0; t < T; ++t) { + h_tok_[t] = tokens[t]; + put(t, p + t); + } + put(2 * max_t_ - 1, p + a); + h_row_[0] = a; + h_row_[1] = 0; + std::atomic_thread_fence(std::memory_order_seq_cst); + if (cudaGraphLaunch(round_exec_[T], cs_) != cudaSuccess || cudaStreamSynchronize(cs_) != cudaSuccess) { + err = std::string("mtp draft: ") + cudaGetErrorString(cudaGetLastError()); + return false; + } + drafts[0] = ((volatile int32_t*) h_out_)[0]; + float pj = ((volatile float*) h_prob_)[0]; + if (probs) probs[0] = pj; + int n = 1; + // the chain continues while the last draft is likely enough to be verified + for (int j = 1; j < max_t_ - 1 && pj >= min_p; ++j) { + if (!capture_step(j, err)) return false; + put(max_t_ + j - 1, p + a + j); + std::atomic_thread_fence(std::memory_order_seq_cst); + if (cudaGraphLaunch(step_exec_[j], cs_) != cudaSuccess || cudaStreamSynchronize(cs_) != cudaSuccess) { + err = std::string("mtp draft step: ") + cudaGetErrorString(cudaGetLastError()); + return false; + } + drafts[j] = ((volatile int32_t*) h_out_)[j]; + pj = ((volatile float*) h_prob_)[j]; + if (probs) probs[j] = pj; + ++n; + } + for (int j = n; j < max_t_ - 1; ++j) { drafts[j] = 0; if (probs) probs[j] = 0.0f; } + if (n_drafts) *n_drafts = n; + ms_draft += ms_since(t0); + ++rounds; + return true; +} + +bool MtpDrafter::draft_first(int T, const float* R_row, int32_t token, int64_t cell, int32_t* drafts, std::string& err, + float* probs, float min_p, int* n_drafts) { + // row 0 is the real pair; rows 1.. repeat it and only write cells the next round overwrites + const int64_t HCN = g_->hc * g_->n_embd; + for (int t = 0; t < T; ++t) + if (cudaMemcpy((void*) (window_R_ + (size_t) t * HCN), R_row, (size_t) HCN * sizeof(float), + cudaMemcpyDeviceToDevice) != cudaSuccess) { + err = "mtp: staging the first residual failed"; + return false; + } + std::vector toks((size_t) T, token); + return draft(T, toks.data(), cell, 0, drafts, err, probs, min_p, n_drafts); +} + +} // namespace strata::core diff --git a/src/core/native_dense.cpp b/src/core/native_dense.cpp new file mode 100644 index 00000000..980ec434 --- /dev/null +++ b/src/core/native_dense.cpp @@ -0,0 +1,179 @@ +#include "strata/core/native_dense.hpp" +#include "strata/core/weights.hpp" +#include "strata/artifact/gguf_reader.hpp" +#include "strata/kernels/native_mmvq.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace strata::core { +namespace { +bool eligible(const std::string& name, bool include_ple_key) { + if (name.rfind("blk.", 0) != 0) return false; + if (include_ple_key && name == "blk.1.ple_key.weight") return true; + static const char* suffixes[] = {".attn_qkv.weight", ".attn_gate.weight", ".ssm_out.weight", + ".attn_q.weight", ".attn_k.weight", ".attn_v.weight", ".attn_output.weight", + ".ffn_gate_shexp.weight", ".ffn_up_shexp.weight", ".ffn_down_shexp.weight"}; + for (const char* suffix : suffixes) if (name.ends_with(suffix)) return true; + return false; +} +struct DeviceFree { void operator()(void* p) const { if (p) cudaFree(p); } }; +using DevicePtr = std::unique_ptr; +struct Pending { + WeightRef* ref; + int type; + uint64_t bytes; + DevicePtr data; +}; +} + +bool NativeDense::served_names(const std::vector& shards, bool include_ple_key, + std::set& out, std::string& err) { + try { + for (const auto& path : shards) { + strata::GgufFile gguf(path); + for (const auto& tensor : gguf.tensors()) + if (eligible(tensor.name, include_ple_key) && strata::kernels::native_mmvq_supported(tensor.type) && + tensor.shape.size() == 2) + out.insert(tensor.name); + } + return true; + } catch (const std::exception& error) { + err = std::string("native dense: ") + error.what(); + return false; + } +} + +NativeDense::~NativeDense() { + if (scratch_) cudaFree(scratch_); + for (void* p : weights_) cudaFree(p); +} + +bool NativeDense::load(const std::vector& shards, WeightTable& table, std::string& err, + bool include_ple_key) { + if (scratch_ || !weights_.empty()) { err = "native dense: already loaded"; return false; } + if (shards.empty()) { err = "native dense: at least one GGUF shard is required"; return false; } + try { + std::vector pending; + std::set seen; + int max_in = 0; + uint64_t total = 0; + uint64_t split_count = 0, split_tensors = 0; + std::set split_numbers; + bool have_architecture = false; + for (const auto& path : shards) { + strata::GgufFile gguf(path); + const auto* count = gguf.get("split.count"); + const auto* number = gguf.get("split.no"); + const auto* tensors = gguf.get("split.tensors.count"); + if (gguf.get("general.architecture")) { + err = strata::check_architecture(gguf); + if (!err.empty()) return false; + have_architecture = true; + if (count && number && tensors && number->u == 0 && count->u > 1) { + split_count = count->u; + split_tensors = tensors->u; + } + } else if (!have_architecture || !split_count || !count || !number || !tensors || + count->u != split_count || number->u == 0 || number->u >= split_count || + tensors->u != split_tensors) { + err = "native dense: additional shard must match the architecture-validated first shard's split metadata"; + return false; + } + if (number && !split_numbers.insert(number->u).second) { + err = "native dense: duplicate split shard number"; return false; + } + std::vector offsets; + for (const auto& tensor : gguf.tensors()) offsets.push_back(tensor.offset); + std::sort(offsets.begin(), offsets.end()); + if (std::adjacent_find(offsets.begin(), offsets.end()) != offsets.end()) { + err = "native dense: tensor payload offsets overlap"; return false; + } + // Validate every directory span, including tensors we do not upload: + // an ignored tensor must not overlap the native matrix that follows it. + const uint64_t payload = gguf.file_size() - gguf.data_start(); + for (const auto& tensor : gguf.tensors()) { + int block_elements = 0, block_bytes = 0; + uint64_t elements = 1; + if (tensor.shape.empty() || !strata::block_geometry(tensor.type, block_elements, block_bytes) || + tensor.shape[0] % (uint64_t) block_elements != 0) { + err = "native dense: invalid block geometry " + tensor.name; return false; + } + for (uint64_t dimension : tensor.shape) { + if (!dimension || elements > (std::numeric_limits::max)() / dimension) { + err = "native dense: invalid tensor extent " + tensor.name; return false; + } + elements *= dimension; + } + const uint64_t blocks = elements / (uint64_t) block_elements; + if (blocks > (std::numeric_limits::max)() / (uint64_t) block_bytes) { + err = "native dense: tensor byte count overflow " + tensor.name; return false; + } + const uint64_t bytes = blocks * (uint64_t) block_bytes; + if (tensor.offset > payload || bytes > payload - tensor.offset) { + err = "native dense: truncated payload " + tensor.name; return false; + } + const auto next = std::upper_bound(offsets.begin(), offsets.end(), tensor.offset); + if (next != offsets.end() && bytes > *next - tensor.offset) { + err = "native dense: overlapping payload " + tensor.name; return false; + } + } + for (const auto& tensor : gguf.tensors()) { + if (!eligible(tensor.name, include_ple_key)) continue; + if (!seen.insert(tensor.name).second) { + err = "native dense: duplicate tensor " + tensor.name; return false; + } + auto found = table.table_.find(tensor.name); + if (found == table.table_.end()) { + err = "native dense: tensor absent from canonical table: " + tensor.name; return false; + } + auto& ref = found->second; + if (ref.native_data) { err = "native dense: override already attached"; return false; } + if (!strata::kernels::native_mmvq_supported(tensor.type)) continue; + if (!ref.quantized() || tensor.shape.size() != 2 || + ref.ne0 <= 0 || ref.ne0 > INT_MAX || ref.ne1 <= 0 || ref.ne1 > INT_MAX || + tensor.shape[0] != (uint64_t) ref.ne0 || tensor.shape[1] != (uint64_t) ref.ne1) { + err = "native dense: incompatible matrix " + tensor.name; return false; + } + const auto bytes = strata::kernels::native_mmvq_weight_bytes( + tensor.type, (int) ref.ne0, (int) ref.ne1); + void* allocation = nullptr; + auto status = cudaMalloc(&allocation, bytes); + DevicePtr data(allocation); + if (status == cudaSuccess) + status = cudaMemcpy(data.get(), gguf.tensor_data(tensor), bytes, cudaMemcpyHostToDevice); + if (status != cudaSuccess) { + err = "native dense upload " + tensor.name + ": " + cudaGetErrorString(status); return false; + } + max_in = (std::max)(max_in, (int) ref.ne0); + total += bytes; + pending.push_back(Pending{&ref, (int) tensor.type, bytes, std::move(data)}); + } + } + if (pending.empty()) { err = "native dense: no supported GDN/QSA matrices in supplied shards"; return false; } + void* allocation = nullptr; + const auto status = cudaMalloc(&allocation, strata::kernels::native_q8_1_bytes(max_in)); + DevicePtr scratch(allocation); + if (status != cudaSuccess) { err = std::string("native dense scratch: ") + cudaGetErrorString(status); return false; } + // All checks and allocations finish before publishing any reference. + weights_.reserve(pending.size()); + for (auto& item : pending) { + item.ref->native_data = item.data.get(); + item.ref->native_type = item.type; + item.ref->native_q8_1 = scratch.get(); + weights_.push_back(item.data.release()); + } + scratch_ = scratch.release(); + bytes_ = total; + return true; + } catch (const std::exception& error) { + err = std::string("native dense: ") + error.what(); + return false; + } +} +} // namespace strata::core diff --git a/src/core/native_head.cpp b/src/core/native_head.cpp new file mode 100644 index 00000000..fc9f694f --- /dev/null +++ b/src/core/native_head.cpp @@ -0,0 +1,151 @@ +#include "strata/core/native_head.hpp" +#include "strata/artifact/gguf_reader.hpp" +#include "strata/kernels/iq_kernels.hpp" +#include "strata/kernels/native_mmvq.hpp" + +#include +#include +#include +#include + +namespace strata::core { + +NativeHead::~NativeHead() { + if (scratch_) cudaFree(scratch_); + if (weights_) cudaFree(weights_); +} + +bool NativeHead::load(const std::string& path, int64_t n_in, int64_t n_out, std::string& err) { + if (loaded()) { err = "native head is already loaded"; return false; } + if (n_in <= 0 || n_out <= 0 || n_in > INT_MAX || n_out > INT_MAX || n_in % 256) { + err = "native head requires positive int32 dimensions and whole 256-value rows"; + return false; + } + try { + strata::GgufFile gguf(path); + err = strata::check_architecture(gguf); + if (!err.empty()) return false; + const strata::TensorInfo* tensor = nullptr; + for (const auto& candidate : gguf.tensors()) { + if (candidate.name != "output.weight") continue; + if (tensor) { err = "native head: duplicate output.weight"; return false; } + tensor = &candidate; + } + if (!tensor || !strata::kernels::native_mmvq_supported((int) tensor->type) || tensor->shape.size() != 2 || + tensor->shape[0] != (uint64_t) n_in || tensor->shape[1] != (uint64_t) n_out) { + err = "native head: expected a natively supported output.weight with the canonical head dimensions"; + return false; + } + const uint64_t bytes = strata::kernels::native_mmvq_weight_bytes((int) tensor->type, (int) n_in, (int) n_out); + const uint64_t payload = gguf.file_size() - gguf.data_start(); + if (tensor->offset > payload || bytes > payload - tensor->offset) { + err = "native head: truncated output.weight payload"; + return false; + } + void* weights = nullptr; + void* scratch = nullptr; + cudaError_t status = cudaMalloc(&weights, bytes); + if (status == cudaSuccess) + status = cudaMalloc(&scratch, strata::kernels::native_q8_1_bytes((int) n_in, 1)); + if (status == cudaSuccess) + status = cudaMemcpy(weights, gguf.tensor_data(*tensor), bytes, cudaMemcpyHostToDevice); + if (status != cudaSuccess) { + if (scratch) cudaFree(scratch); + if (weights) cudaFree(weights); + err = std::string("native head upload: ") + cudaGetErrorString(status); + return false; + } + weights_ = weights; + scratch_ = scratch; + bytes_ = bytes; + n_in_ = (int) n_in; + n_out_ = (int) n_out; + type_ = (int) tensor->type; + return true; + } catch (const std::exception& error) { + err = std::string("native head: ") + error.what(); + return false; + } +} + +bool NativeHead::run(const float* mixed, float* logits, void* stream, std::string& err) const { + if (!loaded() || !mixed || !logits || !stream) { + err = "native head requires loaded weights, device buffers and an explicit stream"; + return false; + } + try { + if (type_ == 13) { + strata::kernels::native_q5_k_f32(weights_, mixed, scratch_, logits, n_in_, n_out_, 1, stream); + } else { + strata::kernels::native_quantize_q8_1(mixed, scratch_, n_in_, 1, stream); + strata::kernels::native_mmvq(type_, weights_, scratch_, logits, n_in_, n_out_, 1, stream); + } + } catch (const std::exception& error) { + err = std::string("native head launch: ") + error.what(); + return false; + } + const cudaError_t status = cudaPeekAtLastError(); + if (status != cudaSuccess) { + err = std::string("native head launch: ") + cudaGetErrorString(status); + return false; + } + return true; +} + +// ================================ plan v0.3 P6: THE NATIVE EMBEDDING ================================ + +namespace { +const NativeEmbed* g_embed = nullptr; +} +void set_native_embed(const NativeEmbed* e) { g_embed = e; } +const NativeEmbed* native_embed() { return g_embed; } + +NativeEmbed::~NativeEmbed() { + if (host_) cudaFreeHost(host_); +} + +bool NativeEmbed::load(const std::string& path, int64_t n_embd, int64_t n_vocab, std::string& err) { + try { + strata::GgufFile gguf(path); + const strata::TensorInfo* t = nullptr; + for (const auto& c : gguf.tensors()) + if (c.name == "token_embd.weight") t = &c; + if (!t || t->shape.size() != 2 || t->shape[0] != (uint64_t) n_embd || t->shape[1] != (uint64_t) n_vocab || + !strata::kernels::iq_supported((int) t->type) || n_embd % 256) { + err = "native embedding: token_embd.weight is absent, of another shape, or of a type without a GPU " + "dequantizer"; + return false; + } + row_ = strata::kernels::iq_row_bytes((int) t->type, n_embd); + bytes_ = (uint64_t) row_ * (uint64_t) n_vocab; + if (cudaHostAlloc(&host_, bytes_, cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess) { + host_ = nullptr; + err = "native embedding: cannot pin " + std::to_string(bytes_ >> 20) + " MiB"; + return false; + } + std::memcpy(host_, gguf.tensor_data(*t), bytes_); + void* d = nullptr; + if (cudaHostGetDevicePointer(&d, host_, 0) != cudaSuccess) { + err = "native embedding: no device alias for the mapped table"; + return false; + } + dev_ = d; + type_ = (int) t->type; + n_embd_ = n_embd; + n_vocab_ = n_vocab; + return true; + } catch (const std::exception& e) { + err = std::string("native embedding: ") + e.what(); + return false; + } +} + +void NativeEmbed::gather_dev(const int32_t* tokens, int64_t n_tok, float* out, void* stream) const { + strata::kernels::iq_embed_rows(type_, dev_, row_, tokens, n_tok, n_embd_, out, stream); +} + +void NativeEmbed::gather_one(int64_t token, float* out, void* stream) const { + strata::kernels::iq_dequant_f32(type_, (const uint8_t*) dev_ + (size_t) token * row_, n_embd_, out, stream); +} + +} // namespace strata::core diff --git a/src/core/overlap_main.cpp b/src/core/overlap_main.cpp new file mode 100644 index 00000000..f5981c12 --- /dev/null +++ b/src/core/overlap_main.cpp @@ -0,0 +1,169 @@ +// src/core/overlap_main.cpp - `strata-overlap`: does streaming an expert overlap with computing on it? +// +// THE ARCHITECTURE'S CENTRAL PREMISE. Only (1 - h) of a token's expert weights are in VRAM, so the rest must +// cross the bus while the GPU is working. If the copy and the compute do not overlap, the per-token cost is +// their SUM; if they do, it is their MAXIMUM. L3 measured the stream at 4.07 ms/token (design h) and L5 the +// matvec at 27.9 ms/token, so serial would be ~32 ms and overlapped ~28 - but those are the STEADY-STATE +// numbers for a whole token; at one expert's granularity the ratio is completely different and that is what +// decides how the loop must be built. +// +// Measured against a SERIAL baseline in the same process, because the interesting quantity is the ratio and +// not the absolute figure - and L6 established that absolute figures on this machine move by 20%. +#include "strata/core/pinned.hpp" +#include "strata/kernels/s_gemv.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +struct Timing { + double seconds = 0; + const char* name = ""; +}; + +} // namespace + +int main(int argc, char** argv) { + std::string path; + int experts = 64; + int threads_per_row = 32; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto val = [&]() -> const char* { + if (i + 1 >= argc) { std::fprintf(stderr, "%s needs a value\n", a.c_str()); std::exit(2); } + return argv[++i]; + }; + if (a == "--file") path = val(); + else if (a == "--experts") experts = std::atoi(val()); + else if (a == "--tpr") threads_per_row = std::atoi(val()); + else { + std::fprintf(stderr, "usage: strata-overlap --file experts.bin [--experts N] [--tpr N]\n"); + return 2; + } + } + if (path.empty()) { std::fprintf(stderr, "--file is required\n"); return 2; } + + // One ROLE's worth of S2 weights: gate is [n_embd 2560] x [ff 640] = 1,638,400 weights = 460,800 B of + // codes at 4 codes per byte, plus 40 scales per row. + const long long n_in = 2560, n_out = 640; + const uint64_t role_codes = (uint64_t) n_out * n_in / 4; + const uint64_t role_bytes = role_codes + (uint64_t) n_out * (n_in / 64) * 4; + std::printf("per role: %lld x %lld S2 weights, %.1f KB of planes\n", n_in, n_out, role_bytes / 1024.0); + + // host side: `experts` blobs read into a pinned arena, and the S2 planes the GEMV will read + strata::core::PinnedArena arena((uint64_t) experts * role_codes); + if (!arena.valid()) { std::fprintf(stderr, "arena allocation failed\n"); return 1; } + { + std::ifstream f(path, std::ios::binary); + if (!f) { std::fprintf(stderr, "cannot open %s\n", path.c_str()); return 1; } + // take the first `experts` blobs' worth of CODE bytes, pretending each is a role's plane: the bytes + // are real Q2_0 codes, so the kernel decodes real data and no cache-friendly pattern is invented + for (int i = 0; i < experts; ++i) { + f.seekg((std::streamoff) i * 1382400); + f.read((char*) arena.data() + (uint64_t) i * role_codes, (std::streamsize) role_codes); + if ((uint64_t) f.gcount() != role_codes) { + std::fprintf(stderr, "short read on expert %d\n", i); + return 1; + } + } + } + std::printf("loaded %d role-sized planes into a %s arena\n", experts, arena.note.c_str()); + + // device side: double buffered codes, plus x / scales / y + uint8_t* d_codes[2] = {nullptr, nullptr}; + for (int b = 0; b < 2; ++b) check(cudaMalloc(&d_codes[b], role_codes), "cudaMalloc codes"); + uint16_t* d_x = nullptr; + float *d_scales = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_x, n_in * sizeof(uint16_t)), "cudaMalloc x"); + check(cudaMalloc(&d_scales, (size_t) n_out * (n_in / 64) * sizeof(float)), "cudaMalloc scales"); + check(cudaMalloc(&d_y, n_out * sizeof(float)), "cudaMalloc y"); + std::vector hx((size_t) n_in, 0x3C00); + std::vector hs((size_t) n_out * (size_t) (n_in / 64), 0.001f); + check(cudaMemcpy(d_x, hx.data(), hx.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "copy x"); + check(cudaMemcpy(d_scales, hs.data(), hs.size() * sizeof(float), cudaMemcpyHostToDevice), "copy scales"); + + cudaStream_t s_copy{}, s_comp{}; + check(cudaStreamCreate(&s_copy), "stream copy"); + check(cudaStreamCreate(&s_comp), "stream compute"); + const strata::kernels::SForm form{2, -1, 64, strata::kernels::Codebook::Affine, false}; + + // ---- SERIAL: the same operations on the SAME stream, so the GPU runs them one after another ---- + // + // NOT a sync-per-iteration loop. The first version waited with `cudaStreamSynchronize` after every copy and + // measured 234.8 us/expert where a copy plus a GEMV is ~43 us: the baseline was dominated by the CPU + // round-trip, so the ratio of 0.095 mostly measured "with syncs" against "without syncs". Putting both + // operations on one stream expresses the same dependency with NO CPU involvement and ONE sync at the end, + // which is what makes the comparison about overlap rather than about synchronisation. + check(cudaStreamSynchronize(0), "pre sync"); + auto t0 = std::chrono::steady_clock::now(); + for (int i = 0; i < experts; ++i) { + check(cudaMemcpyAsync(d_codes[0], arena.data() + (uint64_t) i * role_codes, role_codes, + cudaMemcpyHostToDevice, 0), + "serial copy"); + strata::kernels::s_gemv_split_async(d_x, d_codes[0], d_scales, nullptr, d_y, n_in, n_out, form, + threads_per_row, (void*) (cudaStream_t) 0); + } + check(cudaStreamSynchronize(0), "serial final sync"); + const double serial = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + + // ---- OVERLAPPED: copy the next one while computing on the current one ---- + check(cudaStreamSynchronize(0), "pre sync 2"); + cudaEvent_t ev[2]; + for (int b = 0; b < 2; ++b) check(cudaEventCreateWithFlags(&ev[b], cudaEventDisableTiming), "event"); + t0 = std::chrono::steady_clock::now(); + check(cudaMemcpyAsync(d_codes[0], arena.data(), role_codes, cudaMemcpyHostToDevice, s_copy), "prologue"); + check(cudaEventRecord(ev[0], s_copy), "prologue event"); + for (int i = 0; i < experts; ++i) { + const int cur = i & 1, nxt = (i + 1) & 1; + if (i + 1 < experts) { + check(cudaMemcpyAsync(d_codes[nxt], arena.data() + (uint64_t) (i + 1) * role_codes, role_codes, + cudaMemcpyHostToDevice, s_copy), + "overlap copy"); + check(cudaEventRecord(ev[nxt], s_copy), "overlap event"); + } + // the compute stream must not start row i until its codes have landed + check(cudaStreamWaitEvent(s_comp, ev[cur], 0), "wait"); + // a hand-written launch so it goes on s_comp; `s_gemv` synchronises on the default stream + strata::kernels::s_gemv_split_async(d_x, d_codes[cur], d_scales, nullptr, d_y, n_in, n_out, form, + threads_per_row, (void*) s_comp); + } + check(cudaStreamSynchronize(s_comp), "final sync"); + check(cudaStreamSynchronize(s_copy), "final sync 2"); + const double overlap = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + + std::printf("\n serial %8.3f ms for %d experts (%.1f us/expert)\n", serial * 1000, experts, + serial * 1e6 / experts); + std::printf(" overlapped %8.3f ms for %d experts (%.1f us/expert)\n", overlap * 1000, experts, + overlap * 1e6 / experts); + std::printf(" ratio %.3f (1.00 = no overlap at all, <1.00 = overlap)\n", overlap / serial); + std::printf("\nNOTE: the OVERLAPPED loop launches on `s_comp` without synchronising, so the copy and the\n" + " compute really are concurrent; the serial loop waits after each step. What this does NOT\n" + " model is the CPU expert path, the router, or the fact that a real token has 1440 roles to\n" + " stream rather than %d.\n", experts); + + for (int b = 0; b < 2; ++b) { + cudaFree(d_codes[b]); + cudaEventDestroy(ev[b]); + } + cudaFree(d_x); + cudaFree(d_scales); + cudaFree(d_y); + cudaStreamDestroy(s_copy); + cudaStreamDestroy(s_comp); + return 0; +} diff --git a/src/core/pinned.cu b/src/core/pinned.cu new file mode 100644 index 00000000..56573ce3 --- /dev/null +++ b/src/core/pinned.cu @@ -0,0 +1,291 @@ +// src/core/pinned.cu - P2.S1: the pinned host arena and the parallel expert load. +#include "strata/core/pinned.hpp" +#include "strata/platform/memory.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#ifndef MAP_HUGE_2MB +#define MAP_HUGE_2MB (21 << 26) +#endif +#endif + +namespace strata::core { + +namespace { + +// A 2 MB-aligned reservation. Large pages first, then the largest alignment the OS will give us for free. +void* reserve(uint64_t bytes, PageBacking& got, std::string& note) { +#ifdef _WIN32 + // MEM_LARGE_PAGES needs SeLockMemoryPrivilege; a normal account does not have it and VirtualAlloc then + // fails with ERROR_PRIVILEGE_NOT_HELD. That is the EXPECTED outcome on a desktop, not an error. + SIZE_T large = GetLargePageMinimum(); + if (large > 0) { + void* p = VirtualAlloc(nullptr, (SIZE_T) bytes, MEM_RESERVE | MEM_COMMIT | MEM_LARGE_PAGES, + PAGE_READWRITE); + if (p) { + got = PageBacking::LargePages; + note = "large pages (" + std::to_string((unsigned long long) large) + " B)"; + return p; + } + note = "large pages refused (GetLargePageMinimum=" + std::to_string((unsigned long long) large) + + ", VirtualAlloc error " + std::to_string((unsigned long long) GetLastError()) + + " - needs SeLockMemoryPrivilege); using 4 KB pages"; + } else { + note = "this system has no large-page minimum; using 4 KB pages"; + } + void* p = VirtualAlloc(nullptr, (SIZE_T) bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); + got = PageBacking::NormalPages; + return p; +#else + void* p = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB | MAP_HUGE_2MB, -1, 0); + if (p != MAP_FAILED) { + got = PageBacking::LargePages; + note = "hugetlb 2 MB pages"; + return p; + } + note = "MAP_HUGETLB unavailable (no hugetlb pool configured?); using 4 KB pages"; + p = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + got = PageBacking::NormalPages; + return p == MAP_FAILED ? nullptr : p; +#endif +} + +void release(void* p, uint64_t bytes) { + if (!p) return; +#ifdef _WIN32 + (void) bytes; + VirtualFree(p, 0, MEM_RELEASE); +#else + munmap(p, bytes); +#endif +} + +} // namespace + +uint64_t fnv1a64(const uint8_t* p, uint64_t n, uint64_t seed) { + uint64_t h = seed; + for (uint64_t i = 0; i < n; ++i) { + h ^= p[i]; + h *= 1099511628211ull; + } + return h; +} + +namespace { +bool clear_error() { (void) cudaGetLastError(); return true; } +} // namespace + +namespace { +std::vector uniform_bounds(uint64_t bytes, uint64_t slice) { + std::vector b; + if (slice == 0) return b; + for (uint64_t off = 0; off + slice <= bytes; off += slice) b.push_back(off); + if (!b.empty()) b.push_back(b.back() + slice); + return b; +} +} // namespace + +PinnedArena::PinnedArena(uint64_t bytes, uint64_t slice) : PinnedArena(bytes, uniform_bounds(bytes, slice)) { + if (slice_bytes) slice_bytes = slice; // sliced registration: record the uniform size +} + +PinnedArena::PinnedArena(uint64_t bytes, const std::vector& bounds) : capacity(bytes) { + if (bytes == 0) return; + base = reserve(bytes, backing, note); + + // Register with CUDA BEFORE any page is touched: cudaHostRegister pins what is resident now, and a region + // that has already been faulted in page by page is far more expensive to register and may fail outright. + if (base) { + const cudaError_t e = cudaHostRegister(base, (size_t) bytes, cudaHostRegisterPortable | cudaHostRegisterMapped); + if (e == cudaSuccess) { + note = "cudaHostRegister PORTABLE ok; " + note; + registered_bytes = bytes; + } else if (bounds.size() >= 2 && clear_error()) { + // Plan v0.3 P5: the whole range is refused, so pin it slice by slice from the start. The rest stays + // resident through the working-set lock below. (P6: slices may differ in size, one per layer.) + slice_bytes = 1; // sliced; the uniform constructor records the size + for (size_t i = 0; i + 1 < bounds.size(); ++i) { + const uint64_t off = bounds[i], n = bounds[i + 1] - bounds[i]; + if (cudaHostRegister((uint8_t*) base + off, (size_t) n, cudaHostRegisterPortable | cudaHostRegisterMapped) != cudaSuccess) { + (void) cudaGetLastError(); + break; + } + slice_starts.push_back(off); + registered_bytes = off + n; + ++registered_slices; + } + note = "cudaHostRegister of the whole arena FAILED (" + std::string(cudaGetErrorString(e)) + "); " + + std::to_string(registered_slices) + " slices pinned (" + std::to_string(registered_bytes >> 30) + + " GiB); " + note; + if (registered_bytes < bytes) { + const char* env = std::getenv("STRATA_ARENA_LOCK"); + if (env == nullptr || std::string(env) != "0") { + const strata::platform::LockResult lr = + strata::platform::lock_resident((uint8_t*) base + registered_bytes, bytes - registered_bytes); + locked_bytes = lr.locked_bytes; + note = lr.note + "; " + note; + } + } + } else { + note = std::string("cudaHostRegister FAILED (") + cudaGetErrorString(e) + + ") - the arena is NOT pinned, so copies will be slow; " + note; + // **CONSUME THE ERROR, OR IT LIES ABOUT SOMETHING ELSE LATER.** + // + // `cudaGetLastError()` returns the last error and CLEARS it; until something reads it, the error + // state is sticky. This failure is caught and handled right here - the arena is simply not pinned - + // but leaving it set meant the next `cudaGetLastError()` in the engine, which is `gr_read`'s launch + // check, reported "out of memory" for kernels that allocate nothing. That cost a round: the arena + // was written off as not fitting the machine when in fact the only thing wrong was a stale error + // from this line. + // + // It is the same trap `gr.cu` warns about for ASYNC faults, in the other direction: a synchronous + // failure is sticky too, and it lies about where it happened just as convincingly. + (void) cudaGetLastError(); + // Plan v0.3 P0.1: keep it RESIDENT instead. Unpinned, Windows trims the arena under memory pressure + // and the CPU pool's rate then depends on the OS; locking it through the working set needs no + // special privilege. STRATA_ARENA_LOCK=0 is the A/B arm. + const char* env = std::getenv("STRATA_ARENA_LOCK"); + if (env == nullptr || std::string(env) != "0") { + const strata::platform::LockResult lr = strata::platform::lock_resident(base, bytes); + locked_bytes = lr.locked_bytes; + note = lr.note + "; " + note; + } else { + note = "arena lock disabled (STRATA_ARENA_LOCK=0); " + note; + } + } + } +} + +PinnedArena::~PinnedArena() { + if (base) { + if (locked_bytes) strata::platform::unlock_resident((uint8_t*) base + (slice_bytes ? registered_bytes : 0), locked_bytes); + if (slice_bytes) { + for (uint64_t off : slice_starts) cudaHostUnregister((uint8_t*) base + off); + } else { + cudaHostUnregister(base); + } + release(base, capacity); + base = nullptr; + } +} + +LoadStats load_experts(const std::string& path, uint8_t* dst, uint64_t blob_bytes, uint64_t blobs_per_layer, + uint64_t layers, int threads, uint64_t chunk) { + std::vector off((size_t) layers), n((size_t) layers, blobs_per_layer * blob_bytes); + for (uint64_t L = 0; L < layers; ++L) off[(size_t) L] = L * blobs_per_layer * blob_bytes; + return load_experts_ranges(path, dst, off, n, threads, chunk); +} + +LoadStats load_experts_ranges(const std::string& path, uint8_t* dst, const std::vector& layer_off, + const std::vector& layer_bytes, int threads, uint64_t chunk) { + LoadStats st; + const uint64_t layers = (uint64_t) layer_off.size(); + st.layers = layers; + st.bytes = 0; + for (uint64_t b : layer_bytes) st.bytes += b; + if (threads < 1) threads = 1; + + const auto t0 = std::chrono::steady_clock::now(); + std::vector layer_hash((size_t) layers, 1469598103934665603ull); + std::atomic next_layer{0}; + std::mutex err_mu; + std::string err; + + auto worker = [&]() { + std::vector buf((size_t) chunk); + for (;;) { + const uint64_t L = next_layer.fetch_add(1); + if (L >= layers) break; + const uint64_t off = layer_off[(size_t) L]; + uint64_t remaining = layer_bytes[(size_t) L]; + uint64_t pos = 0; + uint64_t h = 1469598103934665603ull; + // one handle per thread, seeked once per layer: a shared handle would need a lock around the seek + // and would serialise the very thing the threads are here to parallelise + std::ifstream f(path, std::ios::binary); + if (!f) { + std::lock_guard g(err_mu); + err = "cannot open " + path; + return; + } + f.seekg((std::streamoff) off); + while (remaining > 0) { + const uint64_t n = remaining < chunk ? remaining : chunk; + f.read((char*) buf.data(), (std::streamsize) n); + if ((uint64_t) f.gcount() != n) { + std::lock_guard g(err_mu); + err = "short read in layer " + std::to_string(L); + return; + } + std::memcpy(dst + off + pos, buf.data(), (size_t) n); + h = fnv1a64(buf.data(), n, h); + pos += n; + remaining -= n; + } + layer_hash[(size_t) L] = h; + } + }; + + std::vector pool; + for (int i = 1; i < threads; ++i) pool.emplace_back(worker); + worker(); + for (auto& t : pool) t.join(); + + if (!err.empty()) { + std::fprintf(stderr, "load_experts: %s\n", err.c_str()); + st.seconds = -1.0; + return st; + } + st.layer_checksums = std::move(layer_hash); + st.seconds = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + return st; +} + +StreamStats stream_bandwidth(const uint8_t* src, uint64_t bytes, uint64_t chunk, int iters) { + StreamStats st; + st.bytes = bytes * (uint64_t) iters; + st.chunk = chunk; + uint8_t* dst = nullptr; + cudaStream_t s{}; + if (cudaMalloc(&dst, (size_t) chunk) != cudaSuccess) { + std::fprintf(stderr, "stream_bandwidth: cudaMalloc failed for %llu B\n", (unsigned long long) chunk); + st.seconds = -1.0; + return st; + } + cudaStreamCreate(&s); + + // one untimed pass so the first transfer's page-fault and setup cost is not in the measurement + for (uint64_t off = 0; off + chunk <= bytes; off += chunk) { + cudaMemcpyAsync(dst, src + off, (size_t) chunk, cudaMemcpyHostToDevice, s); + } + cudaStreamSynchronize(s); + + const auto t0 = std::chrono::steady_clock::now(); + for (int it = 0; it < iters; ++it) { + for (uint64_t off = 0; off + chunk <= bytes; off += chunk) { + cudaMemcpyAsync(dst, src + off, (size_t) chunk, cudaMemcpyHostToDevice, s); + } + } + cudaStreamSynchronize(s); + st.seconds = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + + cudaStreamDestroy(s); + cudaFree(dst); + return st; +} + +} // namespace strata::core diff --git a/src/core/session.cpp b/src/core/session.cpp new file mode 100644 index 00000000..cf0bbde2 --- /dev/null +++ b/src/core/session.cpp @@ -0,0 +1,930 @@ +// src/core/session.cpp - one token through all 48 layers. See the header for why the graphs are per-layer. +#include "strata/core/session.hpp" + +#include "strata/kernels/qsa.hpp" +#include "strata/kernels/elementwise.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/s2_expert_grouped.hpp" +#include "strata/kernels/cpu/pool.hpp" +#include "strata/kernels/ngram.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +// `_mm_pause` for the doorbell spin. Guarded because it is x86-only; a target without it still builds, the +// spin is just less polite to the pipeline. +#if defined(_MSC_VER) || defined(__x86_64__) || defined(__i386__) +#include +#define STRATA_SPIN_PAUSE() _mm_pause() +#else +#define STRATA_SPIN_PAUSE() ((void) 0) +#endif + +namespace strata::core { +namespace { + +constexpr uint64_t SESSION_STATE_ALIGN = 256; + +uint64_t align_up(uint64_t n, uint64_t a) { return (n + a - 1) / a * a; } + +/// The floats one GDN layer's recurrent + conv state needs. `gdn_buffers_bytes` carves them for ONE layer and +/// `GdnBuffers::state`/`conv_state` point INTO that carve, so a session with 36 GDN layers has to give each one +/// its own - they cannot share, because the recurrence is the whole point. +uint64_t gdn_state_floats(const ModelGeometry& g) { + return (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size + + (uint64_t) g.ssm_conv_channels * (g.ssm_d_conv - 1); +} + +} // namespace + +/// `NG_HIST` rows of `hc_dim` floats: the PLE conv's history, which is the ONLY PLE state that lives in the +/// session arena. The table and the weights are model-level and the caller owns them. +static uint64_t ple_hist_bytes() { + return (uint64_t) strata::kernels::NG_HIST * strata::kernels::NG_HC_DIM * sizeof(float); +} + +uint64_t session_bytes(const ModelGeometry& g, int64_t max_cells, int64_t k) { + uint64_t n = 0; + n += gdn_buffers_bytes(g); + n += (uint64_t) g.n_gdn_layers() * gdn_state_floats(g) * 4; + // One QSA state carries the RoPE table; the others borrow it (P7: 64 MiB per layer at 262K). + if (g.n_qsa_layers() > 0) + n += qsa_state_bytes(g, max_cells, true) + (uint64_t) (g.n_qsa_layers() - 1) * qsa_state_bytes(g, max_cells, false); + n += qsa_buffers_bytes(g, max_cells); + n += moe_buffers_bytes(g, k); + n += block_buffers_bytes(g); + n += ple_hist_bytes(); // the PLE's NG_HIST normalized history rows + return align_up(n, SESSION_STATE_ALIGN) + 4096; +} + +uint64_t session_init(const ModelGeometry& g, int64_t max_cells, int64_t k, void* base, SessionState& s) { + uint8_t* p = (uint8_t*) base; + uint64_t used = 0; + auto take = [&](uint64_t bytes) { + uint8_t* r = p + used; + used = align_up(used + bytes, SESSION_STATE_ALIGN); + return r; + }; + + s.max_cells = max_cells; + s.k = k; + + gdn_buffers_init(g, take(gdn_buffers_bytes(g)), s.gdn); + s.gdn_state = (float*) take((uint64_t) g.n_gdn_layers() * gdn_state_floats(g) * 4); + + // the 12 QSA states are separate allocations carved from one arena, because `QsaState` is a struct of + // pointers and `qsa_state_init` writes them - a contiguous array would need the arena to be laid out the + // same way, which is a coupling with nothing to gain. + const uint64_t first = qsa_state_bytes(g, max_cells, true), rest = qsa_state_bytes(g, max_cells, false); + s.qsa_state_arena = take(g.n_qsa_layers() > 0 ? first + (uint64_t) (g.n_qsa_layers() - 1) * rest : 0); + s.qsa_states = new QsaState[(size_t) g.n_qsa_layers()]; + s.qsa_buf_arena = take(qsa_buffers_bytes(g, max_cells)); + + uint8_t* qp = (uint8_t*) s.qsa_state_arena; + for (int64_t i = 0; i < g.n_qsa_layers(); ++i) + qsa_state_init(g, max_cells, qp + (i == 0 ? 0 : first + (uint64_t) (i - 1) * rest), s.qsa_states[i], + i == 0 ? nullptr : &s.qsa_states[0]); + qsa_buffers_init(g, max_cells, s.qsa_buf_arena, s.qsa_bufs); + + s.moe_arena = take(moe_buffers_bytes(g, k)); + moe_buffers_init(g, k, s.moe_arena, s.moe); + s.block_arena = take(block_buffers_bytes(g)); + block_buffers_init(g, s.block_arena, s.block); + // THE PLE HISTORY: NG_HIST rows of hc_dim floats, row-fastest. Sequence state, carved here and zeroed by + // `session_zero`; it survives every token, which is the whole point of a conv history. + s.ple_hist = (float*) take(ple_hist_bytes()); + s.R = s.block.R; + return used; +} + +void session_zero(SessionState& s, const ModelGeometry& g, const float* R_init, void* stream) { + cudaStream_t cs = (cudaStream_t) stream; + // the residual: `hc` copies of the one vector a caller hands in. A real sequence's first token is the + // embedding broadcast to every stream, which is the reference's own initial condition. + if (R_init != nullptr) { + for (int64_t c = 0; c < g.hc; ++c) + cudaMemcpyAsync(s.block.R + (size_t) c * g.n_embd, R_init, (size_t) g.n_embd * 4, + cudaMemcpyDeviceToDevice, cs); + } else { + cudaMemsetAsync(s.block.R, 0, (size_t) g.hc * g.n_embd * 4, cs); + } + // every GDN layer's recurrence and conv history + cudaMemsetAsync(s.gdn_state, 0, (size_t) g.n_gdn_layers() * gdn_state_floats(g) * 4, cs); + // and every QSA layer's cache and indexer + for (int64_t i = 0; i < g.n_qsa_layers(); ++i) qsa_state_zero(s.qsa_states[i], g, stream); + // **AND THE PLE'S CONV HISTORY AND TOKEN WINDOW.** A sequence that started with a warm history would + // convolve over rows belonging to a different sequence - the conv reads NG_HIST previous NORMALIZED rows, + // so a stale one is a real contribution and not a zero. The token window resets to `NG_HIST`-many nulls + // for the same reason: `ngram_rows` treats a missing predecessor as the EOS cut, which is what a sequence + // boundary IS. + cudaMemsetAsync(s.ple_hist, 0, (size_t) ple_hist_bytes(), cs); + s.ple_prev[0] = -1; + s.ple_prev[1] = -1; + s.ple_token = -1; +} + +/// Sets `s.gdn.state`/`conv_state` for `layer`, which is what makes one layer's GDN state its own. Shared by +/// the direct and captured paths so the two cannot disagree about which slice a layer owns. +void gdn_point_at(const ModelGeometry& g, int64_t layer, SessionState& s) { + if (is_qsa_layer(g, layer)) return; + int64_t gdn_index = 0; + for (int64_t l = 0; l < layer; ++l) if (!is_qsa_layer(g, l)) ++gdn_index; + s.gdn.state = s.gdn_state + (size_t) gdn_index * gdn_state_floats(g); + s.gdn.conv_state = s.gdn.state + (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size; +} + +/// The per-token staging every QSA layer's captured H2D reads FROM. Must run before each replay: the graphs +/// captured the SOURCE POINTER, not the value, and that is exactly why the buffers are pinned and fixed. +void stage_token(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s) { + strata::kernels::QsaShapes sh = strata::kernels::qsa_real_shapes(); + sh.n_head = g.n_head; + sh.n_head_kv = g.n_head_kv; + sh.head_dim = g.head_dim; + sh.idx_n_head = g.idx_q_heads; + sh.idx_dim = g.idx_key_dim; + for (int64_t i = 0; i < g.n_qsa_layers(); ++i) { + QsaState& q = s.qsa_states[i]; + qsa_step_fill(q.host_step, pos, sh); + for (int64_t h = 0; h < g.n_head; ++h) q.host_pos[h] = (int32_t) (pos_base + pos); + } +} + +bool session_capture(const WeightTable& tables, const ModelGeometry& g, SessionState& s, const float* parts, + SessionGraphs& gr, std::string& err, bool split) { + if (gr.captured) return true; + gr.execs = new cudaGraphExec_t[(size_t) g.n_layers]; + gr.posts = new cudaGraphExec_t[(size_t) g.n_layers]; + for (int64_t i = 0; i < g.n_layers; ++i) gr.posts[i] = nullptr; + if (split) { + gr.preA = new cudaGraphExec_t[(size_t) g.n_layers]; + gr.preB = new cudaGraphExec_t[(size_t) g.n_layers]; + for (int64_t i = 0; i < g.n_layers; ++i) { gr.preA[i] = nullptr; gr.preB[i] = nullptr; } + for (int k = 0; k < 5; ++k) { + gr.preP[k] = new cudaGraphExec_t[(size_t) g.n_layers]; + for (int64_t i = 0; i < g.n_layers; ++i) gr.preP[k][i] = nullptr; + } + } + gr.split_captured = split; + gr.n = 0; + + int64_t qsa_index = 0; + for (int64_t l = 0; l < g.n_layers; ++l) { + gdn_point_at(g, l, s); + const bool qsa = is_qsa_layer(g, l); + QsaState& qst = qsa ? s.qsa_states[qsa_index] : s.qsa_states[0]; + + // **TWO GRAPHS PER LAYER, SPLIT AT THE ROUTER.** `pre` ends with the doorbell rung; the host then runs + // the CPU pool on what it published; `post` combines those experts with THIS layer's weights. Capturing + // them together is what the old single graph could not do, and the reason it could not is that + // `moe_combine` needs the pool's answer and the pool needs the router's. + auto capture = [&](bool post, cudaGraphExec_t* out, const char* what, int half = 0, + int stage_prefix = 0) -> bool { + cudaStream_t cs = nullptr; + if (cudaStreamCreate(&cs) != cudaSuccess) { + err = std::string("session_capture: stream create failed"); + return false; + } + if (cudaStreamBeginCapture(cs, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { + err = "session_capture: begin failed at layer " + std::to_string(l); + return false; + } + err.clear(); + // R0.9: `half` is 0 for every mode except the split capture, where it selects a prefix of the + // stages so the mixer and the FFN-front-plus-router can be timed separately. + const bool ok = post + ? block_layer_post(tables, g, l, s.k, s.moe, s.block, parts, (void*) cs, err) + : block_layer_pre(tables, g, l, 0, 0, s.gdn, qst, s.qsa_bufs, s.moe, s.k, + s.block, (void*) cs, err, s.db, s.ple.ready() ? &s.ple : nullptr, + half, stage_prefix); + if (!ok) { + err = "session_capture: " + std::string(what) + " layer " + std::to_string(l) + ": " + err; + return false; + } + cudaGraph_t graph = nullptr; + const cudaError_t ce = cudaStreamEndCapture(cs, &graph); + cudaStreamDestroy(cs); + if (ce != cudaSuccess) { + err = "session_capture: " + std::string(what) + " layer " + std::to_string(l) + ": " + + cudaGetErrorString(ce) + " (a synchronous call in the layer?)"; + return false; + } + if (cudaGraphInstantiate(out, graph, 0) != cudaSuccess) { + err = "session_capture: instantiate failed at layer " + std::to_string(l); + return false; + } + cudaGraphDestroy(graph); + return true; + }; + if (!capture(/*post=*/false, &gr.execs[l], "pre")) return false; + if (!capture(/*post=*/true, &gr.posts[l], "post")) return false; + if (split) { + if (!capture(/*post=*/false, &gr.preA[l], "preA", /*half=*/1)) return false; + if (!capture(/*post=*/false, &gr.preB[l], "preB", /*half=*/2)) return false; + // Prefixes 1..5, ALL through the new parameter - including the fifth, so that it and the fourth + // differ only in the one stage between them and not in how many nodes their graphs carry. + for (int k = 1; k <= 5; ++k) + if (!capture(/*post=*/false, &gr.preP[k - 1][l], "preP", /*half=*/0, /*stage_prefix=*/k)) + return false; + } + ++gr.n; + if (qsa) ++qsa_index; + } + gr.captured = true; + gr.parts_dev = const_cast(parts); // the address the graphs baked in; the loop copies here + return true; +} + +bool session_replay(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, SessionGraphs& gr, + void* stream, std::string& err) { + if (!gr.captured || gr.n != g.n_layers) { err = "session_replay: not captured"; return false; } + cudaStream_t cs = (cudaStream_t) stream; + stage_token(g, pos, pos_base, s); + for (int64_t l = 0; l < g.n_layers; ++l) { + const cudaError_t e = cudaGraphLaunch(gr.execs[l], cs); + if (e != cudaSuccess) { + err = "session_replay: layer " + std::to_string(l) + ": " + cudaGetErrorString(e); + return false; + } + } + return true; +} + +bool session_replay_full(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, std::string& err) { + if (!gr.captured || gr.n != g.n_layers || gr.posts == nullptr) { + err = "session_replay_full: not captured with post graphs"; + return false; + } + cudaStream_t cs = (cudaStream_t) stream; + stage_token(g, pos, pos_base, s); + for (int64_t l = 0; l < g.n_layers; ++l) { + // `pre[l]` then `post[l]`, in the order `session_loop` uses. The two are ordered on one stream, and + // `post[l]` reads what `pre[l]` wrote, so they cannot be reordered or run concurrently. + cudaError_t e = cudaGraphLaunch(gr.execs[l], cs); + if (e != cudaSuccess) { + err = "session_replay_full: pre[" + std::to_string(l) + "]: " + cudaGetErrorString(e); + return false; + } + e = cudaGraphLaunch(gr.posts[l], cs); + if (e != cudaSuccess) { + err = "session_replay_full: post[" + std::to_string(l) + "]: " + cudaGetErrorString(e); + return false; + } + } + return true; +} + +bool session_replay_stages_per_layer(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, std::vector& mixer_per_layer, + double& ms_ffn, double& ms_post, std::string& err) { + if (!gr.split_captured || gr.preA == nullptr || gr.preB == nullptr) { + err = "session_replay_stages: not captured with the split"; + return false; + } + cudaStream_t cs = (cudaStream_t) stream; + stage_token(g, pos, pos_base, s); + + // Four events PER LAYER rather than four reused ones: reusing them would need a synchronisation after + // every layer to read them before the next launch overwrote them, and a per-layer sync would report the + // serialised time rather than the graph's. + const int64_t n = g.n_layers; + std::vector ev((size_t) (n * 4)); + for (auto& e : ev) { + if (cudaEventCreate(&e) != cudaSuccess) { err = "session_replay_stages: event create"; return false; } + } + struct Free { + std::vector* v; + ~Free() { for (auto& e : *v) cudaEventDestroy(e); } + } frees{&ev}; + + for (int64_t l = 0; l < n; ++l) { + cudaEventRecord(ev[(size_t) (l * 4 + 0)], cs); + if (cudaGraphLaunch(gr.preA[l], cs) != cudaSuccess) { err = "stages: preA"; return false; } + cudaEventRecord(ev[(size_t) (l * 4 + 1)], cs); + if (cudaGraphLaunch(gr.preB[l], cs) != cudaSuccess) { err = "stages: preB"; return false; } + cudaEventRecord(ev[(size_t) (l * 4 + 2)], cs); + if (cudaGraphLaunch(gr.posts[l], cs) != cudaSuccess) { err = "stages: post"; return false; } + cudaEventRecord(ev[(size_t) (l * 4 + 3)], cs); + } + if (cudaStreamSynchronize(cs) != cudaSuccess) { err = "stages: final sync"; return false; } + + mixer_per_layer.assign((size_t) n, 0.0); + ms_ffn = 0; ms_post = 0; + for (int64_t l = 0; l < n; ++l) { + float a = 0, b = 0, c = 0; + cudaEventElapsedTime(&a, ev[(size_t) (l * 4 + 0)], ev[(size_t) (l * 4 + 1)]); + cudaEventElapsedTime(&b, ev[(size_t) (l * 4 + 1)], ev[(size_t) (l * 4 + 2)]); + cudaEventElapsedTime(&c, ev[(size_t) (l * 4 + 2)], ev[(size_t) (l * 4 + 3)]); + mixer_per_layer[(size_t) l] = (double) a; + ms_ffn += b; ms_post += c; + } + return true; +} + +bool session_replay_stages(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, double& ms_mixer, double& ms_ffn, double& ms_post, + std::string& err) { + std::vector per; + if (!session_replay_stages_per_layer(g, pos, pos_base, s, gr, stream, per, ms_ffn, ms_post, err)) return false; + ms_mixer = 0; + for (double v : per) ms_mixer += v; + return true; +} + +bool session_replay_stage_sweep(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, int k, double& ms, std::string& err) { + if (!gr.split_captured || gr.preP[0] == nullptr) { + err = "session_replay_stage_sweep: not captured with the split"; + return false; + } + if (k < 1 || k > 5) { err = "session_replay_stage_sweep: k must be 1..5"; return false; } + cudaStream_t cs = (cudaStream_t) stream; + stage_token(g, pos, pos_base, s); + + cudaEvent_t a, b; + if (cudaEventCreate(&a) != cudaSuccess || cudaEventCreate(&b) != cudaSuccess) { + err = "session_replay_stage_sweep: event create"; + return false; + } + cudaEventRecord(a, cs); + for (int64_t l = 0; l < g.n_layers; ++l) { + const cudaError_t e = cudaGraphLaunch(gr.preP[k - 1][l], cs); + if (e != cudaSuccess) { + cudaEventDestroy(a); + cudaEventDestroy(b); + err = std::string("session_replay_stage_sweep: launch: ") + cudaGetErrorString(e); + return false; + } + } + cudaEventRecord(b, cs); + if (cudaStreamSynchronize(cs) != cudaSuccess) { + cudaEventDestroy(a); + cudaEventDestroy(b); + err = "session_replay_stage_sweep: sync"; + return false; + } + float v = 0; + cudaEventElapsedTime(&v, a, b); + cudaEventDestroy(a); + cudaEventDestroy(b); + ms = (double) v; + return true; +} + +bool session_replay_stage_prefixes(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, + SessionGraphs& gr, void* stream, std::vector& stage_ms, + std::vector& mixer_per_layer, std::string& err) { + if (!gr.split_captured || gr.preP[0] == nullptr) { + err = "session_replay_stage_prefixes: not captured with the split"; + return false; + } + cudaStream_t cs = (cudaStream_t) stream; + stage_token(g, pos, pos_base, s); + const int64_t n = g.n_layers; + const size_t r_floats = (size_t) g.hc * (size_t) g.n_embd; + + float* saved = nullptr; + if (cudaMalloc((void**) &saved, r_floats * sizeof(float)) != cudaSuccess) { + err = "session_replay_stage_prefixes: could not save the residual"; + return false; + } + std::vector ev((size_t) (n * 6)); + bool ev_ok = true; + for (auto& e : ev) if (cudaEventCreate(&e) != cudaSuccess) { ev_ok = false; break; } + if (!ev_ok) { + for (auto& e : ev) cudaEventDestroy(e); + cudaFree(saved); + err = "session_replay_stage_prefixes: event create"; + return false; + } + struct Cleanup { + std::vector* v; + float* p; + ~Cleanup() { for (auto& e : *v) cudaEventDestroy(e); cudaFree(p); } + } cleanup{&ev, saved}; + + double t[5] = {0, 0, 0, 0, 0}; + mixer_per_layer.assign((size_t) n, 0.0); + for (int64_t l = 0; l < n; ++l) { + // The residual as this layer receives it, before any prefix has advanced it. + cudaMemcpyAsync(saved, s.block.R, r_floats * sizeof(float), cudaMemcpyDeviceToDevice, cs); + for (int k = 1; k <= 5; ++k) { + if (k > 1) cudaMemcpyAsync(s.block.R, saved, r_floats * sizeof(float), cudaMemcpyDeviceToDevice, cs); + cudaEventRecord(ev[(size_t) (l * 6 + k - 1)], cs); + const cudaGraphExec_t ge = (k == 5) ? gr.execs[l] : gr.preP[k - 1][l]; + const cudaError_t e = cudaGraphLaunch(ge, cs); + if (e != cudaSuccess) { + err = std::string("session_replay_stage_prefixes: launch prefix ") + std::to_string(k) + ": " + + cudaGetErrorString(e); + return false; + } + } + cudaEventRecord(ev[(size_t) (l * 6 + 5)], cs); + } + if (cudaStreamSynchronize(cs) != cudaSuccess) { err = "prefixes: final sync"; return false; } + + float t1 = 0, t2 = 0, t3 = 0, t4 = 0, t5 = 0; + for (int64_t l = 0; l < n; ++l) { + float a = 0, b = 0, c = 0, d = 0, e = 0; + cudaEventElapsedTime(&a, ev[(size_t) (l * 6 + 0)], ev[(size_t) (l * 6 + 1)]); + cudaEventElapsedTime(&b, ev[(size_t) (l * 6 + 1)], ev[(size_t) (l * 6 + 2)]); + cudaEventElapsedTime(&c, ev[(size_t) (l * 6 + 2)], ev[(size_t) (l * 6 + 3)]); + cudaEventElapsedTime(&d, ev[(size_t) (l * 6 + 3)], ev[(size_t) (l * 6 + 4)]); + cudaEventElapsedTime(&e, ev[(size_t) (l * 6 + 4)], ev[(size_t) (l * 6 + 5)]); + t1 += a; t2 += b; t3 += c; t4 += d; t5 += e; + mixer_per_layer[(size_t) l] = (double) c; // prefix 3 IS the mixer: stages 0..2 + } + t[0] = t1; + t[1] = t2 - t1; + t[2] = t3 - t2; + t[3] = t4 - t3; + t[4] = t5 - t4; + stage_ms.assign(t, t + 5); + return true; +} + +void session_graphs_free(SessionGraphs& gr) { + if (gr.execs) { + for (int64_t i = 0; i < gr.n; ++i) cudaGraphExecDestroy(gr.execs[i]); + delete[] gr.execs; + } + if (gr.posts) { + for (int64_t i = 0; i < gr.n; ++i) + if (gr.posts[i] != nullptr) cudaGraphExecDestroy(gr.posts[i]); + delete[] gr.posts; + } + if (gr.preA) { + for (int64_t i = 0; i < gr.n; ++i) + if (gr.preA[i] != nullptr) cudaGraphExecDestroy(gr.preA[i]); + delete[] gr.preA; + } + if (gr.preB) { + for (int64_t i = 0; i < gr.n; ++i) + if (gr.preB[i] != nullptr) cudaGraphExecDestroy(gr.preB[i]); + delete[] gr.preB; + } + for (int k = 0; k < 5; ++k) { + if (gr.preP[k] == nullptr) continue; + for (int64_t i = 0; i < gr.n; ++i) + if (gr.preP[k][i] != nullptr) cudaGraphExecDestroy(gr.preP[k][i]); + delete[] gr.preP[k]; + gr.preP[k] = nullptr; + } + gr.preA = nullptr; + gr.preB = nullptr; + gr.split_captured = false; + gr.posts = nullptr; + gr.execs = nullptr; + gr.parts_dev = nullptr; + gr.n = 0; + gr.captured = false; +} + +bool SessionLoopScratch::init(size_t parts_bytes_in, std::string& err) { + if (y_miss != nullptr || probe != nullptr) { + err = "SessionLoopScratch::init: already initialised"; + return false; + } + parts_bytes = parts_bytes_in; + // MAPPED as well as pinned: the token graph's handoff kernel reads it through its device pointer. + if (cudaHostAlloc((void**) &y_miss, parts_bytes, cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess) { + err = "SessionLoopScratch: cudaHostAlloc for the pool's staging failed"; + return false; + } + std::memset(y_miss, 0, parts_bytes); + if (cudaEventCreate(&probe) != cudaSuccess) { + err = "SessionLoopScratch: cudaEventCreate failed"; + free(); + return false; + } + // **PIN THE HOST ONCE, NOT ONCE PER TOKEN.** `ExpertPool` builds its workers from `physical_cores(true)`, + // which drops the first physical core so the host loop can spin without taking a worker's cycles - and + // nothing in the pool can pin the host, so if this does not happen the spin is free to land on a worker's + // core or its SMT sibling. The symptom is not an error: it is a CPU path at 26.9 GB/s where the same pool + // runs at 36.32. It was being done and undone on EVERY token, which is a syscall pair on the critical path + // for a property that wants to hold for the whole session. + const std::vector cores = strata::kernels::cpu::physical_cores(false); + if (!cores.empty()) { + pinned_core = strata::kernels::cpu::pin_current_thread(cores[0]); + pinned = true; + } + return true; +} + +void SessionLoopScratch::free() { + // Restores the caller's affinity on every path, including teardown: a library that silently leaves its + // caller's thread nailed to one core is worse than one that never pinned at all. + if (pinned) { + strata::kernels::cpu::restore_thread_affinity(pinned_core); + pinned = false; + pinned_core = -1; + } + if (probe != nullptr) { cudaEventDestroy(probe); probe = nullptr; } + if (y_miss != nullptr) { cudaFreeHost(y_miss); y_miss = nullptr; } + parts_bytes = 0; +} + +bool session_loop(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, SessionGraphs& gr, + PoolFn pool, HitFn hits, void* user, bool overlap, void* stream, std::string& err, + float* dump_layers, SessionLoopScratch* scratch) { + if (!gr.captured || gr.n != g.n_layers) { err = "session_loop: not captured"; return false; } + if (gr.parts_dev == nullptr) { err = "session_loop: the graphs were captured without a parts buffer"; return false; } + if (s.db == nullptr) { err = "session_loop: no doorbell; the loop has nothing to poll"; return false; } + + cudaStream_t cs = (cudaStream_t) stream; + const int64_t k = s.k; + const size_t parts_bytes = (size_t) k * g.n_embd * 4; + ++gr.calls_total; + + // ================================ THE POSITION, WHICH THIS LOOP NEVER STAGED ================================ + // + // **EVERY TOKEN AFTER THE FIRST USED TO REPLAY POSITION 0.** The QSA layers read their per-token counts from + // `st.step` and `st.pos_dev`, which are DEVICE buffers filled by an H2D copy captured INTO each graph. A + // graph bakes the SOURCE POINTER, not the value - so the copy re-reads the pinned host buffers on every + // replay, and those hold whatever was last written there. `session_capture` left them at position 0. + // + // `layer.hpp` documents the requirement this loop was missing: "The per-token staging every QSA layer's + // captured H2D reads FROM. Must run before each replay: the graphs captured the SOURCE POINTER, not the + // value, and that is exactly why the buffers are pinned and fixed." `session_replay` calls `stage_token`; + // `session_loop` did not. **A single-token test cannot see a position that never advances** - which is why + // the whole suite passed while a 20-token prompt rotated every token at position 0. + stage_token(g, pos, pos_base, s); + + // ---- **THE TOKEN PATH ALLOCATES NOTHING (P2.T10, review finding H3).** + // + // Everything this loop needs on the host - the pinned staging for the pool's answer, the doorbell probe + // event, and the host pin - lives in a `SessionLoopScratch` owned by the SESSION and passed in. It used to + // be created here, once per token, and `cudaFreeHost` at the end of the call IMPLICITLY SYNCHRONISES THE + // DEVICE, so every token finished with a device-wide sync that nothing had asked for. A comment here said + // "allocated once"; once per token is not once. + // + // Passing `nullptr` keeps the old behaviour so existing callers and tests are unaffected: the loop builds a + // local scratch and frees it on every exit path below, including the error returns. + SessionLoopScratch local; + if (scratch == nullptr) { + if (!local.init(parts_bytes, err)) return false; + scratch = &local; + } + struct LocalFree { + SessionLoopScratch* p; + ~LocalFree() { if (p != nullptr) p->free(); } + } local_free{scratch == &local ? &local : nullptr}; + if (scratch->parts_bytes != parts_bytes) { + err = "session_loop: the scratch was sized for a different k or n_embd"; + return false; + } + + // HOST-side staging for the pool's answer. It is copied to `gr.parts_dev` before the next launch, on the + // same stream, so the ordering is the stream's and no captured node is needed for it. + // + // ---- **PINNED, BECAUSE A COPY FROM PAGEABLE MEMORY IS NOT ASYNCHRONOUS AT ALL.** + // + // `cudaMemcpyAsync` with a pageable source cannot DMA: the driver first memcpys the bytes into an internal + // pinned bounce buffer, SYNCHRONOUSLY, on this thread, and only then enqueues the transfer. So the "async" + // copy would be a blocking memcpy plus a driver round trip, on the critical path of all 48 layers. The + // doorbell's `h_x_f` is `cudaHostAlloc(Mapped)` for exactly this reason and this buffer is the same kind of + // object. + float* y_miss = scratch->y_miss; + cudaEvent_t probe = scratch->probe; + + // the first layer has no previous layer's experts: `y_miss` starts at zero, which is what "hits are empty + // in this phase" means once every miss has been computed + cudaMemcpyAsync(gr.parts_dev, y_miss, parts_bytes, cudaMemcpyHostToDevice, cs); + doorbell_reset(*s.db); + uint32_t expected = 0; + + // **`pre[0]` IS LAUNCHED BEFORE THE LOOP SO EVERY ITERATION HAS THE SAME SHAPE**: poll the ring that + // `pre[l]` published, run the pool on it, combine, then start layer `l+1`'s `pre`. + // + // SLOT 0 OF THE DUMP IS THE INPUT, not a layer output. `s.R` holds the embedding broadcast to every stream + // when this function is entered, which is the reference's `hc_init` node - so dumping it here is what splits + // "the input to layer 0 is already wrong" from "layer 0 is wrong". + if (dump_layers != nullptr) { + const size_t n = (size_t) g.hc * (size_t) g.n_embd; + const cudaError_t de = cudaMemcpyAsync(dump_layers, s.R, n * sizeof(float), cudaMemcpyDeviceToHost, cs); + if (de != cudaSuccess) { + err = "session_loop: dump the input residual: " + std::string(cudaGetErrorString(de)); + return false; + } + } + { + const auto t0 = std::chrono::steady_clock::now(); + const cudaError_t le = cudaGraphLaunch(gr.execs[0], cs); + if (le != cudaSuccess) { + err = "session_loop: launch pre[0]: " + std::string(cudaGetErrorString(le)); + return false; + } + cudaEventRecord(probe, cs); + (void) t0; + } + + for (int64_t l = 0; l < g.n_layers; ++l) { + const auto t_launch = std::chrono::steady_clock::now(); + + // ---- poll for the ring. **THE DRIVER CALL IS NOW A FALLBACK, NOT THE MECHANISM.** + const uint32_t want = ++expected; + bool rang = false; + bool mid_graph = false; + // VOLATILE, because the device writes this through mapped pinned memory and a cached host line would + // never see it. Read through a volatile pointer so the compiler re-issues the load every iteration - + // otherwise this whole spin collapses into `while (true) { }`. + volatile uint32_t* const seq = s.db->h_seq; + for (;;) { + // **THE PER-ITERATION DRIVER CALL IS REQUIRED, AND THREE EXPERIMENTS NOW SAY SO.** Round 287 + // throttled this to one call in 64 and broke tests 51 and 56; adding `__threadfence_system()` to + // `doorbell_ring_kernel` and a volatile read here, then throttling, broke them again. So the call + // is not merely flushing submission (round 195's reading) and it is not a missing fence either: on + // this driver, the device's write to mapped pinned memory becomes host-visible only when the driver + // is entered. The fence and the volatile read are kept because they are correct and cost nothing - + // without them the ring's ordering against `x_f`/`ids`/`weights` is unstated - but they do not + // remove the need for the call, and nothing here should be read as claiming they do. + const cudaError_t q = cudaEventQuery(probe); + if (!rang && *seq >= want) { + rang = true; + mid_graph = (q != cudaSuccess); + } + if (q == cudaSuccess) break; // the graph has ended + if (q != cudaErrorNotReady) { + err = "session_loop: query at layer " + std::to_string(l) + ": " + cudaGetErrorString(q); + return false; + } + if (rang) break; // rung, and the graph is still running: the overlap + STRATA_SPIN_PAUSE(); + } + if (mid_graph) ++gr.rings_mid_graph; + // the window, from just before the launch to the instant the ring was seen + const auto t_ring = std::chrono::steady_clock::now(); + gr.ms_to_ring += std::chrono::duration(t_ring - t_launch).count(); + if (!rang) { + // The graph finished without the ring being observed. `h_seq` is monotonic, so this is not a race + // - it means the ring never happened, which is the failure round 199 found in a captured event. + err = "session_loop: layer " + std::to_string(l) + " ended without ringing"; + return false; + } + + // ---- **THE GPU'S HALF GOES FIRST, SO IT RUNS WHILE THE CPU DOES ITS HALF.** `Launch` only enqueues: + // the quantize and the grouped expert kernel land on `main_cs` and the GPU starts on them immediately, + // while the host is still inside `pool` below. Nothing here waits. + if (hits != nullptr) hits(user, cs, HitPhase::Launch, s.db->h_ids, k); + + // ---- the pool, on the bytes the doorbell published. **THESE ARE LAYER `l`'s EXPERTS.** + if (pool != nullptr) pool(user, s.db->h_x_f, s.db->h_ids, s.db->h_weights, g.n_embd, k, y_miss); + + // ---- **AND THEY ARE COMBINED BY LAYER `l`, NOT BY LAYER `l+1`.** + // + // This copy and the launch below are the whole of the L100 fix. `moe_combine` multiplies `parts` by + // THIS layer's `b.weights`, so `parts` must be THIS layer's expert outputs; the previous form copied + // the pool's answer into `parts_dev` for the NEXT layer, which computed + // `sum_j w_{l+1}[j] * expert_{ids_l,j}(x_l)` - both the selection and the input one layer stale while + // the weights were current. It produced finite, fluent, deterministic tokens that were not the + // model's, and no timing test could see it. + cudaMemcpyAsync(gr.parts_dev, y_miss, parts_bytes, cudaMemcpyHostToDevice, cs); + // ---- **AND THEN THE COMBINE.** Stream-ordered after the copy above, so `hit_out` is added to misses + // that are already in `parts`, and before `post[l]`, whose `moe_combine` reads the sum. A no-op when + // there is no VRAM tier or nothing is resident, which is every layer until the cache warms. + if (hits != nullptr) hits(user, cs, HitPhase::Combine, nullptr, 0); + { + const cudaError_t pe = cudaGraphLaunch(gr.posts[l], cs); + if (pe != cudaSuccess) { + err = "session_loop: launch post[" + std::to_string(l) + "]: " + cudaGetErrorString(pe); + return false; + } + } + // ---- the C1 oracle. `s.R` now holds layer `l`'s residual and keeps it until `post[l+1]` writes it, so + // a copy enqueued HERE - after `post[l]`, before `pre[l+1]` - is ordered correctly by the stream alone. + // Nothing is synchronised: the loop's existing final sync is what completes these. See the note on + // `dump_layers` in session.hpp for why this is an enqueue and not a read. + if (dump_layers != nullptr) { + const size_t n = (size_t) g.hc * (size_t) g.n_embd; + const cudaError_t de = cudaMemcpyAsync(dump_layers + (size_t) (l + 1) * n, s.R, n * sizeof(float), + cudaMemcpyDeviceToHost, cs); + if (de != cudaSuccess) { + err = "session_loop: dump layer " + std::to_string(l) + ": " + cudaGetErrorString(de); + return false; + } + } + if (!overlap) { + // THE COMPARISON ARM. The same sequence with the pipeline removed: the CPU does not start until the + // layer is over, so nothing overlaps and the difference is attributable to the doorbell alone. + if (cudaStreamSynchronize(cs) != cudaSuccess) { + err = "session_loop: sync at layer " + std::to_string(l); + return false; + } + } + // ---- and layer l+1's routing starts. `pre[l+1]` touches none of the buffers `post[l]` reads, because + // the two are ordered on one stream and `pre[l+1]`'s first use of `bb.mixed`/`bb.inject` is its own + // `gr_read`, which comes after `post[l]` has consumed them. + if (l + 1 < g.n_layers) { + const cudaError_t ne = cudaGraphLaunch(gr.execs[l + 1], cs); + if (ne != cudaSuccess) { + err = "session_loop: launch pre[" + std::to_string(l + 1) + "]: " + cudaGetErrorString(ne); + return false; + } + cudaEventRecord(probe, cs); + } + // ---- **THE SECOND HALF OF THE ROUND TRIP, AND THE HALF NOTHING WAS MEASURING.** + // + // From seeing the ring to having queued the next `pre`. Everything in here is a driver call, and on + // the token path the GPU has nothing left to run for most of it - `pre[l]` has already rung, and + // `post[l]` cannot start until this code launches it. So this interval is GPU-idle time, and it is + // the number that decides whether the fix is R2.4 (fewer launches per layer) or a faster kernel. + // + // It INCLUDES the `!overlap` synchronisation when that arm is selected, which is deliberate: that arm + // exists to show what the pipeline is worth, and hiding its cost here would defeat the comparison. + gr.ms_host += std::chrono::duration(std::chrono::steady_clock::now() - t_ring).count(); + } + if (cudaStreamSynchronize(cs) != cudaSuccess) { err = "session_loop: final sync"; return false; } + return true; +} + +bool session_token(const WeightTable& tables, const ModelGeometry& g, int64_t pos, int32_t pos_base, + SessionState& s, const float* parts, void* stream, bool sync_every_layer, + std::string& err) { + cudaStream_t cs = (cudaStream_t) stream; + int64_t qsa_index = 0; + int64_t gdn_index = 0; + + for (int64_t l = 0; l < g.n_layers; ++l) { + // THE GDN LAYERS EACH GET THEIR OWN STATE, and `GdnBuffers` carries it - so the session points the + // shared scratch at the right slice before each call. Sharing one state across 36 layers would make + // every layer start from the previous layer's recurrence, which produces a perfectly finite answer. + if (!is_qsa_layer(g, l)) { + s.gdn.state = s.gdn_state + (size_t) gdn_index * gdn_state_floats(g); + s.gdn.conv_state = s.gdn.state + (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size; + ++gdn_index; + } + + err.clear(); + const bool ok = is_qsa_layer(g, l) + ? block_layer(tables, g, l, pos, pos_base, s.gdn, s.qsa_states[qsa_index], s.qsa_bufs, s.moe, s.k, + s.block, parts, stream, err, nullptr, s.ple.ready() ? &s.ple : nullptr) + : block_layer(tables, g, l, pos, pos_base, s.gdn, s.qsa_states[0], s.qsa_bufs, s.moe, s.k, + s.block, parts, stream, err, nullptr, s.ple.ready() ? &s.ple : nullptr); + if (!ok) { + err = "layer " + std::to_string(l) + ": " + err; + return false; + } + if (is_qsa_layer(g, l)) ++qsa_index; + + if (sync_every_layer) { + // THE DEBUG MODE, and its cost is the point: it is a real synchronisation after every layer, which + // P2.X3 forbids in the fast path. It exists to turn an ASYNCHRONOUS error - a kernel reading a + // buffer a later layer wrote, or an out-of-range launch - into a failure AT THE LAYER THAT CAUSED + // IT, instead of a wrong number 40 layers later or at the end of the token. + const cudaError_t e = cudaStreamSynchronize(cs); + if (e != cudaSuccess) { + err = "layer " + std::to_string(l) + ": " + cudaGetErrorString(e); + return false; + } + } + } + return true; +} + +} // namespace strata::core + +namespace strata::core { + +bool session_capture_token(const WeightTable& tables, const ModelGeometry& g, SessionState& s, float* parts_dev, + const float* y_miss_host, size_t parts_bytes, TokenGraph& tg, std::string& err, + const TokenHits* hits) { + if (hits != nullptr && !hits->on()) { err = "session_capture_token: incomplete hit configuration"; return false; } + if (tg.captured) return true; + if (s.db == nullptr || s.db->d_flag == nullptr || s.db->d_seq == nullptr) { + err = "session_capture_token: the doorbell has no flag"; + return false; + } + if (parts_dev == nullptr || y_miss_host == nullptr || parts_bytes == 0) { + err = "session_capture_token: parts buffers are required"; + return false; + } + float* y_dev = nullptr; + if (cudaHostGetDevicePointer((void**) &y_dev, const_cast(y_miss_host), 0) != cudaSuccess || !y_dev) { + err = "session_capture_token: the parts staging is not mapped pinned memory"; + return false; + } + cudaStream_t cs = nullptr; + if (cudaStreamCreate(&cs) != cudaSuccess) { err = "session_capture_token: stream create failed"; return false; } + if (cudaStreamBeginCapture(cs, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { + cudaStreamDestroy(cs); + err = "session_capture_token: begin capture failed"; + return false; + } + int64_t qsa_index = 0; + bool ok = true; + for (int64_t l = 0; l < g.n_layers && ok; ++l) { + gdn_point_at(g, l, s); + const bool qsa = is_qsa_layer(g, l); + QsaState& qst = qsa ? s.qsa_states[qsa_index] : s.qsa_states[0]; + err.clear(); + ok = block_layer_pre(tables, g, l, 0, 0, s.gdn, qst, s.qsa_bufs, s.moe, s.k, s.block, (void*) cs, err, s.db, + s.ple.ready() ? &s.ple : nullptr); + if (!ok) { err = "session_capture_token: pre layer " + std::to_string(l) + ": " + err; break; } + if (hits != nullptr) { + // After the ring (and the shared expert): the GPU's experts run while the CPU computes the misses. + strata::kernels::moe_hit_select(s.moe.ids, hits->d_res + l * hits->n_expert, (int) s.k, + (int) hits->n_expert, hits->d_slot, hits->d_dst, hits->d_count, (void*) cs); + strata::kernels::quantize_q8_0_scaled(s.block.mixed, hits->x_q8, hits->x_scale, g.n_embd, (void*) cs); + strata::kernels::moe_hit_grouped_s2_dev(hits->cache_base, hits->d_slot, hits->d_dst, hits->d_count, s.k, + hits->blob, hits->x_q8, hits->scratch, hits->hit_out, (void*) cs, + hits->x_scale); + } + strata::kernels::doorbell_wait(s.db->d_flag, s.db->d_seq, (void*) cs); + // A kernel, not a memcpy node: a copy-engine node splits the WDDM submission (measured 67 flushes/token). + strata::kernels::copy_from_mapped(parts_dev, y_dev, (int64_t) (parts_bytes / sizeof(float)), (void*) cs); + if (hits != nullptr) + strata::kernels::moe_hit_add(parts_dev, hits->hit_out, hits->d_dst, hits->d_count, s.k, g.n_embd, (void*) cs); + ok = block_layer_post(tables, g, l, s.k, s.moe, s.block, parts_dev, (void*) cs, err); + if (!ok) { err = "session_capture_token: post layer " + std::to_string(l) + ": " + err; break; } + if (qsa) ++qsa_index; + } + cudaGraph_t graph = nullptr; + const cudaError_t ce = cudaStreamEndCapture(cs, &graph); + cudaStreamDestroy(cs); + if (!ok) { if (graph) cudaGraphDestroy(graph); return false; } + if (ce != cudaSuccess) { + err = std::string("session_capture_token: end capture: ") + cudaGetErrorString(ce); + return false; + } + const cudaError_t ie = cudaGraphInstantiate(&tg.exec, graph, 0); + cudaGraphDestroy(graph); + if (ie != cudaSuccess) { + err = std::string("session_capture_token: instantiate: ") + cudaGetErrorString(ie); + return false; + } + tg.captured = true; + tg.n_layers = g.n_layers; + tg.y_src = y_miss_host; + tg.parts_bytes = parts_bytes; + return true; +} + +bool session_run_token(const ModelGeometry& g, int64_t pos, int32_t pos_base, SessionState& s, TokenGraph& tg, + PoolFn pool, void* user, float* y_miss_host, void* stream, std::string& err) { + if (!tg.captured) { err = "session_run_token: not captured"; return false; } + if (y_miss_host != tg.y_src) { err = "session_run_token: the staging buffer is not the captured one"; return false; } + cudaStream_t cs = (cudaStream_t) stream; + ++tg.calls; + stage_token(g, pos, pos_base, s); + doorbell_reset(*s.db); + const cudaError_t le = cudaGraphLaunch(tg.exec, cs); + if (le != cudaSuccess) { err = std::string("session_run_token: launch: ") + cudaGetErrorString(le); return false; } + (void) cudaStreamQuery(cs); // one flush, so WDDM submits the graph now + static const int flush_us = [] { + const char* e = std::getenv("STRATA_TG_FLUSH_US"); + return e ? std::atoi(e) : 2000; // 24 Sep: 0 flushes run as fast as 5 us ones; this only notices faults + }(); + volatile uint32_t* const seq = s.db->h_seq; + volatile uint32_t* const flag = s.db->h_flag; + using Clock = std::chrono::steady_clock; + for (int64_t l = 0; l < g.n_layers; ++l) { + const uint32_t want = (uint32_t) (l + 1); + const auto t0 = Clock::now(); + auto last_flush = t0; + uint32_t spins = 0; + while (*seq < want) { + STRATA_SPIN_PAUSE(); + if ((++spins & 1023u) != 0) continue; + const auto now = Clock::now(); + if (now - last_flush > std::chrono::microseconds(flush_us)) { + // A slow ring: flush the submission queue once more, and notice a fault or a finished graph. + last_flush = now; + ++tg.flushes; + const cudaError_t q = cudaStreamQuery(cs); + if (q != cudaErrorNotReady && *seq < want) { + err = "session_run_token: layer " + std::to_string(l) + " never rang (" + + (q == cudaSuccess ? std::string("graph finished") : std::string(cudaGetErrorString(q))) + ")"; + return false; + } + } + if (now - t0 > std::chrono::seconds(20)) { + err = "session_run_token: timed out waiting for layer " + std::to_string(l); + return false; + } + } + const auto t1 = Clock::now(); + if (pool != nullptr) pool(user, s.db->h_x_f, s.db->h_ids, s.db->h_weights, g.n_embd, s.k, y_miss_host); + std::atomic_thread_fence(std::memory_order_seq_cst); + _mm_sfence(); + *flag = want; + const auto t2 = Clock::now(); + tg.ms_wait += std::chrono::duration(t1 - t0).count(); + tg.ms_pool += std::chrono::duration(t2 - t1).count(); + } + const cudaError_t se = cudaStreamSynchronize(cs); + if (se != cudaSuccess) { err = std::string("session_run_token: ") + cudaGetErrorString(se); return false; } + return true; +} + +void token_graph_free(TokenGraph& tg) { + if (tg.exec) cudaGraphExecDestroy(tg.exec); + tg = TokenGraph{}; +} + +} // namespace strata::core diff --git a/src/core/verify.cpp b/src/core/verify.cpp new file mode 100644 index 00000000..35a73a21 --- /dev/null +++ b/src/core/verify.cpp @@ -0,0 +1,877 @@ +// src/core/verify.cpp - see include/strata/core/verify.hpp. +#include "strata/core/verify.hpp" +#if defined(_WIN32) +#include +#endif + +#include "strata/core/native_head.hpp" +#include "strata/kernels/iq_kernels.hpp" +#include "strata/kernels/cpu/expert_layout.hpp" +#include "strata/kernels/bf16_gemv.hpp" +#include "strata/kernels/cpu/expert.hpp" +#include "strata/kernels/elementwise.hpp" +#include "strata/kernels/fused_gr.hpp" +#include "strata/kernels/gr.hpp" +#include "strata/kernels/kv_q8.hpp" +#include "strata/kernels/native_mmvq.hpp" +#include "strata/kernels/native_qsa.hpp" +#include "strata/kernels/native_qsa_indexer.hpp" +#include "strata/kernels/native_rope.hpp" +#include "strata/kernels/ngram.hpp" +#include "strata/kernels/ple.hpp" +#include "strata/kernels/qsa.hpp" +#include "strata/kernels/qsa_decode_attn.hpp" +#include "strata/kernels/qsa_select.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/rope.hpp" +#include "strata/kernels/s2_expert_grouped.hpp" +#include "strata/kernels/sampler.hpp" +#include "strata/kernels/shared_expert.hpp" +#include "strata/kernels/verify_kernels.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace strata::core { +namespace { + +constexpr float EPS = 1e-6f; +using Clock = std::chrono::steady_clock; +double ms_since(Clock::time_point t) { return std::chrono::duration(Clock::now() - t).count(); } +const bool g_dbg = std::getenv("STRATA_VERIFY_DEBUG") != nullptr; +#define VDBG(...) do { if (g_dbg) { std::fprintf(stderr, "verify dbg: " __VA_ARGS__); std::fflush(stderr); } } while (0) + +struct Bump { + uint8_t* base = nullptr; + uint64_t used = 0; + template T* take(uint64_t n) { + T* p = base ? (T*) (base + used) : nullptr; + used += (n * sizeof(T) + 255) & ~255ull; + return p; + } +}; + +bool mapped(size_t bytes, void** h, void** d) { + if (cudaHostAlloc(h, bytes, cudaHostAllocMapped) != cudaSuccess) return false; + std::memset(*h, 0, bytes); + return cudaHostGetDevicePointer(d, *h, 0) == cudaSuccess; +} + +strata::kernels::QsaShapes shapes_of(const ModelGeometry& g) { + strata::kernels::QsaShapes s = strata::kernels::qsa_real_shapes(); + s.n_head = g.n_head; + s.n_head_kv = g.n_head_kv; + s.head_dim = g.head_dim; + s.idx_n_head = g.idx_q_heads; + s.idx_dim = g.idx_key_dim; + return s; +} + +const WeightRef* need(const LayerView& v, const char* suffix, std::string& err) { + const WeightRef* r = v.get(suffix); + if (r == nullptr && err.empty()) err = v.name(suffix) + " is missing"; + return r; +} + +bool native_of(const WeightRef* w, const std::string& name, std::string& err) { + if (w == nullptr) return false; + if (w->native_data == nullptr) { + err = "verify: " + name + " is not served natively (run with --native)"; + return false; + } + return true; +} + +} // namespace + +Verifier::~Verifier() { + if (cs_) cudaStreamSynchronize(cs_); + for (auto& e : exec_) + if (e) cudaGraphExecDestroy(e); + if (commit_exec_) cudaGraphExecDestroy(commit_exec_); + if (cs_) cudaStreamDestroy(cs_); + if (copy_) { cudaStreamSynchronize(copy_); cudaStreamDestroy(copy_); } + if (arena_) cudaFree(arena_); + void* hosts[] = {h_tok_, h_step_, h_pos_, h_commit_, h_ple_, h_out_, h_x_, h_ids_, h_w_, h_seq_, h_flag_, h_ymiss_, + h_flagA_, h_plan_, h_flagB_}; + for (void* h : hosts) + if (h) cudaFreeHost(h); +} + +bool Verifier::init(const WeightTable& wt, const ModelGeometry& g, SessionState& ss, const VerifyHits& hits, + const NativeHead* head, int max_t, std::string& err) { + wt_ = &wt; + g_ = &g; + ss_ = &ss; + hits_ = hits; + head_ = head; + max_t_ = max_t; + if (max_t < 2 || max_t > strata::kernels::kVerifyMaxT || max_t > strata::kernels::cpu::MAXT) { + err = "verify: the window must hold 2.." + std::to_string(strata::kernels::kVerifyMaxT) + " tokens"; + return false; + } + if (hits.d_res == nullptr || hits.cache_base == nullptr || hits.blob <= 0) { + err = "verify: needs the profile-filled VRAM expert tier (--expert-profile and --expert-cache)"; + return false; + } + std::string why; + if (!layer_verify_compatible(why)) { + err = "verify: " + why + " (the verify window reproduces the default native decode path)"; + return false; + } + if (!strata::kernels::fused_gr_supported(g.n_embd, g.hc, g.hc_lr) || ss.k != 10 || g.ssm_state_size != 128 || + g.ssm_d_conv != 4) { + err = "verify: geometry differs from the artifact's"; + return false; + } + const WeightRef* wo = wt.find("output.weight"); + if (wo == nullptr) { err = "verify: output.weight is missing"; return false; } + n_vocab_ = wo->ne1; + + const strata::kernels::QsaShapes s = shapes_of(g); + cap_ = strata::kernels::qsa_selection_width(strata::kernels::kTopkMaxCells, s); + max_blocks_ = ss.qsa_states[0].max_cells / s.idx_block + 2; + attn_scratch_floats_ = (int64_t) strata::kernels::qsa_decode_attn_scratch_floats(cap_, s); + + const uint64_t T = (uint64_t) max_t, N = (uint64_t) g.n_embd, HC = (uint64_t) g.hc, K = (uint64_t) ss.k; + const uint64_t C = (uint64_t) g.ssm_conv_channels, ZV = (uint64_t) g.ssm_value_dim, HV = (uint64_t) g.ssm_v_heads; + const uint64_t NH = (uint64_t) g.n_head, HD = (uint64_t) g.head_dim, NKV = (uint64_t) g.n_head_kv; + const uint64_t IQ = (uint64_t) g.idx_q_heads, ID = (uint64_t) g.idx_key_dim; + const uint64_t nG = (uint64_t) g.n_gdn_layers(), nQ = (uint64_t) g.n_qsa_layers(); + const uint64_t HS = (uint64_t) strata::kernels::NG_HIST * strata::kernels::NG_HC_DIM; + const uint64_t TS = (uint64_t) (s.idx_block - 1) * ID; + const int max_in = (int) std::max(std::max(N, ZV), NH * HD); + + // ---- mapped staging + bool ok = mapped(T * 4, (void**) &h_tok_, (void**) &m_tok_) && + mapped(T * strata::kernels::kStepCount * 4, (void**) &h_step_, (void**) &m_step_) && + mapped(T * NH * 4, (void**) &h_pos_, (void**) &m_pos_) && + mapped((2 + T) * 4 + 16, (void**) &h_commit_, (void**) &m_commit_) && + mapped(T * N * 4, (void**) &h_ple_, (void**) &m_ple_) && + mapped(T * 4 + 16, (void**) &h_out_, (void**) &m_out_) && + mapped(T * N * 4, (void**) &h_x_, (void**) &m_x_) && + mapped(T * K * 4, (void**) &h_ids_, (void**) &m_ids_) && + mapped(T * K * 4, (void**) &h_w_, (void**) &m_w_) && + mapped(64, (void**) &h_seq_, (void**) &m_seq_) && + mapped(64, (void**) &h_flag_, (void**) &m_flag_) && + mapped(64, (void**) &h_flagA_, (void**) &m_flagA_) && + mapped(64, (void**) &h_flagB_, (void**) &m_flagB_) && + mapped(T * K * N * 4, (void**) &h_ymiss_, (void**) &m_ymiss_); + if (!ok) { err = "verify: mapped staging allocation failed"; return false; } + // the GPU plan: counts(4) | start(cap+1) | dst(cap) | tok(cap) | pad | ptr(cap u64) | ptr2(cap u64) | start2(cap+1) + { + const int64_t cap = (int64_t) (T * K); + const int64_t i32 = 4 + (cap + 1) + cap + cap; + const int64_t ptr_off = (i32 + 1) & ~1ll; + plan_i32_ = ptr_off + 4 * cap + (cap + 1) + 1; + if (!mapped((size_t) plan_i32_ * 4 * 2 + 64, (void**) &h_plan_, (void**) &m_plan_)) { + err = "verify: mapped plan allocation failed"; + return false; + } + sink_.counts = h_plan_; + sink_.start = h_plan_ + 4; + sink_.dst = sink_.start + cap + 1; + sink_.tok = sink_.dst + cap; + sink_.ptr = (unsigned long long*) (h_plan_ + ptr_off); + sink_.ptr2 = sink_.ptr + cap; + sink_.start2 = h_plan_ + ptr_off + 4 * cap; + sink_.cap = cap; + sink_.publish = &Verifier::publish_plan; + sink_.fetch = &Verifier::fetch_dma; + sink_.ctx = this; + } + + // ---- the device arena: the same sequence counted, then carved + auto carve = [&](Bump& b) { + tok_ = b.take(T); step_ = b.take(T * strata::kernels::kStepCount); + pos_ = b.take(T * NH); commit_ = b.take(2 + T); + ple_ = b.take(T * N); emb_ = b.take(T * N); R_ = b.take(T * HC * N); + mixed_ = b.take(T * N); bo_ = b.take(T * N); + inj_ = b.take(T * HC); inj2_ = b.take(T * HC); + lo_ = b.take(T * (uint64_t) g.hc_lr); rs_ = b.take(T * HC); xn_ = b.take(T * HC * N); + xq_ = b.take(strata::kernels::native_q8_1_bytes(max_in, (int) T)); + qkv_L_ = b.take(nG * T * C); h_L_ = b.take(nG * T * C); + gate_L_ = b.take(nG * T * HV); beta_L_ = b.take(nG * T * HV); + z_ = b.take(T * ZV); y_ = b.take(T * ZV); y_dummy_ = b.take(T * ZV); + qfull_ = b.take(T * NH * 2 * HD); qcur_ = b.take(T * NH * HD); + kcur_ = b.take(T * NKV * HD); vcur_ = b.take(T * NKV * HD); + idx_raw_L_ = b.take(nQ * T * ID); qidx_ = b.take(T * IQ * ID); + scores_ = b.take(T * (uint64_t) max_blocks_); sel_ = b.take(T * (uint64_t) cap_); + attn_ = b.take(T * NH * HD); attn32_ = b.take(T * NH * HD); + attn_scratch_ = b.take(T * (uint64_t) attn_scratch_floats_); + tail_snap_ = b.take(nQ * TS); + logits_ = b.take(T * (uint64_t) g.n_expert); w_ = b.take(T * K); ids_ = b.take(T * K); + shared_ = b.take(T * N); parts_ = b.take(T * K * N); hit_out_ = b.take(T * K * N); + hit_slot_ = b.take(T * K); hit_dst_ = b.take(T * K); hit_count_ = b.take(4); + plan_ = b.take(2 * ((uint64_t) plan_i32_ + 16)); + staging_ = b.take((uint64_t) kStagingBlobs * strata::kernels::cpu::expert_layout().max_blob); + hit_xq_ = b.take(T * (N / 32) * 34); hit_xs_ = b.take(T * (N / 32)); + nat_xq_ = b.take(T * (N / 32) * 36); + hit_scratch_ = b.take(std::max( + strata::kernels::moe_hit_grouped_scratch_bytes((int64_t) (T * K), g.n_embd, g.n_ff), + strata::kernels::native_expert_scratch_bytes((int64_t) (T * K), g.n_ff))); + head_mixed_ = b.take(T * N); head_inj_ = b.take(HC); + sh_bf16_ = b.take(T * N); sh_gate_ = b.take(T * (uint64_t) g.n_ff); + sh_up_ = b.take(T * (uint64_t) g.n_ff); sh_g_ = b.take(T + 4); + head_logits_ = b.take(T * (uint64_t) n_vocab_); + hist_snap_ = b.take(T * HS); + }; + Bump count; + carve(count); + if (cudaMalloc(&arena_, count.used) != cudaSuccess) { + err = "verify: the device arena (" + std::to_string(count.used >> 20) + " MiB) does not fit"; + return false; + } + cudaMemset(arena_, 0, count.used); + Bump real; + real.base = (uint8_t*) arena_; + carve(real); + sink_.staging = (unsigned long long) staging_; + sink_.staging_cap = kStagingBlobs; + (void) TS; + if (cudaStreamCreateWithFlags(©_, cudaStreamNonBlocking) != cudaSuccess) { + err = "verify: copy stream create failed"; + return false; + } + if (cudaStreamCreateWithFlags(&cs_, cudaStreamNonBlocking) != cudaSuccess) { + err = "verify: stream create failed"; + return false; + } + std::fprintf(stderr, "strata verify: window up to %d tokens, %.1f MiB of device buffers\n", max_t, + (double) count.used / 1048576.0); + return true; +} + +const float* Verifier::final_R(int t) const { return R_ + (size_t) t * (size_t) (g_->hc * g_->n_embd); } + +// ================================ THE WINDOW, AS CAPTURED ================================ +// +// Plan v0.3 P6 (split window): with `groups_ == 2` the window's tokens are cut into two groups A = [0, T/2 up) and +// B = the rest, and the stream is ordered +// +// pre(0,A) pre(0,B) | post(0,A) pre(1,A) | post(0,B) pre(1,B) | post(1,A) pre(2,A) | ... +// +// so the CPU computes A's experts of layer l while the GPU runs B's mixer and router of layer l, and B's experts +// while the GPU combines A and runs A's layer l+1. B's mixer only needs A's mixer of the same layer (K/V, GDN +// state), never A's experts, so nothing waits that did not wait before. Every token's arithmetic is unchanged. +bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) { + using namespace strata::kernels; + const ModelGeometry& g = *g_; + const WeightTable& wt = *wt_; + SessionState& ss = *ss_; + const int64_t N = g.n_embd, HC = g.hc, K = ss.k, C = g.ssm_conv_channels, ZV = g.ssm_value_dim; + const int64_t HV = g.ssm_v_heads, HK = g.ssm_k_heads, NH = g.n_head, HD = g.head_dim, NKV = g.n_head_kv; + const int64_t IQ = g.idx_q_heads, ID = g.idx_key_dim, NE = g.n_expert, MT = max_t_; + const QsaShapes s = shapes_of(g); + const GrShapes gs{g.n_embd, g.hc, g.hc_lr}; + const uint64_t gdn_floats = (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size + + (uint64_t) g.ssm_conv_channels * (g.ssm_d_conv - 1); + const int64_t HS = (int64_t) NG_HIST * NG_HC_DIM; + const int64_t TS = (s.idx_block - 1) * ID; + const bool ple_on = ss.ple.ready(); + auto Rt = [&](int t) { return R_ + (size_t) t * HC * N; }; + const int G = (split_ && T >= 2) ? 2 : 1; + const int tb_[2] = {0, (T + 1) / 2}, te_[2] = {G == 2 ? (T + 1) / 2 : T, T}; + groups_[T] = G; + + // ---- the window's inputs, from mapped staging + copy_i32_from_mapped(tok_, m_tok_, T, cs); + copy_i32_from_mapped(step_, m_step_, (int64_t) T * kStepCount, cs); + copy_i32_from_mapped(pos_, m_pos_, (int64_t) T * NH, cs); + if (ple_on) copy_from_mapped(ple_, m_ple_, (int64_t) T * N, cs); + + // ---- the embeddings, broadcast to the hc streams + if (const NativeEmbed* ne = native_embed()) { // plan v0.3 P6: the GGUF-form table + ne->gather_dev(tok_, T, emb_, cs); + broadcast_streams(emb_, R_, N, (int) HC, T, cs); + } else { + const WeightRef* w = wt.find("token_embd.weight"); + if (w == nullptr || w->codebook_iq4nl || (w->code_bits != 2 && w->code_bits != 4 && w->code_bits != 8)) { + err = "verify: token_embd.weight is missing or not an S2/S4/S8 tensor"; + return false; + } + const auto* codes = (const uint8_t*) w->data; + const auto* scales = (const float*) (codes + w->codes_bytes); + const auto* offsets = w->has_offset ? (const float*) (codes + w->codes_bytes + w->scales_bytes) : nullptr; + const uint64_t row_codes = (uint64_t) (w->ne0 / (8 / w->code_bits)); + const uint64_t row_groups = (uint64_t) (w->ne0 / w->group_elems); + embedding_gather_dev(codes, scales, offsets, tok_, T, w->ne0, w->code_bits, w->code_bias, w->group_elems, + row_codes, row_groups, emb_, cs); + broadcast_streams(emb_, R_, N, (int) HC, T, cs); + } + + // per-layer state indices (GDN and QSA layers are numbered separately) + std::vector gdn_idx((size_t) g.n_layers, -1), qsa_idx((size_t) g.n_layers, -1); + { + int64_t qi = 0, gi = 0; + for (int64_t l = 0; l < g.n_layers; ++l) { + if (is_qsa_layer(g, l)) qsa_idx[(size_t) l] = qi++; + else gdn_idx[(size_t) l] = gi++; + } + } + + // ---------------------------------------------------------------- pre(l, group): up to the ring + auto pre = [&](int64_t l, int grp) -> bool { + const int tb = tb_[grp], te = te_[grp], n = te - tb; + const LayerView v(wt, l); + const char* pfx[2] = {"hc_attn_", "hc_ffn_"}; + const WeightRef *wn[2], *wd[2], *wu[2], *wi[2]; + for (int h = 0; h < 2; ++h) { + wn[h] = need(v, (std::string(pfx[h]) + "norm.weight").c_str(), err); + wd[h] = need(v, (std::string(pfx[h]) + "down.weight").c_str(), err); + wu[h] = need(v, (std::string(pfx[h]) + "up.weight").c_str(), err); + wi[h] = need(v, (std::string(pfx[h]) + "inject.weight").c_str(), err); + if (!wn[h] || !wd[h] || !wu[h] || !wi[h]) return false; + } + bool pending = l > 0; // the previous layer's FFN write, folded into this layer's first read + if (l == 1 && ple_on) { + float* normalized = (float*) ((uint8_t*) ss.ple.scratch + ple_block_scratch_bytes()); + for (int t = tb; t < te; ++t) { + gr_write(Rt(t), bo_ + t * N, inj2_ + t * HC, gs, Rt(t), cs); + PleOut po; + po.normalized = normalized; + po.result = Rt(t); + try { + ple_block(ple_ + t * N, Rt(t), ss.ple.hist, ss.ple.w, po, ss.ple.scratch, cs); + ple_history_advance(ss.ple.hist, normalized, cs); + } catch (const std::exception& e) { + err = std::string("verify PLE: ") + e.what(); + return false; + } + copy_from_mapped(hist_snap_ + (size_t) t * HS, ss.ple.hist, HS, cs); + } + pending = false; + } + auto gr_read_group = [&](int half, bool apply, float* inj_prev, float* inj_out) { + FusedGrArgs fa[kFusedGrMaxT]; + for (int t = tb; t < te; ++t) { + FusedGrArgs& a = fa[t - tb]; + a.R = Rt(t); a.R_out = Rt(t); a.apply = apply; + a.bo_prev = bo_ + t * N; a.inj_prev = inj_prev + t * HC; + a.w_norm = (const float*) wn[half]->data; a.w_down = (const uint16_t*) wd[half]->data; + a.w_up = (const uint16_t*) wu[half]->data; a.w_inject = (const uint16_t*) wi[half]->data; + a.eps = EPS; a.lo = lo_ + t * g.hc_lr; a.rs = rs_ + t * HC; + a.inject_out = inj_out + t * HC; a.mixed = mixed_ + t * N; + } + fused_gr_read_multi(fa, n, xn_ + (size_t) tb * HC * N, cs); + }; + gr_read_group(0, pending, inj2_, inj_); + float* xm = mixed_ + tb * N; + try { + if (!is_qsa_layer(g, l)) { + // ======================= GDN ======================= + const WeightRef *wqkv = need(v, "attn_qkv.weight", err), *wg = need(v, "attn_gate.weight", err), + *wout = need(v, "ssm_out.weight", err), *wa = need(v, "ssm_alpha.weight", err), + *wb = need(v, "ssm_beta.weight", err), *wc = need(v, "ssm_conv1d.weight", err), + *wnm = need(v, "ssm_norm.weight", err), *wdt = need(v, "ssm_dt.bias", err), + *wsa = need(v, "ssm_a", err); + if (!wqkv || !wg || !wout || !wa || !wb || !wc || !wnm || !wdt || !wsa) return false; + if (!native_of(wqkv, v.name("attn_qkv.weight"), err) || !native_of(wg, v.name("attn_gate.weight"), err) || + !native_of(wout, v.name("ssm_out.weight"), err)) + return false; + const int64_t gi = gdn_idx[(size_t) l]; + float* state = ss.gdn_state + (size_t) gi * gdn_floats; + float* conv = state + (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size; + float* qkv = qkv_L_ + (size_t) gi * MT * C; + float* hb = h_L_ + (size_t) gi * MT * C; + float* gate = gate_L_ + (size_t) gi * MT * HV; + float* beta = beta_L_ + (size_t) gi * MT * HV; + native_quantize_q8_1(xm, xq_, (int) N, n, cs); + native_mmvq(wqkv->native_type, wqkv->native_data, xq_, qkv + (size_t) tb * C, (int) N, (int) C, n, cs); + gdn_conv_l2_multi(conv, qkv, (const float*) wc->data, hb, (int) C, (int) (2 * HK), EPS, n, cs, tb); + gdn_ab_multi(xm, (const uint16_t*) wa->data, (const uint16_t*) wb->data, (const float*) wdt->data, + (const float*) wsa->data, gate + (size_t) tb * HV, beta + (size_t) tb * HV, (int) N, (int) HV, + n, cs); + native_mmvq(wg->native_type, wg->native_data, xq_, z_ + (size_t) tb * ZV, (int) N, (int) ZV, n, cs); + // the recurrence from the untouched state over tokens [0, te); outputs only for this group's + gdn_step_norm_multi(state, hb, (int) C, gate, beta, z_, (const float*) wnm->data, EPS, y_, (int) HK, + (int) HV, te, nullptr, cs, tb); + native_quantize_q8_1(y_ + (size_t) tb * ZV, xq_, (int) ZV, n, cs); + native_mmvq(wout->native_type, wout->native_data, xq_, bo_ + tb * N, (int) ZV, (int) N, n, cs); + } else { + // ======================= QSA ======================= + const int64_t qi = qsa_idx[(size_t) l]; + const QsaState& st = ss.qsa_states[qi]; + const WeightRef *wik = need(v, "indexer.k_proj.weight", err), *wq = need(v, "attn_q.weight", err), + *wk = need(v, "attn_k.weight", err), *wv = need(v, "attn_v.weight", err), + *wo = need(v, "attn_output.weight", err), *wiq = need(v, "indexer.q_proj.weight", err), + *wqn = need(v, "attn_q_norm.weight", err), *wkn = need(v, "attn_k_norm.weight", err), + *wiqn = need(v, "indexer.q_norm.weight", err), *wikn = need(v, "indexer.k_norm.weight", err); + if (!wik || !wq || !wk || !wv || !wo || !wiq || !wqn || !wkn || !wiqn || !wikn) return false; + if (!native_of(wq, v.name("attn_q.weight"), err) || !native_of(wk, v.name("attn_k.weight"), err) || + !native_of(wv, v.name("attn_v.weight"), err) || !native_of(wo, v.name("attn_output.weight"), err)) + return false; + auto norm_rope = [&](float* data, const WeightRef* norm, int rows, int cols, const int32_t* pos) { + if (native_qsa_enabled()) native_qsa_rms_norm_weighted(data, (const float*) norm->data, data, cols, rows, EPS, cs); + else rms_norm_weighted(data, (const float*) norm->data, rows, cols, EPS, cs); + if (native_rope_enabled()) native_rope_apply(data, data, rows, cols, (int) s.n_rot, (float) qsa_freq_base(), pos, cs); + else rope_neox_apply(data, data, rows, cols, (int) s.n_rot, st.cos_tab, st.sin_tab, pos, cs); + }; + float* idx_raw = idx_raw_L_ + (size_t) qi * MT * ID; + native_quantize_q8_1(xm, xq_, (int) N, n, cs); + for (int t = tb; t < te; ++t) + bf16_gemv_fp32_mmvf(mixed_ + t * N, (const uint16_t*) wik->data, idx_raw + t * ID, (int) N, (int) ID, cs); + native_mmvq(wk->native_type, wk->native_data, xq_, kcur_ + tb * NKV * HD, (int) N, (int) (NKV * HD), n, cs); + native_mmvq(wv->native_type, wv->native_data, xq_, vcur_ + tb * NKV * HD, (int) N, (int) (NKV * HD), n, cs); + for (int t = tb; t < te; ++t) norm_rope(kcur_ + t * NKV * HD, wkn, (int) NKV, (int) HD, pos_ + t * NH); + if (grp == 0) copy_from_mapped(tail_snap_ + (size_t) qi * TS, st.idx_tail, TS, cs); + for (int t = tb; t < te; ++t) { + const int32_t* step_t = step_ + t * kStepCount; + if (st.kv_int8) + kv_append_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, step_t, + kcur_ + t * NKV * HD, vcur_ + t * NKV * HD, s, cs); + else + kv_append_step(st.k_pool, st.v_pool, st.page_table, step_t, kcur_ + t * NKV * HD, + vcur_ + t * NKV * HD, s, cs); + } + const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos}; + for (int t = tb; t < te; ++t) + native_qsa_indexer_append(idx_raw + t * ID, step_ + t * kStepCount + kStepPos, 0, + (const float*) wikn->data, EPS, ib, s, st.max_cells, + (float) qsa_freq_base(), cs); + native_mmvq(wq->native_type, wq->native_data, xq_, qfull_ + tb * NH * 2 * HD, (int) N, (int) (NH * 2 * HD), + n, cs); + for (int t = tb; t < te; ++t) { + float* qc = qcur_ + t * NH * HD; + if (cudaMemcpy2DAsync(qc, (size_t) HD * 4, qfull_ + t * NH * 2 * HD, (size_t) HD * 2 * 4, + (size_t) HD * 4, (size_t) NH, cudaMemcpyDeviceToDevice, cs) != cudaSuccess) { + err = "verify: the q/gate split failed"; + return false; + } + norm_rope(qc, wqn, (int) NH, (int) HD, pos_ + t * NH); + } + for (int t = tb; t < te; ++t) { + float* qx = qidx_ + t * IQ * ID; + bf16_gemv_fp32_mmvf(mixed_ + t * N, (const uint16_t*) wiq->data, qx, (int) N, (int) (IQ * ID), cs); + norm_rope(qx, wiqn, (int) IQ, (int) ID, pos_ + t * NH); + } + qsa_block_scores(st.idx_pooled, st.idx_dead, qidx_ + tb * IQ * ID, step_ + tb * kStepCount, n, max_blocks_, + s, scores_ + (size_t) tb * max_blocks_, cs); + qsa_block_topk(scores_ + (size_t) tb * max_blocks_, step_ + tb * kStepCount, n, max_blocks_, cap_, s, + sel_ + (size_t) tb * cap_, cs); + QsaAttnPools pools; + pools.page_table = st.page_table; + if (st.kv_int8) { pools.k_q = st.k_q; pools.v_q = st.v_q; pools.k_scale = st.k_scale; pools.v_scale = st.v_scale; } + else { pools.k_pool = st.k_pool; pools.v_pool = st.v_pool; } + qsa_decode_attn_batch(qcur_ + tb * NH * HD, pools, sel_ + (size_t) tb * cap_, step_ + tb * kStepCount, cap_, + s, attn_scratch_ + (size_t) tb * attn_scratch_floats_, attn_ + tb * NH * HD, n, cs); + for (int t = tb; t < te; ++t) { + if (native_qsa_enabled()) + native_qsa_gate_apply(attn_ + t * NH * HD, qfull_ + t * NH * 2 * HD, attn32_ + t * NH * HD, + (int) NH, (int) HD, cs); + else + qsa_gate_apply_f32(attn_ + t * NH * HD, qfull_ + t * NH * 2 * HD, s, attn32_ + t * NH * HD, cs); + } + native_quantize_q8_1(attn32_ + tb * NH * HD, xq_, (int) (NH * HD), n, cs); + native_mmvq(wo->native_type, wo->native_data, xq_, bo_ + tb * N, (int) (NH * HD), (int) N, n, cs); + } + } catch (const std::exception& e) { + err = "verify layer " + std::to_string(l) + ": " + e.what(); + return false; + } + gr_read_group(1, true, inj_, inj2_); + for (int t = tb; t < te; ++t) { + MoEBuffers mb = ss.moe; + mb.logits = logits_ + t * NE; mb.ids = ids_ + t * K; mb.weights = w_ + t * K; + if (!moe_route(wt, g, l, K, mb, mixed_ + t * N, cs, err, nullptr)) return false; + } + doorbell_publish(xm, ids_ + tb * K, w_ + tb * K, (int64_t) n * N, (int64_t) n * K, m_x_ + tb * N, + m_ids_ + tb * K, m_w_ + tb * K, m_seq_, cs); + { + const WeightRef *wgi = need(v, "ffn_gate_inp_shexp.weight", err), *wsg = need(v, "ffn_gate_shexp.weight", err), + *wsu = need(v, "ffn_up_shexp.weight", err), *wsd = need(v, "ffn_down_shexp.weight", err); + if (!wgi || !wsg || !wsu || !wsd) return false; + if (!native_of(wsg, v.name("ffn_gate_shexp.weight"), err) || !native_of(wsu, v.name("ffn_up_shexp.weight"), err) || + !native_of(wsd, v.name("ffn_down_shexp.weight"), err)) + return false; + NativeSharedWeights nsw; + nsw.gate_type = wsg->native_type; nsw.gate_data = wsg->native_data; + nsw.up_type = wsu->native_type; nsw.up_data = wsu->native_data; + nsw.down_type = wsd->native_type; nsw.down_data = wsd->native_data; + nsw.q8_1 = xq_; + for (int t = tb; t < te; ++t) f32_to_bf16_bulk(mixed_ + t * N, sh_bf16_ + t * N, N, cs); + try { + shared_expert_multi(n, xm, sh_bf16_ + tb * N, nsw, (const uint16_t*) wgi->data, sh_gate_ + (size_t) tb * g.n_ff, + sh_up_ + (size_t) tb * g.n_ff, sh_g_ + tb, shared_ + tb * N, N, g.n_ff, cs); + } catch (const std::exception& e) { + err = std::string("verify shared expert: ") + e.what(); + return false; + } + } + if (strata::kernels::cpu::expert_layout().native) + quantize_q8_1_rows(xm, n, N, nat_xq_ + (size_t) tb * (N / 32) * 36, cs); + else + quantize_q8_0_scaled(xm, hit_xq_ + (size_t) tb * (N / 32) * 34, hit_xs_ + (size_t) tb * (N / 32), (int64_t) n * N, cs); + return true; + }; + + // ---------------------------------------------------------------- post(l, group): experts, combine + auto post = [&](int64_t l, int grp) -> bool { + const int tb = tb_[grp], te = te_[grp], n = te - tb; + const uint32_t ring = (uint32_t) (l * G + grp + 1); + wait_flag_ge(m_flagA_, ring, cs); // the pool published this group's GPU plan + const int64_t cap = (int64_t) n * K, capx = (int64_t) max_t_ * K; + int32_t* pl = plan_ + (size_t) grp * (size_t) (plan_i32_ + 16); + copy_i32_from_mapped(pl, m_plan_ + (size_t) grp * (size_t) plan_i32_, plan_i32_, cs); + const int32_t* p_counts = pl; + const int32_t* p_start = pl + 4; + const int32_t* p_dst = p_start + capx + 1; + const int32_t* p_tok = p_dst + capx; + const int64_t ptr_off = ((4 + (capx + 1) + 2 * capx) + 1) & ~1ll; + const unsigned long long* p_ptr = (const unsigned long long*) (pl + ptr_off); + const unsigned long long* p_ptr2 = p_ptr + capx; + const int32_t* p_start2 = pl + ptr_off + 4 * capx; + float* hit_out = hit_out_ + (size_t) tb * K * N; + const auto& lay = strata::kernels::cpu::expert_layout(); + // plan v0.3 P6: the VRAM groups now; the PCIe groups once the copy engine has landed them in staging + auto grouped = [&](const unsigned long long* gp, const int32_t* gs, const int32_t* gn) { + if (lay.native) { + // the layer's GGUF formats (i-quant gate/up, Q2_0 / IQ4_NL down) + const auto& f = lay.fmt[(size_t) l]; + const NativeExpertLayout L = native_expert_layout(f.gu_type, f.d_type, f.n_embd, f.n_ff); + native_expert_grouped(L, gp, gs, gn, p_dst, p_tok, cap, cap, + nat_xq_ + (size_t) tb * (N / 32) * 36, hit_scratch_, hit_out, cs); + } else { + moe_grouped_s2(gp, gs, gn, p_dst, p_tok, cap, cap, hit_xq_ + (size_t) tb * (N / 32) * 34, + hit_xs_ + (size_t) tb * (N / 32), hit_scratch_, hit_out, cs); + } + }; + grouped(p_ptr, p_start, p_counts); + wait_flag_ge(m_flagB_, ring, cs); // the PCIe share is in staging (DMA) or mapped + if (sink_.pcie_mode == 2) { // stage it with a copy kernel, then point at staging + const int64_t per = G == 2 ? kStagingBlobs / 2 : kStagingBlobs; + uint8_t* stage = staging_ + (size_t) (grp * per) * lay.max_blob; + fetch_blobs(p_ptr2, p_counts + 2, stage, (int64_t) lay.blob_bytes(l), (int) per, cs); + rebase_ptrs((unsigned long long*) p_ptr2, p_counts + 2, stage, (int64_t) lay.blob_bytes(l), cs); + } + grouped(p_ptr2, p_start2, p_counts + 2); + wait_flag_ge(m_flag_, ring, cs); // the CPU's share is in the mapped rows + copy_from_mapped(parts_ + (size_t) tb * K * N, m_ymiss_ + (size_t) tb * K * N, (int64_t) n * K * N, cs); + moe_hit_add(parts_ + (size_t) tb * K * N, hit_out, p_dst, p_counts + 1, cap, N, cs); + for (int t = tb; t < te; ++t) { + MoEBuffers mb = ss.moe; + mb.weights = w_ + t * K; mb.shared = shared_ + t * N; + if (!moe_combine_parts(g, l, K, mb, parts_ + (size_t) t * K * N, bo_ + t * N, cs, err)) return false; + } + if (l == g.n_layers - 1) + for (int t = tb; t < te; ++t) gr_write(Rt(t), bo_ + t * N, inj2_ + t * HC, gs, Rt(t), cs); + return true; + }; + + for (int grp = 0; grp < G; ++grp) + if (!pre(0, grp)) return false; + for (int64_t l = 0; l < g.n_layers; ++l) + for (int grp = 0; grp < G; ++grp) { + if (!post(l, grp)) return false; + if (l + 1 < g.n_layers && !pre(l + 1, grp)) return false; + } + + // ---- the head, T columns, and the argmax of each + { + const WeightRef *hn = wt.find("output_hc_norm.weight"), *hd = wt.find("output_hc_down.weight"), + *hu = wt.find("output_hc_up.weight"); + if (!hn || !hd || !hu) { err = "verify: an output_hc_* weight is missing"; return false; } + for (int t = 0; t < T; ++t) { + BlockBuffers bb = ss.block; + bb.R = Rt(t); + bb.mixed = head_mixed_ + t * N; + if (head_ != nullptr && head_->loaded()) { + if (!lm_head_mix(wt, g, bb, cs, err)) return false; + } else if (!lm_head(wt, g, bb, head_logits_ + (size_t) t * n_vocab_, cs, err)) { + return false; + } + } + if (head_ != nullptr && head_->loaded()) { + try { + native_quantize_q8_1(head_mixed_, xq_, (int) N, T, cs); + native_mmvq(head_->type(), head_->weights(), xq_, head_logits_, (int) N, (int) n_vocab_, T, cs); + } catch (const std::exception& e) { + err = std::string("verify head: ") + e.what(); + return false; + } + } + SamplerParams sp; + sp.greedy = true; + sp.temperature = 0.0f; + sample_tokens(head_logits_, T, (int) n_vocab_, nullptr, 0, sp, m_out_, cs); + } + return true; +} + +bool Verifier::capture(int T, std::string& err) { + if (exec_[T] != nullptr) return true; + if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { + err = "verify: begin capture failed"; + return false; + } + std::string rerr; + const bool ok = record_window(T, cs_, rerr); + cudaGraph_t graph = nullptr; + const cudaError_t ce = cudaStreamEndCapture(cs_, &graph); + if (!ok) { + if (graph) cudaGraphDestroy(graph); + err = rerr; + return false; + } + if (ce != cudaSuccess) { + err = std::string("verify: end capture: ") + cudaGetErrorString(ce); + return false; + } + const cudaError_t ie = cudaGraphInstantiate(&exec_[T], graph, 0); + cudaGraphDestroy(graph); + if (ie != cudaSuccess) { + err = std::string("verify: instantiate: ") + cudaGetErrorString(ie); + return false; + } + const cudaError_t ue = cudaGraphUpload(exec_[T], cs_); + const cudaError_t us = cudaStreamSynchronize(cs_); + std::fprintf(stderr, "strata verify: captured the %d-token window (upload %s, sync %s)\n", T, + cudaGetErrorString(ue), cudaGetErrorString(us)); + return true; +} + +bool Verifier::capture_commit(std::string& err) { + if (commit_exec_ != nullptr) return true; + using namespace strata::kernels; + const ModelGeometry& g = *g_; + SessionState& ss = *ss_; + const QsaShapes s = shapes_of(g); + const int64_t C = g.ssm_conv_channels, HV = g.ssm_v_heads, ID = g.idx_key_dim, MT = max_t_; + const uint64_t gdn_floats = (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size + + (uint64_t) g.ssm_conv_channels * (g.ssm_d_conv - 1); + const int64_t TS = (s.idx_block - 1) * ID; + const int64_t HS = (int64_t) NG_HIST * NG_HC_DIM; + if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { + err = "verify: begin commit capture failed"; + return false; + } + bool ok = true; + try { + copy_i32_from_mapped(commit_, m_commit_, 2 + MT, cs_); + int64_t qsa_index = 0, gdn_index = 0; + for (int64_t l = 0; l < g.n_layers && ok; ++l) { + const LayerView v(*wt_, l); + if (!is_qsa_layer(g, l)) { + const WeightRef* wnm = need(v, "ssm_norm.weight", err); + if (!wnm) { ok = false; break; } + float* state = ss.gdn_state + (size_t) gdn_index * gdn_floats; + float* conv = state + (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size; + const float* qkv = qkv_L_ + (size_t) gdn_index * MT * C; + gdn_conv_commit(conv, qkv, (int) C, commit_, cs_); + gdn_step_norm_multi(state, h_L_ + (size_t) gdn_index * MT * C, (int) C, gate_L_ + (size_t) gdn_index * MT * HV, + beta_L_ + (size_t) gdn_index * MT * HV, z_, (const float*) wnm->data, EPS, y_dummy_, + (int) g.ssm_k_heads, (int) HV, (int) MT, commit_, cs_); + ++gdn_index; + } else { + const QsaState& st = ss.qsa_states[qsa_index]; + const WeightRef* wikn = need(v, "indexer.k_norm.weight", err); + if (!wikn) { ok = false; break; } + copy_from_mapped(st.idx_tail, tail_snap_ + (size_t) qsa_index * TS, TS, cs_); + const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos}; + for (int64_t t = 0; t < MT; ++t) + native_qsa_indexer_append(idx_raw_L_ + (size_t) (qsa_index * MT + t) * ID, commit_ + 2 + t, 0, + (const float*) wikn->data, EPS, ib, s, st.max_cells, + (float) qsa_freq_base(), cs_); + ++qsa_index; + } + } + if (ok && ss.ple.ready()) copy_indexed(ss.ple.hist, hist_snap_, HS, commit_ + 1, HS, cs_); + } catch (const std::exception& e) { + err = std::string("verify commit: ") + e.what(); + ok = false; + } + cudaGraph_t graph = nullptr; + const cudaError_t ce = cudaStreamEndCapture(cs_, &graph); + if (!ok) { + if (graph) cudaGraphDestroy(graph); + return false; + } + if (ce != cudaSuccess || cudaGraphInstantiate(&commit_exec_, graph, 0) != cudaSuccess) { + if (graph) cudaGraphDestroy(graph); + err = std::string("verify: commit capture: ") + cudaGetErrorString(ce); + return false; + } + cudaGraphDestroy(graph); + return true; +} + +bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool, void* user, int32_t* out, + std::string& err) { + using namespace strata::kernels; + if (T < 1 || T > max_t_) { err = "verify: window size out of range"; return false; } + const ModelGeometry& g = *g_; + SessionState& ss = *ss_; + if (pos0 + T > ss.qsa_states[0].max_cells) { err = "verify: the window runs past the context"; return false; } + if (!capture(T, err) || !capture_commit(err)) return false; + VDBG("captured; staging\n"); + const Clock::time_point t0 = Clock::now(); + const QsaShapes s = shapes_of(g); + for (int t = 0; t < T; ++t) { + h_tok_[t] = tokens[t]; + qsa_step_fill(h_step_ + t * kStepCount, pos0 + t, s); + for (int64_t h = 0; h < g.n_head; ++h) h_pos_[t * g.n_head + h] = (int32_t) (pos0 + t); + } + if (ss.ple.ready()) { + uint32_t rows[kVerifyMaxT * PLE_N_HEADS]; + int32_t prev[2] = {ss.ple_prev[0], ss.ple_prev[1]}; + for (int t = 0; t < T; ++t) { + ngram_rows(&tokens[t], prev, 1, ss.ple.consts, rows + t * PLE_N_HEADS); + prev[0] = prev[1]; + prev[1] = tokens[t]; + } + if (!ss.ple.table->gather_batch(rows, (size_t) T, h_ple_, err)) return false; + } + *(volatile uint32_t*) h_seq_ = 0; + *(volatile uint32_t*) h_flag_ = 0; + *(volatile uint32_t*) h_flagA_ = 0; + *(volatile uint32_t*) h_flagB_ = 0; + std::atomic_thread_fence(std::memory_order_seq_cst); + last_t_ = T; + last_pos0_ = pos0; + for (int t = 0; t < T; ++t) last_tokens_[t] = tokens[t]; + ms_host += ms_since(t0); + VDBG("staged; launching\n"); + const cudaError_t le = cudaGraphLaunch(exec_[T], cs_); + if (le != cudaSuccess) { err = std::string("verify: launch: ") + cudaGetErrorString(le); return false; } + (void) cudaStreamQuery(cs_); + VDBG("launched\n"); + volatile uint32_t* const seq = h_seq_; + volatile uint32_t* const flag = h_flag_; + const int G = groups_[T] > 0 ? groups_[T] : 1; + const int gtb[2] = {0, (T + 1) / 2}, gte[2] = {G == 2 ? (T + 1) / 2 : T, T}; + for (int64_t k = 0; k < g.n_layers * G; ++k) { + const int64_t l = k / G; + const int grp = (int) (k % G); + const uint32_t want = (uint32_t) (k + 1); + const Clock::time_point a = Clock::now(); + auto last_flush = a; + uint32_t spins = 0; + while (*seq < want) { + _mm_pause(); + if ((++spins & 1023u) != 0) continue; + const auto now = Clock::now(); + if (now - last_flush > std::chrono::microseconds(2000)) { + last_flush = now; + const cudaError_t q = cudaStreamQuery(cs_); + if (q != cudaErrorNotReady && *seq < want) { + err = "verify: layer " + std::to_string(l) + " never rang (" + + (q == cudaSuccess ? std::string("graph finished") : std::string(cudaGetErrorString(q))) + ")"; + return false; + } + } + if (now - a > std::chrono::seconds(20)) { err = "verify: timed out at layer " + std::to_string(l); return false; } + } + const Clock::time_point b = Clock::now(); + VDBG("layer %lld rang\n", (long long) l); + cur_layer_ = want - 1; + set_plan_slot(grp); + const int tb = gtb[grp], n = gte[grp] - gtb[grp]; + if (pool != nullptr) + pool(user, h_x_ + (size_t) tb * g.n_embd, h_ids_ + (size_t) tb * ss.k, n, ss.k, + h_ymiss_ + (size_t) tb * ss.k * g.n_embd, l); + VDBG("layer %lld served\n", (long long) l); + std::atomic_thread_fence(std::memory_order_seq_cst); + _mm_sfence(); + if (*(volatile uint32_t*) h_flagA_ != want) { // the pool did not publish a plan: an empty one + sink_.counts[0] = 0; + sink_.counts[1] = 0; + sink_.counts[2] = 0; + sink_.start[0] = 0; + sink_.start2[0] = 0; + std::atomic_thread_fence(std::memory_order_seq_cst); + *(volatile uint32_t*) h_flagA_ = want; + raise_flag(h_flagB_, want); + } + *flag = want; + ms_wait += std::chrono::duration(b - a).count(); + ms_pool += ms_since(b); + } + const cudaError_t se = cudaStreamSynchronize(cs_); + if (se != cudaSuccess) { err = std::string("verify: ") + cudaGetErrorString(se); return false; } + cudaStreamSynchronize(copy_); // no host function of this window may raise flag B in the next one + for (int t = 0; t < T; ++t) out[t] = ((volatile int32_t*) h_out_)[t]; + VDBG("window done\n"); + ++windows; + return true; +} + +void Verifier::set_plan_slot(int grp) { + const int64_t cap = sink_.cap; + int32_t* base = h_plan_ + (size_t) grp * (size_t) plan_i32_; + const int64_t i32 = 4 + (cap + 1) + cap + cap; + const int64_t ptr_off = (i32 + 1) & ~1ll; + sink_.counts = base; + sink_.start = base + 4; + sink_.dst = sink_.start + cap + 1; + sink_.tok = sink_.dst + cap; + sink_.ptr = (unsigned long long*) (base + ptr_off); + sink_.ptr2 = sink_.ptr + cap; + sink_.start2 = base + ptr_off + 4 * cap; + const int G = groups_[last_t_] > 0 ? groups_[last_t_] : 1; + const int64_t per = G == 2 ? kStagingBlobs / 2 : kStagingBlobs; + sink_.staging = (unsigned long long) (staging_ + (size_t) (grp * per) * strata::kernels::cpu::expert_layout().max_blob); + sink_.staging_cap = per; +} + +// Flag B only rises: a host function of an earlier layer may run after a later layer already raised it directly. +void Verifier::raise_flag(uint32_t* flag, uint32_t value) { + volatile long* f = (volatile long*) flag; +#if defined(_WIN32) + long cur = *f; + while ((uint32_t) cur < value) { + const long prev = _InterlockedCompareExchange(f, (long) value, cur); + if (prev == cur) break; + cur = prev; + } +#else + uint32_t cur = __atomic_load_n((uint32_t*) flag, __ATOMIC_SEQ_CST); + while (cur < value && !__atomic_compare_exchange_n((uint32_t*) flag, &cur, value, false, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST)) {} +#endif +} + +// Plan v0.3 P6: the PCIe share by DMA. The copy engine moves the blobs while the CPU computes its own share and the +// GPU its VRAM experts; a host function raises flag B when they have landed (the graph waits for it before the PCIe +// groups). Staging is split between the two token groups of a split window. +void Verifier::fetch_dma(void* ctx, const uint8_t* const* src, int n, size_t bytes) { + Verifier* v = (Verifier*) ctx; + const uint32_t want = v->cur_layer_ + 1; + if (n <= 0) { raise_flag(v->h_flagB_, want); return; } + uint8_t* stage = (uint8_t*) v->sink_.staging; // this group's half in a split window + for (int i = 0; i < n; ++i) cudaMemcpyAsync(stage + (size_t) i * bytes, src[i], bytes, cudaMemcpyHostToDevice, v->copy_); + FlagSet& fs = v->flag_sets_[v->cur_layer_ % (sizeof v->flag_sets_ / sizeof v->flag_sets_[0])]; + fs.flag = v->h_flagB_; + fs.value = want; + cudaLaunchHostFunc(v->copy_, [](void* p) { FlagSet* s = (FlagSet*) p; raise_flag(s->flag, s->value); }, &fs); +} + +void Verifier::publish_plan(void* ctx) { + Verifier* v = (Verifier*) ctx; + _mm_sfence(); + *(volatile uint32_t*) v->h_flagA_ = v->cur_layer_ + 1; +} + +bool Verifier::commit(int n_keep, std::string& err) { + if (n_keep < 1 || n_keep > last_t_) { err = "verify: commit count out of range"; return false; } + const Clock::time_point t0 = Clock::now(); + h_commit_[0] = n_keep; + h_commit_[1] = n_keep - 1; + for (int t = 0; t < max_t_; ++t) h_commit_[2 + t] = t < n_keep ? (int32_t) (last_pos0_ + t) : -1; + std::atomic_thread_fence(std::memory_order_seq_cst); + const cudaError_t le = cudaGraphLaunch(commit_exec_, cs_); + if (le != cudaSuccess) { err = std::string("verify: commit launch: ") + cudaGetErrorString(le); return false; } + const cudaError_t se = cudaStreamSynchronize(cs_); + if (se != cudaSuccess) { err = std::string("verify: commit: ") + cudaGetErrorString(se); return false; } + for (int t = 0; t < n_keep; ++t) { + ss_->ple_prev[0] = ss_->ple_prev[1]; + ss_->ple_prev[1] = last_tokens_[t]; + } + ms_commit += ms_since(t0); + return true; +} + +} // namespace strata::core diff --git a/src/core/weights.cpp b/src/core/weights.cpp new file mode 100644 index 00000000..a77f3c64 --- /dev/null +++ b/src/core/weights.cpp @@ -0,0 +1,456 @@ +// src/core/weights.cpp - the dense-weight loader. See the header for the engine-vs-pack distinction. +#include "strata/core/weights.hpp" + +#include "strata/kernels/f16_bits.hpp" + +#include + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +namespace strata::core { +namespace { + +constexpr uint64_t CHUNK = 8ull << 20; // 8 MiB of SOURCE per staging round + +double now_ms() { + using namespace std::chrono; + return duration(steady_clock::now().time_since_epoch()).count(); +} + +struct IndexRow { + std::string name; + int file = 0; + WeightKind kind = WeightKind::Verbatim; + uint64_t src_off = 0, src_bytes = 0, dst_off = 0, dst_bytes = 0; + int64_t ne0 = 0, ne1 = 0; + int code_bits = 0, code_bias = 0, group_elems = 0, codebook = 0, has_offset = 0; + uint64_t codes_bytes = 0, scales_bytes = 0, offset_bytes = 0; + int scales_fp16 = 0; + int act_kind = 0; + bool raw16 = false; ///< plan v0.3 P6: already 16-bit in the file (kinds 4/5) +}; + +/// How a segment's bytes must be transformed on the way from the pack to the arena. +enum class Conv : int { + Copy = 0, ///< the bytes are already the engine form + Bf16High = 1, ///< f32 container holding a bf16 value: take the high 16 bits + ToF16 = 2, ///< f32 container holding an f16 value: a real round-to-nearest-even conversion + WidenF16 = 3, ///< fp16 bits to be held as f32: EXACT, and the reason this path exists at all +}; + +/// One contiguous run of a tensor's source span with a single destination range and one transform. +/// +/// THE SEGMENT LIST IS WHY THIS LOADER CAN CHANGE WIDTHS PLANE BY PLANE. A quantized tensor used to be one +/// verbatim span, which is only correct when every plane is already in engine form - and for 90 of the 303 +/// quantized tensors the scale plane is fp16 in the pack and must be f32 in the arena. +struct Seg { + uint64_t src_off = 0; ///< relative to the row's src_off + uint64_t src_bytes = 0; + uint64_t dst_off = 0; ///< relative to the row's dst_off + uint64_t dst_bytes = 0; + Conv conv = Conv::Copy; +}; + +const char* file_name(int id) { + switch (id) { + case 0: return "dense.bin"; + case 1: return "embd.bin"; + case 2: return "experts.bin"; + case 3: return "extra.bin"; // plan v0.3 P6: a native pack's float tensors that the base pack quantized + default: return nullptr; + } +} + +/// UTF-16 path for CreateFile-free portability: the loader uses `std::fopen`, which on Windows takes an ANSI +/// path. The pack lives beside the executable in practice, but a pack under a user directory with a +/// non-ASCII name would silently fail to open, so the caller gets the errno rather than a null pointer. +bool read_at(std::FILE* f, uint64_t off, void* dst, size_t n, std::string& err, const char* what) { +#if defined(_WIN32) + if (_fseeki64(f, (long long) off, SEEK_SET) != 0) { err = std::string(what) + ": seek failed"; return false; } +#else + if (fseeko(f, (off_t) off, SEEK_SET) != 0) { err = std::string(what) + ": seek failed"; return false; } +#endif + if (std::fread(dst, 1, n, f) != n) { err = std::string(what) + ": short read"; return false; } + return true; +} + +} // namespace + +bool WeightTable::pool_bytes(const std::string& pack_dir, uint64_t& out, std::string& err, + const std::set* skip) { + const std::string path = pack_dir + "/index.txt"; + std::FILE* f = std::fopen(path.c_str(), "rb"); + if (!f) { err = "cannot open " + path; return false; } + char line[1024]; + out = 0; + uint64_t pool = 0, compact = 0; + int align = 0; + while (std::fgets(line, sizeof line, f)) { + if (line[0] == '#') { + unsigned long long p = 0; + int a = 0, tensors = 0; + if (std::sscanf(line, "# align %d pool %llu tensors %d", &a, &p, &tensors) == 3) { pool = p; align = a; } + continue; + } + if (skip == nullptr) continue; + char name[256] = {0}; + unsigned long long dst_bytes = 0, dummy = 0; + int i1 = 0, i2 = 0; + if (std::sscanf(line, "%255s %d %d %llu %llu %llu %llu", name, &i1, &i2, &dummy, &dummy, &dummy, &dst_bytes) != 7) + continue; + if (skip->count(name)) continue; + const uint64_t a = align > 0 ? (uint64_t) align : 256; + compact += (dst_bytes + a - 1) / a * a; + } + std::fclose(f); + if (pool == 0) { err = "no '# align ... pool ...' header in " + path; return false; } + out = skip ? compact : pool; + return true; +} + +bool WeightTable::load(const std::string& pack_dir, void* arena_base, uint64_t arena_bytes, std::string& err, + const std::set* skip) { + const std::string path = pack_dir + "/index.txt"; + std::FILE* idx = std::fopen(path.c_str(), "rb"); + if (!idx) { err = "cannot open " + path + " (run tools/pack_index.py)"; return false; } + + uint64_t pool = 0; + int align = 0; + std::vector rows; + char line[1024]; + while (std::fgets(line, sizeof line, idx)) { + if (line[0] == '#') { + unsigned long long p = 0; + int a = 0, t = 0; + if (std::sscanf(line, "# align %d pool %llu tensors %d", &a, &p, &t) == 3) { pool = p; align = a; } + continue; + } + IndexRow r; + char name[256] = {0}; + int kind = 0; + // The format is written by tools/pack_index.py and is space-separated with no quoting; a name with a + // space in it would break the parse, which is why the parser REFUSES rather than taking what it got. + const int n = std::sscanf(line, + "%255s %d %d %llu %llu %llu %llu %lld %lld %d %d %d %d %d %llu %llu %llu %d %d", + name, &r.file, &kind, &r.src_off, &r.src_bytes, &r.dst_off, &r.dst_bytes, + &r.ne0, &r.ne1, &r.code_bits, &r.code_bias, &r.group_elems, &r.codebook, + &r.has_offset, &r.codes_bytes, &r.scales_bytes, &r.offset_bytes, + &r.scales_fp16, &r.act_kind); + if (n != 19) { + // A malformed index line is a WRONG BYTE OFFSET waiting to happen, and a wrong offset decodes to + // a plausible weight. Refuse loudly instead of loading the rows that happened to parse. + err = "index.txt: could not parse a row (" + std::to_string(n) + " of 19 fields)"; + std::fclose(idx); + return false; + } + r.name = name; + // plan v0.3 P6: a standalone native pack (tools/iq_pack.py) stores 16-bit floats as they are in the GGUF: + // index kinds 4 (BF16) and 5 (F16) are copied and take the engine forms of kinds 1 and 3 + if (kind == 4 || kind == 5) { + r.raw16 = true; + kind = kind == 4 ? (int) WeightKind::Bf16InF32 : (int) WeightKind::F16InF32; + } + r.kind = (WeightKind) kind; + rows.push_back(r); + } + std::fclose(idx); + + if (pool == 0 || rows.empty()) { err = "index.txt has no header or no rows"; return false; } + // Plan v0.3 P1: a skip set compacts the arena. Kept rows are re-placed in index order at the index's + // alignment; skipped rows keep their metadata and get no bytes. + std::vector skipped(rows.size(), false); + if (skip != nullptr) { + const uint64_t a = align > 0 ? (uint64_t) align : 256; + uint64_t at = 0; + for (size_t i = 0; i < rows.size(); ++i) { + if (skip->count(rows[i].name)) { skipped[i] = true; continue; } + rows[i].dst_off = at; + at += (rows[i].dst_bytes + a - 1) / a * a; + } + pool = at; + } + if (arena_bytes < pool) { + err = "arena is " + std::to_string(arena_bytes) + " B but the index needs " + std::to_string(pool); + return false; + } + + // Two pinned staging buffers, both reused. Pinned because a 5 GB pageable upload spends its time in the + // driver copying through a bounce buffer, and bounded by CHUNK rather than by the largest tensor. + // + // STAGE_OUT IS NOT OPTIONAL. The first version of this loop converted the values straight into the + // destination pointer - which is DEVICE memory - from host code. A host write to a device address is + // undefined behaviour, and on this driver it does not fault: it corrupts whatever the address maps to in + // the host's own space, which is nothing at all, so the tensor would have loaded as the UNCONVERTED pack + // bytes and every BF16 weight would have been read as 32-bit garbage. Converted data goes to a host + // buffer and crosses the bus once. + // + // STAGE_OUT IS TWICE STAGE_IN because a conversion can now GROW: widening an fp16 scale plane to f32 + // doubles it. Sized at CHUNK it would have written 8 MiB past the end of a pinned allocation for every + // one of the 90 widened tensors - a heap corruption that would have been blamed on whatever ran next. + void* stage_in = nullptr; + void* stage_out = nullptr; + if (cudaHostAlloc(&stage_in, CHUNK, cudaHostAllocDefault) != cudaSuccess || + cudaHostAlloc(&stage_out, CHUNK * 2, cudaHostAllocDefault) != cudaSuccess) { + err = "cudaHostAlloc for the staging buffers failed"; + if (stage_in) cudaFreeHost(stage_in); + if (stage_out) cudaFreeHost(stage_out); + return false; + } + + uint8_t* dst_base = (uint8_t*) arena_base; + std::FILE* cur = nullptr; + int cur_file = -1; + table_.clear(); + report_ = LoadReport{}; + + const double t_read0 = now_ms(); + double upload_ms = 0; + for (size_t row_i = 0; row_i < rows.size(); ++row_i) { + const IndexRow& r = rows[row_i]; + if (skipped[row_i]) { + WeightRef wr; + wr.data = nullptr; + wr.resident = false; + wr.bytes = r.dst_bytes; + wr.ne0 = r.ne0; + wr.ne1 = r.ne1; + wr.elements = r.ne0 * (r.ne1 > 0 ? r.ne1 : 1); + wr.kind = r.kind; + wr.code_bits = r.code_bits; + wr.code_bias = r.code_bias; + wr.group_elems = r.group_elems; + wr.codebook_iq4nl = r.codebook != 0; + wr.has_offset = r.has_offset != 0; + wr.codes_bytes = r.codes_bytes; + wr.scales_bytes = r.scales_bytes; + wr.offset_bytes = r.offset_bytes; + wr.src_off = r.src_off; + wr.src_bytes = r.src_bytes; + wr.file_id = r.file; + wr.scales_fp16 = r.scales_fp16 != 0; + wr.act_kind = r.act_kind; + table_[r.name] = wr; + ++report_.tensors; + continue; + } + if (r.code_bits != 0 && r.dst_bytes == 0) { + // plan v0.3 P6: a native pack's row that carries a shape only - the GGUF form must serve it + err = r.name + ": this pack holds the tensor only in its GGUF form (run with --native SHARD1)"; + cudaFreeHost(stage_in); cudaFreeHost(stage_out); + return false; + } + if (r.file != cur_file) { + if (cur) std::fclose(cur); + const char* fn = file_name(r.file); + const std::string p = pack_dir + "/" + (fn ? fn : "?"); + cur = std::fopen(p.c_str(), "rb"); + if (!cur) { err = "cannot open " + p; cudaFreeHost(stage_in); cudaFreeHost(stage_out); return false; } + cur_file = r.file; + } + + // ---- the segment list: what this tensor's bytes are, plane by plane, in both forms + Seg segs[3]; + int n_segs = 0; + bool bad = false; + uint64_t sum_src = 0, sum_dst = 0; + if (r.codes_bytes != 0) { + // A quantized tensor: codes, then scales, then the optional offset plane, each contiguous in the + // source span in that order (pack_index.py verifies the contiguity and refuses otherwise). + segs[n_segs++] = Seg{0, r.codes_bytes, 0, r.codes_bytes, Conv::Copy}; + if (r.scales_bytes) { + const uint64_t src_scale_bytes = r.scales_fp16 ? r.scales_bytes / 2 : r.scales_bytes; + if (r.scales_fp16 && (r.scales_bytes % 2 || src_scale_bytes * 2 != r.scales_bytes)) { + err = r.name + ": an fp16 scale plane of an odd byte count"; + bad = true; + } + segs[n_segs++] = Seg{r.codes_bytes, src_scale_bytes, r.codes_bytes, r.scales_bytes, + r.scales_fp16 ? Conv::WidenF16 : Conv::Copy}; + } + if (r.offset_bytes) { + const uint64_t after = r.codes_bytes + (r.scales_fp16 ? r.scales_bytes / 2 : r.scales_bytes); + segs[n_segs++] = Seg{after, r.offset_bytes, r.codes_bytes + r.scales_bytes, r.offset_bytes, + Conv::Copy}; + } + } else { + // Everything else is one run whose transform is the row's kind. The two promoted types shrink + // 4 B/elem to 2; the rest are already the engine form. + Conv c = Conv::Copy; + if (r.raw16) c = Conv::Copy; + else if (r.kind == WeightKind::Bf16InF32) c = Conv::Bf16High; + else if (r.kind == WeightKind::F16InF32) c = Conv::ToF16; + const uint64_t elems = (uint64_t) r.ne0 * (uint64_t) (r.ne1 > 0 ? r.ne1 : 1); + if (c != Conv::Copy && (r.src_bytes != elems * 4 || r.dst_bytes != elems * 2)) { + // A promoted type is 4 B/elem in and 2 B/elem out, and nothing else. If those two numbers do + // not say so, the shape and the byte counts disagree and the element count this loop would + // use to walk the source is not the element count the pack wrote. + char buf[320]; + std::snprintf(buf, sizeof buf, + "%s: a promoted tensor of %llu elements is %llu B in and %llu B out, not %llu/%llu", + r.name.c_str(), (unsigned long long) elems, (unsigned long long) r.src_bytes, + (unsigned long long) r.dst_bytes, (unsigned long long) (elems * 4), + (unsigned long long) (elems * 2)); + err = buf; + bad = true; + } + segs[n_segs++] = Seg{0, r.src_bytes, 0, r.dst_bytes, c}; + } + for (int i = 0; i < n_segs; ++i) { sum_src += segs[i].src_bytes; sum_dst += segs[i].dst_bytes; } + // The segment list is this loop's own idea of the layout, and it is the ONLY thing standing between a + // mis-sized plane and a wrong byte offset. It is checked against both recorded totals, not trusted. + if (!bad && (sum_src != r.src_bytes || sum_dst != r.dst_bytes)) { + char buf[360]; + std::snprintf(buf, sizeof buf, + "%s: the segments cover %llu/%llu B but the index says %llu/%llu - the plane layout " + "this loader built is not the one the index describes", + r.name.c_str(), (unsigned long long) sum_src, (unsigned long long) sum_dst, + (unsigned long long) r.src_bytes, (unsigned long long) r.dst_bytes); + err = buf; + bad = true; + } + if (bad) { cudaFreeHost(stage_in); cudaFreeHost(stage_out); return false; } + + for (int si = 0; si < n_segs; ++si) { + const Seg& s = segs[si]; + const bool widening = (s.conv == Conv::WidenF16); + if (widening && (s.src_bytes & 1ull)) { + err = r.name + ": an fp16 plane with an odd source byte count"; + cudaFreeHost(stage_in); cudaFreeHost(stage_out); + return false; + } + uint64_t done = 0; + while (done < s.src_bytes) { + uint64_t n = std::min(CHUNK, s.src_bytes - done); + // WIDENING NEVER SPLITS AN ELEMENT. CHUNK is even, so a full chunk cannot, but the tail of a + // plane whose length is odd would - and half an fp16 is a plausible-looking scale. + if (widening && (n & 1ull)) { + if (n == 1) { err = r.name + ": an fp16 plane ending on a half element"; cudaFreeHost(stage_in); cudaFreeHost(stage_out); return false; } + n -= 1; + } + if (!read_at(cur, r.src_off + s.src_off + done, stage_in, (size_t) n, err, r.name.c_str())) { + cudaFreeHost(stage_in); cudaFreeHost(stage_out); + return false; + } + const uint8_t* src = (const uint8_t*) stage_in; + const void* host_src = src; + uint64_t out_bytes = n; + uint64_t out_at = s.dst_off + done; + + switch (s.conv) { + case Conv::Copy: + break; + case Conv::Bf16High: { + // THE HIGH 16 BITS ARE THE BF16 ENCODING, exactly - not a rounding. A bf16 value is + // the top half of its f32 image, and the pack promoted it without changing it, so + // this is a copy with a stride. + const uint64_t elems = n / 4; + out_bytes = elems * 2; + uint8_t* o = (uint8_t*) stage_out; + for (uint64_t i = 0; i < elems; ++i) { + uint32_t v; + std::memcpy(&v, src + i * 4, 4); + const uint16_t h = (uint16_t) (v >> 16); + std::memcpy(o + i * 2, &h, 2); + } + host_src = stage_out; + out_at = s.dst_off + done / 2; + break; + } + case Conv::ToF16: { + const uint64_t elems = n / 4; + out_bytes = elems * 2; + uint8_t* o = (uint8_t*) stage_out; + for (uint64_t i = 0; i < elems; ++i) { + float v; + std::memcpy(&v, src + i * 4, 4); + const uint16_t h = strata::kernels::f16_from_f32(v); + std::memcpy(o + i * 2, &h, 2); + } + host_src = stage_out; + out_at = s.dst_off + done / 2; + break; + } + case Conv::WidenF16: { + // fp16 bits -> f32. EXACT: every fp16 value is an f32 value, including the + // subnormals, so this cannot change a single weight - which is why the engine may do + // it at all, and why doing it costs 10.88 MiB rather than a second kernel family. + const uint64_t elems = n / 2; + out_bytes = elems * 4; + float* o = (float*) stage_out; + for (uint64_t i = 0; i < elems; ++i) { + uint16_t h; + std::memcpy(&h, src + i * 2, 2); + o[i] = strata::kernels::f32_from_f16(h); + } + host_src = stage_out; + out_at = s.dst_off + done * 2; + break; + } + } + + const double u0 = now_ms(); + if (cudaMemcpy(dst_base + r.dst_off + out_at, host_src, out_bytes, cudaMemcpyHostToDevice) != + cudaSuccess) { + err = "cudaMemcpy failed for " + r.name; + cudaFreeHost(stage_in); cudaFreeHost(stage_out); + return false; + } + upload_ms += now_ms() - u0; + done += n; + } + } + + WeightRef wr; + wr.data = dst_base + r.dst_off; + wr.bytes = r.dst_bytes; + wr.ne0 = r.ne0; + wr.ne1 = r.ne1; + wr.elements = r.ne0 * (r.ne1 > 0 ? r.ne1 : 1); + wr.kind = r.kind; + wr.code_bits = r.code_bits; + wr.code_bias = r.code_bias; + wr.group_elems = r.group_elems; + wr.codebook_iq4nl = r.codebook != 0; + wr.has_offset = r.has_offset != 0; + wr.codes_bytes = r.codes_bytes; + wr.scales_bytes = r.scales_bytes; + wr.offset_bytes = r.offset_bytes; + wr.src_off = r.src_off; + wr.src_bytes = r.src_bytes; + wr.file_id = r.file; + wr.scales_fp16 = r.scales_fp16 != 0; + wr.act_kind = r.act_kind; + table_[r.name] = wr; + + ++report_.tensors; + if ((r.kind == WeightKind::Bf16InF32 || r.kind == WeightKind::F16InF32) && !r.raw16) { + ++report_.re_rounded; + report_.bytes_saved += r.src_bytes - r.dst_bytes; + } + } + if (cur) std::fclose(cur); + cudaFreeHost(stage_in); + cudaFreeHost(stage_out); + + report_.arena_bytes = pool; + report_.read_ms = now_ms() - t_read0 - upload_ms; + report_.upload_ms = upload_ms; + if (cudaDeviceSynchronize() != cudaSuccess) { err = "cudaDeviceSynchronize after the load failed"; return false; } + return true; +} + +const WeightRef* WeightTable::find(const std::string& name) const { + const auto it = table_.find(name); + return it == table_.end() ? nullptr : &it->second; +} + +} // namespace strata::core diff --git a/src/kernels/bf16_gemv_parity.cpp b/src/kernels/bf16_gemv_parity.cpp new file mode 100644 index 00000000..435c336e --- /dev/null +++ b/src/kernels/bf16_gemv_parity.cpp @@ -0,0 +1,148 @@ +// src/kernels/bf16_gemv_parity.cpp - P2.S5's test for the BF16 GEMV. +// +// THREE THINGS ARE CHECKED, and the third is the one the engine's correctness rests on: +// +// 1. both kernels against a host reference that reads the SAME bf16 bits - so what is under test is the +// reduction, not the conversion, and the tolerance is set by summation order and nothing else; +// 2. the naive and the split kernel agree with each other, because the naive one is the reference the +// split one exists to be checked against and a disagreement there means one of them is wrong; +// 3. **THE ACTIVATION CONTRACT IS OBSERVABLE.** A BF16 weight sees a BF16 activation; feeding it fp16 or +// f32 instead is a 2^-9 = 1.95e-03 relative change, which is above any 1e-3 tolerance. An fp16 +// activation is the rival reading, computed and required to differ, because it is what the engine did +// before `docs/activation-contract.md` settled the question and it produces perfectly plausible output. +#include "strata/kernels/bf16_bits.hpp" +#include "strata/kernels/bf16_gemv.hpp" +#include "strata/kernels/f16_bits.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +using strata::kernels::bf16_from_f32; +using strata::kernels::f32_from_bf16; + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +double rel_l1(const std::vector& a, const std::vector& b) { + double d = 0, m = 0; + for (size_t i = 0; i < a.size(); ++i) { + d += std::fabs((double) a[i] - (double) b[i]); + m += std::fabs((double) a[i]); + } + return d / (m > 1e-30 ? m : 1e-30); +} + +/// The host reference, in DOUBLE, over the same bf16 bits. +void reference(const std::vector& x, const std::vector& w, long long n_in, long long n_out, + std::vector& y) { + y.assign((size_t) n_out, 0.0f); + for (long long o = 0; o < n_out; ++o) { + double acc = 0; + for (long long i = 0; i < n_in; ++i) + acc += (double) f32_from_bf16(x[(size_t) i]) * (double) f32_from_bf16(w[(size_t) (o * n_in + i)]); + y[(size_t) o] = (float) acc; + } +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: bf16_gemv_parity [--selftest]\n"); return 2; } + } + int bad = 0; + + // THE REAL SHAPES, because a kernel that only works on a square toy is not a kernel this engine can use. + // `ssm_alpha` is [2560, 48] - a wide reduction and 48 outputs - and `indexer.k_proj` is [2560, 128]. + struct Shape { long long n_in, n_out; const char* what; }; + const Shape shapes[] = { + {2560, 48, "ssm_alpha.weight [2560, 48]"}, + {2560, 128, "indexer.k_proj [2560, 128]"}, + {2560, 512, "indexer.q_proj [2560, 512]"}, + {2560, 2560, "ple_value [2560, 2560]"}, + }; + + for (const Shape& s : shapes) { + std::mt19937 rng(4242); + std::normal_distribution g(0.0f, 1.0f); + std::vector fx((size_t) s.n_in), fw((size_t) (s.n_in * s.n_out)); + for (auto& v : fx) v = g(rng); + // a realistic weight scale: these are projections, not embeddings + for (auto& v : fw) v = g(rng) * 0.05f; + + // THE ACTIVATION IS ROUNDED TO BF16 FIRST, which is the contract, and the WEIGHT is already bf16 in + // the pack - the loaded bytes ARE the high 16 bits of an f32. + std::vector x((size_t) s.n_in), w((size_t) (s.n_in * s.n_out)); + for (size_t i = 0; i < fx.size(); ++i) x[i] = bf16_from_f32(fx[i]); + for (size_t i = 0; i < fw.size(); ++i) w[i] = bf16_from_f32(fw[i]); + + std::vector want; + reference(x, w, s.n_in, s.n_out, want); + + uint16_t *d_x = nullptr, *d_w = nullptr; + float *d_y = nullptr; + check(cudaMalloc(&d_x, x.size() * 2), "x"); + check(cudaMalloc(&d_w, w.size() * 2), "w"); + check(cudaMalloc(&d_y, (size_t) s.n_out * 4), "y"); + check(cudaMemcpy(d_x, x.data(), x.size() * 2, cudaMemcpyHostToDevice), "cx"); + check(cudaMemcpy(d_w, w.data(), w.size() * 2, cudaMemcpyHostToDevice), "cw"); + + std::vector naive((size_t) s.n_out), warp((size_t) s.n_out), split((size_t) s.n_out); + strata::kernels::bf16_gemv(d_x, d_w, d_y, s.n_in, s.n_out, nullptr); + check(cudaMemcpy(naive.data(), d_y, naive.size() * 4, cudaMemcpyDeviceToHost), "cy1"); + strata::kernels::bf16_gemv_split(d_x, d_w, d_y, s.n_in, s.n_out, 32, nullptr); + check(cudaMemcpy(warp.data(), d_y, warp.size() * 4, cudaMemcpyDeviceToHost), "cy2"); + strata::kernels::bf16_gemv_split(d_x, d_w, d_y, s.n_in, s.n_out, 256, nullptr); + check(cudaMemcpy(split.data(), d_y, split.size() * 4, cudaMemcpyDeviceToHost), "cy3"); + + const double rn = rel_l1(want, naive), rw = rel_l1(want, warp), rs = rel_l1(want, split); + std::printf(" %-34s naive %.2e warp %.2e split256 %.2e\n", s.what, rn, rw, rs); + // The products are EXACT - both sides are bf16-valued, so f32 represents each product exactly - and + // the only difference is the order the 2560 terms are added. A tight bound is therefore meaningful. + if (!(rn <= 1e-5 && rw <= 1e-5 && rs <= 1e-5)) { std::printf(" *** over 1e-5 ***\n"); ++bad; } + + // ---- 3. THE ACTIVATION CONTRACT. The rival: an fp16 activation instead of bf16, which is what the + // engine did before the contract was settled. It must be observable, or check 1 above would pass + // against either and the contract would be untested. + { + std::vector fp16_as_f32((size_t) s.n_in); + std::vector x16((size_t) s.n_in); + for (size_t i = 0; i < fx.size(); ++i) { + x16[i] = strata::kernels::f16_from_f32(fx[i]); + fp16_as_f32[i] = strata::kernels::f32_from_f16(x16[i]); + } + // Round the fp16 value to bf16 - i.e. give the kernel a bf16 activation whose VALUES came through + // fp16. That is the whole difference between the two contracts at this call site. + for (size_t i = 0; i < fp16_as_f32.size(); ++i) x[i] = bf16_from_f32(fp16_as_f32[i]); + check(cudaMemcpy(d_x, x.data(), x.size() * 2, cudaMemcpyHostToDevice), "cx2"); + std::vector rival((size_t) s.n_out); + strata::kernels::bf16_gemv(d_x, d_w, d_y, s.n_in, s.n_out, nullptr); + check(cudaMemcpy(rival.data(), d_y, rival.size() * 4, cudaMemcpyDeviceToHost), "cy4"); + const double r = rel_l1(want, rival); + const bool visible = r > 1e-4; + std::printf(" %-30s %s (%.4f%% apart)\n", "bf16 vs fp16 activation", + visible ? "yes" : "*** NO ***", r * 100); + if (!visible) ++bad; + } + cudaFree(d_x); cudaFree(d_w); cudaFree(d_y); + } + + std::printf("\nbf16_gemv: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("bf16_gemv_parity OK\n"); + return 0; +} diff --git a/src/kernels/cpu/expert.cpp b/src/kernels/cpu/expert.cpp new file mode 100644 index 00000000..045e3d58 --- /dev/null +++ b/src/kernels/cpu/expert.cpp @@ -0,0 +1,683 @@ +// src/kernels/cpu/expert.cpp - P2.S3: the Q2_0 expert kernel, promoted from bench/micro/cpu_s2.cpp. +// +// The body is the validated kernel, moved rather than rewritten: it carries the P0.T2 parity result +// (rel 1.461e-06 against the ggml formula) and the three performance findings recorded in +// include/strata/kernels/cpu/expert.hpp. Read that header first; it says why each piece is shaped this way. +#include "strata/kernels/cpu/expert.hpp" + +#include +#if defined(_MSC_VER) +#include +#else +#include +#endif + +#include +#include +#include +#include +#include + +namespace strata::kernels::cpu { +namespace { + +std::atomic oracle_q8_0{false}; + +/// fp16 -> fp32, written out rather than using `_cvtsh_ss`, because the F16C intrinsic's behaviour on +/// subnormals is the one place the two can differ and the scales in this artifact are small. +inline float h2f(const uint8_t* p) { + uint16_t h; + std::memcpy(&h, p, 2); + const uint32_t sign = (uint32_t) (h >> 15) & 1u; + uint32_t exp = (h >> 10) & 0x1Fu, man = h & 0x3FFu, f; + if (exp == 0) { + if (man == 0) { + f = sign << 31; + } else { + exp = 127 - 15 + 1; + while (!(man & 0x400u)) { man <<= 1; --exp; } + man &= 0x3FFu; + f = (sign << 31) | (exp << 23) | (man << 13); + } + } else if (exp == 31) { + f = (sign << 31) | 0x7F800000u | (man << 13); + } else { + f = (sign << 31) | ((exp - 15 + 127) << 23) | (man << 13); + } + float out; + std::memcpy(&out, &f, 4); + return out; +} + +// The x86 Q8_0 quantization and generic Q2_0 dot sequences are adapted from llama.cpp +// 3cf03257f219afbe7334045ff7c6a06ac68c627d, +// ggml/src/ggml-cpu/arch/x86/quants.c: quantize_row_q8_0 and +// ggml/src/ggml-cpu/quants.c: ggml_vec_dot_q2_0_q8_0_generic. +// +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +void quantize_oracle_q8_0(const float* x, int n, ActQ& a) { + a.nchunks = n / QKA; + for (int chunk = 0; chunk < a.nchunks; ++chunk) { + const float* xb = x + chunk * QKA; + __m256 v0 = _mm256_loadu_ps(xb); + __m256 v1 = _mm256_loadu_ps(xb + 8); + __m256 v2 = _mm256_loadu_ps(xb + 16); + __m256 v3 = _mm256_loadu_ps(xb + 24); + const __m256 sign = _mm256_set1_ps(-0.0f); + __m256 maximum = _mm256_andnot_ps(sign, v0); + maximum = _mm256_max_ps(maximum, _mm256_andnot_ps(sign, v1)); + maximum = _mm256_max_ps(maximum, _mm256_andnot_ps(sign, v2)); + maximum = _mm256_max_ps(maximum, _mm256_andnot_ps(sign, v3)); + __m128 max4 = _mm_max_ps(_mm256_extractf128_ps(maximum, 1), _mm256_castps256_ps128(maximum)); + max4 = _mm_max_ps(max4, _mm_movehl_ps(max4, max4)); + max4 = _mm_max_ss(max4, _mm_movehdup_ps(max4)); + const float amax = _mm_cvtss_f32(max4); + const float d = amax / 127.0f; + const uint16_t half_d = (uint16_t) _mm_cvtsi128_si32(_mm_cvtps_ph(_mm_set_ss(d), 0)); + const __m256 inverse = _mm256_set1_ps(amax != 0.0f ? 127.0f / amax : 0.0f); + v0 = _mm256_round_ps(_mm256_mul_ps(v0, inverse), _MM_FROUND_TO_NEAREST_INT); + v1 = _mm256_round_ps(_mm256_mul_ps(v1, inverse), _MM_FROUND_TO_NEAREST_INT); + v2 = _mm256_round_ps(_mm256_mul_ps(v2, inverse), _MM_FROUND_TO_NEAREST_INT); + v3 = _mm256_round_ps(_mm256_mul_ps(v3, inverse), _MM_FROUND_TO_NEAREST_INT); + __m256i i0 = _mm256_packs_epi32(_mm256_cvtps_epi32(v0), _mm256_cvtps_epi32(v1)); + const __m256i i2 = _mm256_packs_epi32(_mm256_cvtps_epi32(v2), _mm256_cvtps_epi32(v3)); + i0 = _mm256_packs_epi16(i0, i2); + i0 = _mm256_permutevar8x32_epi32(i0, _mm256_setr_epi32(0, 4, 1, 5, 2, 6, 3, 7)); + int8_t* q = a.q + chunk * QKA; + _mm256_storeu_si256((__m256i*) q, i0); + int32_t sum = 0; + for (int j = 0; j < QKA; ++j) sum += q[j]; + a.scale[chunk] = h2f((const uint8_t*) &half_d); + a.sum[chunk] = sum; + a.hx[chunk] = a.scale[chunk] * float(sum); + } +} + +/// 2-bit unpack via VPMULTISHIFTQB. +/// +/// ARGUMENT ORDER IS (control, data) AND WAS DETERMINED EMPIRICALLY (`bench/micro/probe_multishift.cpp`). +/// The Intel guide states (a=data, b=control); using that produced 43 of 64 wrong codes. `plan.md`'s sketch +/// passes the shift pattern first, which is correct - so a "fix" that swapped it to match the guide was the +/// bug, not the fix. Within each 64-bit lane, output byte i is `(u16 >> 2i) & 0xFF`, so AND 3 yields the +/// eight 2-bit codes in order. +/// +/// The result is the 32 codes of ONE ACTIVATION CHUNK (4 qword lanes), which is the unit the Q8_1 scale is +/// defined on - not the 64-weight block. +inline __m256i unpack_q2_0(const uint8_t* codes) { + const __m128i packed = _mm_loadl_epi64((const __m128i*) codes); // 8 bytes -> 4 u16 in the low half + const __m256i lanes = _mm256_cvtepu16_epi64(packed); // 4 qwords, 8 codes each + const __m256i ctrl = _mm256_set1_epi64x((long long) 0x0E0C0A0806040200ULL); + return _mm256_and_si256(_mm256_multishift_epi64_epi8(ctrl, lanes), _mm256_set1_epi8(3)); +} + +/// 32 codes against 32 int8 activations. Codes 0 contribute nothing to `sum(c*xhat)`, so the zero lanes the +/// narrow load leaves behind are harmless. +inline __m256i block_dot(const uint8_t* codes, const int8_t* xq) { + return _mm256_dpbusd_epi32(_mm256_setzero_si256(), unpack_q2_0(codes), + _mm256_load_si256((const __m256i*) xq)); +} + +inline float hsum_ps(__m256 v) { + const __m128 s = _mm_add_ps(_mm256_castps256_ps128(v), _mm256_extractf128_ps(v, 1)); + return _mm_cvtss_f32(_mm_hadd_ps(_mm_hadd_ps(s, s), s)); +} + +// ================================ plan v0.3 P6: the row dot in 512-bit lanes ================================ +// +// The 256-bit row dot below spent ~12 cycles per 64-weight block: two unpacks, two VNNI dots, two converts, two +// scalar scale products broadcast into two FMAs, an fp16 conversion and a scalar correction - compute-bound at +// ~39 GB/s while the machine streams 52. Here a block is ONE unpack (16 code bytes -> 64 codes), ONE VNNI dot +// over 64 activations, ONE convert and ONE FMA whose per-chunk scales come from a permute of a vector of scale +// products built for eight blocks at a time; the weight-independent correction is one FMA per eight blocks. +// The sum order differs from the 256-bit kernel (last-bit differences); the verify window and plain decode both +// use this kernel, so speculative decode still reproduces plain decode exactly. +static const bool kZmm = std::getenv("STRATA_CPU_YMM") == nullptr; + +inline __m512i unpack64_q2_0(const uint8_t* codes) { + const __m128i packed = _mm_loadu_si128((const __m128i*) codes); // 8 u16 = 64 codes + const __m512i lanes = _mm512_cvtepu16_epi64(packed); // 8 qwords, 8 codes each + const __m512i ctrl = _mm512_set1_epi64((long long) 0x0E0C0A0806040200ULL); + return _mm512_and_si512(_mm512_multishift_epi64_epi8(ctrl, lanes), _mm512_set1_epi8(3)); +} + +// For blocks [b0, b0+nb) (nb <= 8): p[2i+h] = d_{b0+i} * xscale[2(b0+i)+h], dd[2i+h] = d_{b0+i}. +inline void scales8(const uint8_t* wscales, const ActQ& a, int b0, int nb, __m512& p, __m512& dd) { + const __mmask8 m8 = nb >= 8 ? (__mmask8) 0xFF : (__mmask8) ((1u << nb) - 1u); + const __m128i h = _mm_maskz_loadu_epi16(m8, wscales + 2 * b0); + const __m512 d8 = _mm512_castps256_ps512(_mm256_cvtph_ps(h)); + const __m512i dup = _mm512_setr_epi32(0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7); + dd = _mm512_permutexvar_ps(dup, d8); + const __mmask16 m16 = nb >= 8 ? (__mmask16) 0xFFFF : (__mmask16) ((1u << (2 * nb)) - 1u); + dd = _mm512_maskz_mov_ps(m16, dd); + p = _mm512_mul_ps(dd, _mm512_maskz_loadu_ps(m16, a.scale + 2 * b0)); +} + +inline float row_dot_z(const uint8_t* codes, const uint8_t* scales, const ActQ& a, int nblocks) { + __m512 acc = _mm512_setzero_ps(), corr = _mm512_setzero_ps(); + const __m512i base = _mm512_setr_epi32(0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1); + for (int b0 = 0; b0 < nblocks; b0 += 8) { + const int nb = nblocks - b0 < 8 ? nblocks - b0 : 8; + __m512 p, dd; + scales8(scales, a, b0, nb, p, dd); + const __mmask16 m16 = nb >= 8 ? (__mmask16) 0xFFFF : (__mmask16) ((1u << (2 * nb)) - 1u); + corr = _mm512_fmadd_ps(dd, _mm512_maskz_loadu_ps(m16, a.hx + 2 * b0), corr); + for (int i = 0; i < nb; ++i) { + const int b = b0 + i; + const __m512i dot = _mm512_dpbusd_epi32(_mm512_setzero_si512(), unpack64_q2_0(codes + b * 16), + _mm512_load_si512((const void*) (a.q + b * QK))); + const __m512 sv = _mm512_permutexvar_ps(_mm512_add_epi32(base, _mm512_set1_epi32(2 * i)), p); + acc = _mm512_fmadd_ps(sv, _mm512_cvtepi32_ps(dot), acc); + } + } + return _mm512_reduce_add_ps(acc) - _mm512_reduce_add_ps(corr); +} + +template +inline void row_dot_multi_z(const uint8_t* codes, const uint8_t* scales, const ActQ* const* a, int nblocks, + float* res) { + __m512 acc[NT], corr[NT]; + for (int t = 0; t < NT; ++t) { acc[t] = _mm512_setzero_ps(); corr[t] = _mm512_setzero_ps(); } + const __m512i base = _mm512_setr_epi32(0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1); + for (int b0 = 0; b0 < nblocks; b0 += 8) { + const int nb = nblocks - b0 < 8 ? nblocks - b0 : 8; + const __mmask16 m16 = nb >= 8 ? (__mmask16) 0xFFFF : (__mmask16) ((1u << (2 * nb)) - 1u); + __m512 p[NT]; + for (int t = 0; t < NT; ++t) { + __m512 dd; + scales8(scales, *a[t], b0, nb, p[t], dd); + corr[t] = _mm512_fmadd_ps(dd, _mm512_maskz_loadu_ps(m16, a[t]->hx + 2 * b0), corr[t]); + } + for (int i = 0; i < nb; ++i) { + const int b = b0 + i; + const __m512i w = unpack64_q2_0(codes + b * 16); + const __m512i idx = _mm512_add_epi32(base, _mm512_set1_epi32(2 * i)); + for (int t = 0; t < NT; ++t) { + const __m512i dot = _mm512_dpbusd_epi32(_mm512_setzero_si512(), w, + _mm512_load_si512((const void*) (a[t]->q + b * QK))); + acc[t] = _mm512_fmadd_ps(_mm512_permutexvar_ps(idx, p[t]), _mm512_cvtepi32_ps(dot), acc[t]); + } + } + } + for (int t = 0; t < NT; ++t) res[t] = _mm512_reduce_add_ps(acc[t]) - _mm512_reduce_add_ps(corr[t]); +} + +/// One weight row against a quantized activation. +/// +/// sum_j (c_j - 1) d_w xhat_j d_x == d_w * ( d_x*sum(c*xhat) - d_x*sum(xhat) ) +/// +/// split per 32-chunk because `d_x` changes at the chunk boundary while `d_w` does not. +/// +/// The accumulator stays a FLOAT VECTOR for the whole row and is reduced once at the end. Reducing per +/// 64-weight block (two 8-lane horizontal sums: a store plus 16 scalar adds) cost far more than the dot +/// products themselves - it made the kernel compute-bound at 21 GB/s instead of DRAM-bound at 32. +inline float row_dot(const uint8_t* codes, const uint8_t* scales, const ActQ& a, int nblocks) { + if (kZmm) return row_dot_z(codes, scales, a, nblocks); + __m256 acc = _mm256_setzero_ps(); + // The -sum(xhat) term is a SCALAR: broadcasting it into all 8 lanes and letting hsum_ps add them would + // apply it eight times per block. + float corr = 0.f; + for (int b = 0; b < nblocks; ++b) { + const float d = h2f(scales + 2 * b); + const __m256 slo = _mm256_cvtepi32_ps(block_dot(codes + b * 16, a.q + b * QK)); + const __m256 shi = _mm256_cvtepi32_ps(block_dot(codes + b * 16 + 8, a.q + b * QK + QKA)); + acc = _mm256_fmadd_ps(_mm256_set1_ps(d * a.scale[2 * b]), slo, acc); + acc = _mm256_fmadd_ps(_mm256_set1_ps(d * a.scale[2 * b + 1]), shi, acc); + corr += d * (a.hx[2 * b] + a.hx[2 * b + 1]); + } + return hsum_ps(acc) - corr; +} + +// VNNI's integer partial sums are exact. Remove the Q2_0 code offset BEFORE +// conversion to float, then follow the pinned generic CPU dot's two-chunk and +// per-64-block reduction. Its x86 type trait selects this generic reduction even +// in the AVX-512 oracle build. The legacy eight-lane FP32 reduction above has a +// different rounding order and remains the default. +inline int chunk_dot_oracle(const uint8_t* codes, const int8_t* q, int sum) { + const __m256i lanes = block_dot(codes, q); + const __m128i halves = _mm_add_epi32(_mm256_castsi256_si128(lanes), _mm256_extracti128_si256(lanes, 1)); + const __m128i pairs = _mm_hadd_epi32(halves, halves); + return _mm_cvtsi128_si32(_mm_hadd_epi32(pairs, pairs)) - sum; +} + +inline float row_dot_oracle(const uint8_t* codes, const uint8_t* scales, const ActQ& a, int nblocks) { + float result = 0.0f; + for (int b = 0; b < nblocks; ++b) { + float block = 0.0f; + for (int half = 0; half < 2; ++half) { + const int chunk = 2 * b + half; + const int dot = chunk_dot_oracle(codes + b * 16 + half * 8, a.q + chunk * QKA, a.sum[chunk]); + block += a.scale[chunk] * float(dot); + } + result += h2f(scales + 2 * b) * block; + } + return result; +} + +/// `row_dot` for NT activations at once: the codes of each block are unpacked once, and each token's +/// accumulator sees exactly the operations `row_dot` would apply to it, in the same order. +template +inline void row_dot_multi(const uint8_t* codes, const uint8_t* scales, const ActQ* const* a, int nblocks, + float* res) { + if (kZmm) { row_dot_multi_z(codes, scales, a, nblocks, res); return; } + __m256 acc[NT]; + float corr[NT]; + for (int t = 0; t < NT; ++t) { acc[t] = _mm256_setzero_ps(); corr[t] = 0.f; } + for (int b = 0; b < nblocks; ++b) { + const float d = h2f(scales + 2 * b); + const __m256i lo = unpack_q2_0(codes + b * 16); + const __m256i hi = unpack_q2_0(codes + b * 16 + 8); + for (int t = 0; t < NT; ++t) { + const __m256 slo = _mm256_cvtepi32_ps( + _mm256_dpbusd_epi32(_mm256_setzero_si256(), lo, _mm256_load_si256((const __m256i*) (a[t]->q + b * QK)))); + const __m256 shi = _mm256_cvtepi32_ps( + _mm256_dpbusd_epi32(_mm256_setzero_si256(), hi, + _mm256_load_si256((const __m256i*) (a[t]->q + b * QK + QKA)))); + acc[t] = _mm256_fmadd_ps(_mm256_set1_ps(d * a[t]->scale[2 * b]), slo, acc[t]); + acc[t] = _mm256_fmadd_ps(_mm256_set1_ps(d * a[t]->scale[2 * b + 1]), shi, acc[t]); + corr[t] += d * (a[t]->hx[2 * b] + a[t]->hx[2 * b + 1]); + } + } + for (int t = 0; t < NT; ++t) res[t] = hsum_ps(acc[t]) - corr[t]; +} + +template +void expert_multi(const uint8_t* blob, const ActQ* const* a1, float* const* out, ExpertScratchMulti& ws) { + float g[NT], u[NT]; + for (int r = 0; r < FF; ++r) { + row_dot_multi(blob + O_GU_CODES + (size_t) (2 * r) * ROW_GU, + blob + O_GU_SCALES + (size_t) (2 * r) * SC_GU * 2, a1, SC_GU, g); + row_dot_multi(blob + O_GU_CODES + (size_t) (2 * r + 1) * ROW_GU, + blob + O_GU_SCALES + (size_t) (2 * r + 1) * SC_GU * 2, a1, SC_GU, u); + for (int t = 0; t < NT; ++t) ws.ff[t][r] = (g[t] / (1.f + std::exp(-g[t]))) * u[t]; + } + const ActQ* a2[NT]; + for (int t = 0; t < NT; ++t) { + act_quant_q8_1(ws.ff[t], FF, ws.a2[t]); + a2[t] = &ws.a2[t]; + } + float o[NT]; + for (int r = 0; r < H; ++r) { + row_dot_multi(blob + O_D_CODES + (size_t) r * ROW_D, blob + O_D_SCALES + (size_t) r * SC_D * 2, a2, + SC_D, o); + for (int t = 0; t < NT; ++t) out[t][r] = o[t]; + } +} + +void expert_oracle_q8_0(const uint8_t* blob, const ActQ& a1, float* out, ExpertScratch& ws) { + for (int r = 0; r < FF; ++r) { + const float g = row_dot_oracle(blob + O_GU_CODES + size_t(2 * r) * ROW_GU, + blob + O_GU_SCALES + size_t(2 * r) * SC_GU * 2, a1, SC_GU); + const float u = row_dot_oracle(blob + O_GU_CODES + size_t(2 * r + 1) * ROW_GU, + blob + O_GU_SCALES + size_t(2 * r + 1) * SC_GU * 2, a1, SC_GU); + ws.ff[r] = (g / (1.f + std::exp(-g))) * u; + } + quantize_oracle_q8_0(ws.ff, FF, ws.a2); + for (int r = 0; r < H; ++r) + out[r] = row_dot_oracle(blob + O_D_CODES + size_t(r) * ROW_D, + blob + O_D_SCALES + size_t(r) * SC_D * 2, ws.a2, SC_D); +} + +} // namespace + +const char* CpuFeatures::reason() const { + if (usable()) return "ok"; + // Named individually: "AVX-512 not supported" sends a user looking for a new CPU when the machine may have + // AVX-512F and be missing only VNNI, which is a much narrower and more explicable gap. + static char buf[160]; + std::snprintf(buf, sizeof buf, "missing %s%s%s%s%s", avx512f ? "" : "AVX512F ", + avx512bw ? "" : "AVX512BW ", avx512vl ? "" : "AVX512VL ", + avx512_vnni ? "" : "AVX512-VNNI ", avx512_vbmi ? "" : "AVX512-VBMI"); + return buf; +} + +CpuFeatures cpu_features() { + CpuFeatures f; + int reg[4] = {0, 0, 0, 0}; +#if defined(_MSC_VER) + __cpuid(reg, 0); + if (reg[0] < 7) return f; + __cpuidex(reg, 7, 0); +#else + unsigned r[4] = {0, 0, 0, 0}; + __cpuid_count(0, 0, r[0], r[1], r[2], r[3]); + if (r[0] < 7) return f; + __cpuid_count(7, 0, r[0], r[1], r[2], r[3]); + for (int i = 0; i < 4; ++i) reg[i] = (int) r[i]; +#endif + const unsigned ebx = (unsigned) reg[1], ecx = (unsigned) reg[2]; + f.avx512f = (ebx >> 16) & 1u; + f.avx512bw = (ebx >> 30) & 1u; + f.avx512vl = (ebx >> 31) & 1u; + f.avx512_vnni = (ecx >> 11) & 1u; + f.avx512_vbmi = (ecx >> 1) & 1u; + return f; +} + +void cpu_require_expert_support() { + const CpuFeatures f = cpu_features(); + if (f.usable()) return; + std::fprintf(stderr, + "strata: this CPU cannot run the expert kernel: %s.\n" + " The engine needs AVX512-VNNI and AVX512-VBMI (Intel Ice Lake / AMD Zen 4 or newer).\n" + " The scalar fallback exists for tests only and is far too slow to decode with.\n", + f.reason()); + std::exit(1); +} + +void act_quant_q8_1(const float* x, int n, ActQ& a) { + if (oracle_q8_0.load(std::memory_order_relaxed)) { + quantize_oracle_q8_0(x, n, a); + return; + } + a.nchunks = n / QKA; + // Plan v0.3 P6: AVX-512, the same operations per element as the scalar loop below (max of |x|, one multiply, + // +-0.5 away from zero, truncation, clamp), so the result is bitwise the scalar one. The scalar loop took + // ~22 us per 2560 values - 3.2 ms of every speculative round. + { + const __m512 half = _mm512_set1_ps(0.5f), mhalf = _mm512_set1_ps(-0.5f), zero = _mm512_setzero_ps(); + const __m512i lo = _mm512_set1_epi32(-127), hi = _mm512_set1_epi32(127); + const __m512 absmask = _mm512_castsi512_ps(_mm512_set1_epi32(0x7fffffff)); + for (int k = 0; k < a.nchunks; ++k) { + const float* xb = x + k * QKA; + const __m512 x0 = _mm512_loadu_ps(xb), x1 = _mm512_loadu_ps(xb + 16); + const float amax = _mm512_reduce_max_ps(_mm512_max_ps(_mm512_and_ps(x0, absmask), _mm512_and_ps(x1, absmask))); + const float s = amax > 0.f ? amax / 127.f : 0.f; + const float inv = s > 0.f ? 1.f / s : 0.f; + const __m512 vinv = _mm512_set1_ps(inv); + const __m512 t0 = _mm512_mul_ps(x0, vinv), t1 = _mm512_mul_ps(x1, vinv); + const __m512 r0 = _mm512_add_ps(t0, _mm512_mask_blend_ps(_mm512_cmp_ps_mask(t0, zero, _CMP_GE_OQ), mhalf, half)); + const __m512 r1 = _mm512_add_ps(t1, _mm512_mask_blend_ps(_mm512_cmp_ps_mask(t1, zero, _CMP_GE_OQ), mhalf, half)); + __m512i v0 = _mm512_cvttps_epi32(r0), v1 = _mm512_cvttps_epi32(r1); + v0 = _mm512_min_epi32(_mm512_max_epi32(v0, lo), hi); + v1 = _mm512_min_epi32(_mm512_max_epi32(v1, lo), hi); + _mm_storeu_si128((__m128i*) (a.q + k * QKA), _mm512_cvtepi32_epi8(v0)); + _mm_storeu_si128((__m128i*) (a.q + k * QKA + 16), _mm512_cvtepi32_epi8(v1)); + const int32_t sum = _mm512_reduce_add_epi32(_mm512_add_epi32(v0, v1)); + a.scale[k] = s; + a.sum[k] = sum; + a.hx[k] = s * (float) sum; + } + return; + } + for (int k = 0; k < a.nchunks; ++k) { + const float* xb = x + k * QKA; + float amax = 0.f; + for (int j = 0; j < QKA; ++j) amax = std::fmax(amax, std::fabs(xb[j])); + const float s = amax > 0.f ? amax / 127.f : 0.f; + const float inv = s > 0.f ? 1.f / s : 0.f; + int32_t sum = 0; + int8_t* q = a.q + k * QKA; + for (int j = 0; j < QKA; ++j) { + // **`std::lround` IS A FUNCTION CALL AND IT COST 51 us PER LAYER.** Measured: quantizing 2560 + // floats took 51.3 us - 20 ns per element, about 60 cycles for a fabs, a multiply and a round. + // `lround` respects the current rounding mode, so MSVC cannot inline it to a single instruction + // and emits a call per element. 48 layers x 51.3 us = 2.46 ms/token, which is 8.6% of the whole + // CPU expert term and is pure overhead. + // + // `t + copysign(0.5, t)` is EXACTLY `lround`'s rule - round half AWAY FROM ZERO - and it is + // branchless and vectorizable. It is NOT `_mm256_round_ps`, which rounds half to EVEN and would + // quietly change the activation on the ties. The cast is safe because `|t| <= 127` by construction + // (`s = amax/127`, so `|x_j|/s <= 127`), well inside int32. + const float t = xb[j] * inv; + const float r = t + (t >= 0.f ? 0.5f : -0.5f); + int v = (int) r; + v = v < -127 ? -127 : (v > 127 ? 127 : v); + q[j] = (int8_t) v; + sum += v; + } + a.scale[k] = s; + a.sum[k] = sum; + a.hx[k] = s * (float) sum; + } +} + +void expert_set_oracle_q8_0(bool enabled) { + oracle_q8_0.store(enabled, std::memory_order_relaxed); +} + +void s2_expert_vnni(const uint8_t* blob, const float* x, float* out, ExpertScratch& ws) { + act_quant_q8_1(x, H, ws.a1); + s2_expert_vnni_q(blob, ws.a1, out, ws); +} + +void s2_expert_vnni_q(const uint8_t* blob, const ActQ& a1, float* out, ExpertScratch& ws) { + if (oracle_q8_0.load(std::memory_order_relaxed)) { + expert_oracle_q8_0(blob, a1, out, ws); + return; + } + for (int r = 0; r < FF; ++r) { + const float g = row_dot(blob + O_GU_CODES + (size_t) (2 * r) * ROW_GU, + blob + O_GU_SCALES + (size_t) (2 * r) * SC_GU * 2, a1, SC_GU); + const float u = row_dot(blob + O_GU_CODES + (size_t) (2 * r + 1) * ROW_GU, + blob + O_GU_SCALES + (size_t) (2 * r + 1) * SC_GU * 2, a1, SC_GU); + // SiLU on the GATE, multiplied by up - the same reading `docs/semantics.md` records for the shared + // expert, and the one that is wrong the other way round in a way that still produces a number. + ws.ff[r] = (g / (1.f + std::exp(-g))) * u; + } + act_quant_q8_1(ws.ff, FF, ws.a2); + for (int r = 0; r < H; ++r) + out[r] = row_dot(blob + O_D_CODES + (size_t) r * ROW_D, + blob + O_D_SCALES + (size_t) r * SC_D * 2, ws.a2, SC_D); +} + +bool expert_oracle_q8_0_enabled() { return oracle_q8_0.load(std::memory_order_relaxed); } + +void s2_expert_gu_rows(const uint8_t* blob, const ActQ& a1, float* ff, int r0, int r1) { + for (int r = r0; r < r1; ++r) { + const float g = row_dot(blob + O_GU_CODES + (size_t) (2 * r) * ROW_GU, + blob + O_GU_SCALES + (size_t) (2 * r) * SC_GU * 2, a1, SC_GU); + const float u = row_dot(blob + O_GU_CODES + (size_t) (2 * r + 1) * ROW_GU, + blob + O_GU_SCALES + (size_t) (2 * r + 1) * SC_GU * 2, a1, SC_GU); + ff[r] = (g / (1.f + std::exp(-g))) * u; + } +} + +void s2_expert_down_rows(const uint8_t* blob, const ActQ& a2, float* out, int r0, int r1) { + for (int r = r0; r < r1; ++r) + out[r] = row_dot(blob + O_D_CODES + (size_t) r * ROW_D, blob + O_D_SCALES + (size_t) r * SC_D * 2, a2, SC_D); +} + +namespace { +template +void gu_rows_multi(const uint8_t* blob, const ActQ* const* a1, float* const* ff, int r0, int r1) { + float g[NT], u[NT]; + for (int r = r0; r < r1; ++r) { + row_dot_multi(blob + O_GU_CODES + (size_t) (2 * r) * ROW_GU, + blob + O_GU_SCALES + (size_t) (2 * r) * SC_GU * 2, a1, SC_GU, g); + row_dot_multi(blob + O_GU_CODES + (size_t) (2 * r + 1) * ROW_GU, + blob + O_GU_SCALES + (size_t) (2 * r + 1) * SC_GU * 2, a1, SC_GU, u); + for (int t = 0; t < NT; ++t) ff[t][r] = (g[t] / (1.f + std::exp(-g[t]))) * u[t]; + } +} +template +void down_rows_multi(const uint8_t* blob, const ActQ* const* a2, float* const* out, int r0, int r1) { + float o[NT]; + for (int r = r0; r < r1; ++r) { + row_dot_multi(blob + O_D_CODES + (size_t) r * ROW_D, blob + O_D_SCALES + (size_t) r * SC_D * 2, a2, + SC_D, o); + for (int t = 0; t < NT; ++t) out[t][r] = o[t]; + } +} +} // namespace + +void s2_expert_gu_rows_multi(const uint8_t* blob, const ActQ* const* a1, int n_tokens, float* const* ff, int r0, + int r1) { + switch (n_tokens) { + case 1: gu_rows_multi<1>(blob, a1, ff, r0, r1); break; + case 2: gu_rows_multi<2>(blob, a1, ff, r0, r1); break; + case 3: gu_rows_multi<3>(blob, a1, ff, r0, r1); break; + case 4: gu_rows_multi<4>(blob, a1, ff, r0, r1); break; + case 5: gu_rows_multi<5>(blob, a1, ff, r0, r1); break; + case 6: gu_rows_multi<6>(blob, a1, ff, r0, r1); break; + case 7: gu_rows_multi<7>(blob, a1, ff, r0, r1); break; + default: gu_rows_multi<8>(blob, a1, ff, r0, r1); break; + } +} + +void s2_expert_down_rows_multi(const uint8_t* blob, const ActQ* const* a2, int n_tokens, float* const* out, int r0, + int r1) { + switch (n_tokens) { + case 1: down_rows_multi<1>(blob, a2, out, r0, r1); break; + case 2: down_rows_multi<2>(blob, a2, out, r0, r1); break; + case 3: down_rows_multi<3>(blob, a2, out, r0, r1); break; + case 4: down_rows_multi<4>(blob, a2, out, r0, r1); break; + case 5: down_rows_multi<5>(blob, a2, out, r0, r1); break; + case 6: down_rows_multi<6>(blob, a2, out, r0, r1); break; + case 7: down_rows_multi<7>(blob, a2, out, r0, r1); break; + default: down_rows_multi<8>(blob, a2, out, r0, r1); break; + } +} + +// ================================ plan v0.3 P6: Q2_0 rows in the GGUF block layout ================================ +// +// The IQ model files keep their down projections in Q2_0 GGUF blocks (fp16 d, then 16 code bytes: 18 bytes per +// 64 weights, interleaved), which ggml-cpu computes with a scalar loop on x86. This is `row_dot_multi_z` with +// the block stride of the GGUF layout: the same unpack, the same VNNI dot, the same correction. +namespace { +template +inline void q2g_row_multi(const uint8_t* row, const ActQ* const* a, int nblocks, float* res) { + __m512 acc[NT], corr[NT]; + for (int t = 0; t < NT; ++t) { acc[t] = _mm512_setzero_ps(); corr[t] = _mm512_setzero_ps(); } + const __m512i base = _mm512_setr_epi32(0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1); + const __m512i dup = _mm512_setr_epi32(0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7); + for (int b0 = 0; b0 < nblocks; b0 += 8) { + const int nb = nblocks - b0 < 8 ? nblocks - b0 : 8; + const __mmask16 m16 = nb >= 8 ? (__mmask16) 0xFFFF : (__mmask16) ((1u << (2 * nb)) - 1u); + alignas(16) uint16_t sc[8] = {0, 0, 0, 0, 0, 0, 0, 0}; + for (int i = 0; i < nb; ++i) std::memcpy(&sc[i], row + (size_t) (b0 + i) * 18, 2); + const __m512 d8 = _mm512_castps256_ps512(_mm256_cvtph_ps(_mm_load_si128((const __m128i*) sc))); + const __m512 dd = _mm512_maskz_mov_ps(m16, _mm512_permutexvar_ps(dup, d8)); + __m512 p[NT]; + for (int t = 0; t < NT; ++t) { + p[t] = _mm512_mul_ps(dd, _mm512_maskz_loadu_ps(m16, a[t]->scale + 2 * b0)); + corr[t] = _mm512_fmadd_ps(dd, _mm512_maskz_loadu_ps(m16, a[t]->hx + 2 * b0), corr[t]); + } + for (int i = 0; i < nb; ++i) { + const int b = b0 + i; + const __m512i w = unpack64_q2_0(row + (size_t) b * 18 + 2); + const __m512i idx = _mm512_add_epi32(base, _mm512_set1_epi32(2 * i)); + for (int t = 0; t < NT; ++t) { + const __m512i dot = _mm512_dpbusd_epi32(_mm512_setzero_si512(), w, + _mm512_load_si512((const void*) (a[t]->q + b * QK))); + acc[t] = _mm512_fmadd_ps(_mm512_permutexvar_ps(idx, p[t]), _mm512_cvtepi32_ps(dot), acc[t]); + } + } + } + for (int t = 0; t < NT; ++t) res[t] = _mm512_reduce_add_ps(acc[t]) - _mm512_reduce_add_ps(corr[t]); +} +template +void q2g_rows(const uint8_t* w, size_t row_bytes, int nblocks, const ActQ* const* a, float* const* out, int r0, int r1) { + float res[NT]; + for (int r = r0; r < r1; ++r) { + q2g_row_multi(w + (size_t) r * row_bytes, a, nblocks, res); + for (int t = 0; t < NT; ++t) out[t][r] = res[t]; + } +} +} // namespace + +void q2_0_gguf_rows_multi(const uint8_t* w, size_t row_bytes, int nblocks, const ActQ* const* a, int nt, + float* const* out, int r0, int r1) { + switch (nt) { + case 1: q2g_rows<1>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 2: q2g_rows<2>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 3: q2g_rows<3>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 4: q2g_rows<4>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 5: q2g_rows<5>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 6: q2g_rows<6>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 7: q2g_rows<7>(w, row_bytes, nblocks, a, out, r0, r1); break; + default: q2g_rows<8>(w, row_bytes, nblocks, a, out, r0, r1); break; + } +} + +void s2_expert_vnni_multi(const uint8_t* blob, const ActQ* const* a1, int n_tokens, float* const* out, + ExpertScratchMulti& ws) { + if (oracle_q8_0.load(std::memory_order_relaxed) || n_tokens < 1 || n_tokens > MAXT) { + for (int t = 0; t < n_tokens; ++t) s2_expert_vnni_q(blob, *a1[t], out[t], ws.single); + return; + } + switch (n_tokens) { + case 1: expert_multi<1>(blob, a1, out, ws); break; + case 2: expert_multi<2>(blob, a1, out, ws); break; + case 3: expert_multi<3>(blob, a1, out, ws); break; + case 4: expert_multi<4>(blob, a1, out, ws); break; + case 5: expert_multi<5>(blob, a1, out, ws); break; + case 6: expert_multi<6>(blob, a1, out, ws); break; + case 7: expert_multi<7>(blob, a1, out, ws); break; + default: expert_multi<8>(blob, a1, out, ws); break; + } +} + +void s2_expert_scalar(const uint8_t* blob, const float* x_in, float* out, bool quant_acts) { + // BOTH STAGES, which is what `quant_acts` promises and what `bench/micro/cpu_s2.cpp` does NOT do. + // + // The original's comment says the oracle "consume[s] the SAME INT8 activation values the VNNI path uses, + // at both stages", but its code quantizes only the INTERMEDIATE - the gate/up projections still see raw + // f32 `x`. Feeding it an already-quantized input (which is what the engine does) makes the two coincide + // and hides the discrepancy entirely; feeding it a raw f32 input makes the comparison one between two + // DIFFERENT computations, and the gap measured here was 1.19e-02 - three times P2.S3's tolerance, and + // read as a kernel bug when it was a fixture bug. + float xq[H]; + const float* x = x_in; + if (quant_acts) { + ActQ a1; + act_quant_q8_1(x_in, H, a1); + for (int i = 0; i < H; ++i) xq[i] = a1.scale[i / QKA] * (float) a1.q[i]; + x = xq; + } + float ff[FF]; + for (int r = 0; r < FF; ++r) { + const uint8_t* gc = blob + O_GU_CODES + (size_t) (2 * r) * ROW_GU; + const uint8_t* gs = blob + O_GU_SCALES + (size_t) (2 * r) * SC_GU * 2; + const uint8_t* uc = blob + O_GU_CODES + (size_t) (2 * r + 1) * ROW_GU; + const uint8_t* us = blob + O_GU_SCALES + (size_t) (2 * r + 1) * SC_GU * 2; + float sg = 0.f, su = 0.f; + for (int b = 0; b < SC_GU; ++b) { + const float dg = h2f(gs + 2 * b), du = h2f(us + 2 * b); + for (int j = 0; j < QK; ++j) { + const int o = b * QK + j; + sg += (float) (((gc[b * 16 + (j >> 2)] >> (2 * (j & 3))) & 3) - 1) * dg * x[o]; + su += (float) (((uc[b * 16 + (j >> 2)] >> (2 * (j & 3))) & 3) - 1) * du * x[o]; + } + } + ff[r] = (sg / (1.f + std::exp(-sg))) * su; + } + if (quant_acts) { + // Replace the exact intermediate with the INT8 values the VNNI path actually sees, so the two differ + // only by FP32 evaluation order. + ActQ a2; + act_quant_q8_1(ff, FF, a2); + for (int i = 0; i < FF; ++i) ff[i] = a2.scale[i / QKA] * (float) a2.q[i]; + } + for (int r = 0; r < H; ++r) { + const uint8_t* dc = blob + O_D_CODES + (size_t) r * ROW_D; + const uint8_t* ds = blob + O_D_SCALES + (size_t) r * SC_D * 2; + float acc = 0.f; + for (int b = 0; b < SC_D; ++b) { + const float d = h2f(ds + 2 * b); + for (int j = 0; j < QK; ++j) + acc += (float) (((dc[b * 16 + (j >> 2)] >> (2 * (j & 3))) & 3) - 1) * d * ff[b * QK + j]; + } + out[r] = acc; + } +} + +} // namespace strata::kernels::cpu diff --git a/src/kernels/cpu/expert_layout.cpp b/src/kernels/cpu/expert_layout.cpp new file mode 100644 index 00000000..c02cd657 --- /dev/null +++ b/src/kernels/cpu/expert_layout.cpp @@ -0,0 +1,136 @@ +// src/kernels/cpu/expert_layout.cpp - plan v0.3 P6: the per-layer expert table. See the header. +#include "strata/kernels/cpu/expert_layout.hpp" + +#include +#include +#if defined(_MSC_VER) +#include +#include +#else +#include +#endif +#include +#include + +namespace strata::kernels::cpu { +namespace { +ExpertLayout g_layout; +} + +const ExpertLayout& expert_layout() { return g_layout; } + +bool cpu_avx512_ok() { + static const bool ok = [] { + if (const char* f = std::getenv("STRATA_FORCE_AVX2"); f != nullptr && f[0] == '1') return false; + unsigned r[4] = {0, 0, 0, 0}; + auto cpuid = [&](unsigned leaf, unsigned sub) { +#if defined(_MSC_VER) + int x[4]; + __cpuidex(x, (int) leaf, (int) sub); + for (int i = 0; i < 4; ++i) r[i] = (unsigned) x[i]; +#else + __cpuid_count(leaf, sub, r[0], r[1], r[2], r[3]); +#endif + }; + cpuid(0, 0); + if (r[0] < 7) return false; + cpuid(1, 0); + if (!((r[2] >> 27) & 1u)) return false; // OSXSAVE +#if defined(_MSC_VER) + const unsigned long long xcr0 = _xgetbv(0); +#else + unsigned lo = 0, hi = 0; + __asm__ volatile("xgetbv" : "=a"(lo), "=d"(hi) : "c"(0)); + const unsigned long long xcr0 = ((unsigned long long) hi << 32) | lo; +#endif + if ((xcr0 & 0xE6) != 0xE6) return false; // the OS saves the AVX-512 state + cpuid(7, 0); + const unsigned ebx = r[1], ecx = r[2]; + return ((ebx >> 16) & 1u) && ((ebx >> 30) & 1u) && ((ebx >> 31) & 1u) && ((ecx >> 11) & 1u) && ((ecx >> 1) & 1u); + }(); + return ok; +} + +void q2_rows_any(const uint8_t* w, size_t row_bytes, int nblocks, const ActQ* const* a, int nt, float* const* out, + int r0, int r1) { + if (cpu_avx512_ok()) q2_0_gguf_rows_multi(w, row_bytes, nblocks, a, nt, out, r0, r1); + else q2_0_gguf_rows_multi_avx2(w, row_bytes, nblocks, a, nt, out, r0, r1); +} + +void act_quant_any(const float* x, int n, ActQ& a) { + if (cpu_avx512_ok()) act_quant_q8_1(x, n, a); + else act_quant_q8_1_avx2(x, n, a); +} + +#if !defined(STRATA_NATIVE_EXPERTS) +// Without ggml-cpu no native pack loads (expert_layout_load refuses), so these are never reached. +bool native_experts_available() noexcept { return false; } +bool native_fmt(int, int, int64_t, int64_t, NativeFmt&, std::string& err) { err = "built without native experts"; return false; } +void native_quant_act(const NativeFmt&, const float*, void*) { std::abort(); } +void native_quant_h(const NativeFmt&, const float*, void*) { std::abort(); } +void native_gu_rows(const NativeFmt&, const uint8_t*, const void* const*, int, float* const*, int, int) { std::abort(); } +void native_down_rows(const NativeFmt&, const uint8_t*, const void* const*, int, float* const*, int, int) { std::abort(); } +#endif + +bool expert_layout_load(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, std::string& err) { + ExpertLayout L; + L.n_layers = n_layers; + L.n_expert = n_expert; + std::ifstream in(pack_dir + "/native_experts.txt"); + if (!in) { + L.total = (uint64_t) n_layers * (uint64_t) n_expert * (uint64_t) BLOB; + g_layout = L; + return true; + } +#if !defined(STRATA_NATIVE_EXPERTS) + err = "this pack has native (IQ) experts but the engine was built without STRATA_NATIVE_EXPERTS"; + return false; +#else + L.native = true; + L.fmt.resize((size_t) n_layers); + L.offset.assign((size_t) n_layers, ~0ull); + L.bytes.assign((size_t) n_layers, 0); + L.max_blob = 0; + std::string line; + while (std::getline(in, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream ss(line); + long long l = -1, gt = -1, dt = -1; + unsigned long long off = 0, blob = 0, go = 0, uo = 0, dox = 0; + if (!(ss >> l >> gt >> dt >> off >> blob) || l < 0 || l >= n_layers) { + err = "native_experts.txt: a malformed line: " + line; + return false; + } + NativeFmt f; + if (!native_fmt((int) gt, (int) dt, H, FF, f, err)) return false; + if (f.bytes != blob) { + err = "native_experts.txt: layer " + std::to_string(l) + " blob is " + std::to_string(blob) + + " B but its formats make " + std::to_string(f.bytes); + return false; + } + if (ss >> go >> uo >> dox) { // v2 lines: the GGUF offsets + if (L.gguf_off.empty()) L.gguf_off.assign((size_t) (3 * n_layers), 0); + L.gguf_off[(size_t) (3 * l)] = go; + L.gguf_off[(size_t) (3 * l + 1)] = uo; + L.gguf_off[(size_t) (3 * l + 2)] = dox; + } + L.fmt[(size_t) l] = f; + L.offset[(size_t) l] = off; + L.bytes[(size_t) l] = blob; + if (blob > L.max_blob) L.max_blob = blob; + } + uint64_t at = 0; + for (int64_t l = 0; l < n_layers; ++l) { + if (L.offset[(size_t) l] != at) { + err = "native_experts.txt: layer " + std::to_string(l) + " is missing or not contiguous"; + return false; + } + at += L.bytes[(size_t) l] * (uint64_t) n_expert; + } + L.total = at; + g_layout = L; + return true; +#endif +} + +} // namespace strata::kernels::cpu diff --git a/src/kernels/cpu/expert_multi_test.cpp b/src/kernels/cpu/expert_multi_test.cpp new file mode 100644 index 00000000..446c95fe --- /dev/null +++ b/src/kernels/cpu/expert_multi_test.cpp @@ -0,0 +1,87 @@ +// src/kernels/cpu/expert_multi_test.cpp - plan v0.3 P6: one expert, several tokens (CPU only, synthetic data). +// +// expert_multi_test bitwise check: s2_expert_vnni_multi vs s2_expert_vnni_q per token, n = 1..8 +// expert_multi_test --bench [E] time per expert for n = 1..5 over E synthetic experts (default 256 = 354 MB, +// far beyond L3, so the blob comes from DRAM as it does in the engine); 1 thread +// +// The bench measures the speculation model's `extra_use_cost`: the CPU time of each extra token routed to an +// expert, as a fraction of reading the expert once (tools/spec_economics.py assumes 0.2). +#include "strata/kernels/cpu/expert.hpp" + +#include +#include +#include +#include +#include +#include + +namespace c = strata::kernels::cpu; + +namespace { +void make_blob(uint8_t* b, std::mt19937& rng) { + for (size_t i = 0; i < c::O_GU_SCALES; ++i) b[i] = (uint8_t) rng(); // codes + for (size_t i = c::O_GU_SCALES; i < c::BLOB; i += 2) { // fp16 scales ~0.004-0.016 + const uint16_t h = (uint16_t) (0x1C00 + rng() % 0x0800); + std::memcpy(b + i, &h, 2); + } +} + +double now_ms() { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); +} +} // namespace + +int main(int argc, char** argv) { + c::cpu_require_expert_support(); + std::mt19937 rng(9); + const bool bench = argc > 1 && std::strcmp(argv[1], "--bench") == 0; + const int E = bench ? (argc > 2 ? std::atoi(argv[2]) : 256) : 4; + std::vector blobs((size_t) E * c::BLOB); + for (int e = 0; e < E; ++e) make_blob(&blobs[(size_t) e * c::BLOB], rng); + std::normal_distribution nd(0.f, 1.f); + static c::ActQ acts[c::MAXT]; + static float xs[c::MAXT][c::H]; + for (int t = 0; t < c::MAXT; ++t) { + for (float& v : xs[t]) v = nd(rng); + c::act_quant_q8_1(xs[t], c::H, acts[t]); + } + const c::ActQ* a1[c::MAXT]; + for (int t = 0; t < c::MAXT; ++t) a1[t] = &acts[t]; + static float out_multi[c::MAXT][c::H], out_single[c::H]; + float* outs[c::MAXT]; + for (int t = 0; t < c::MAXT; ++t) outs[t] = out_multi[t]; + static c::ExpertScratchMulti wsm; + static c::ExpertScratch ws; + + if (!bench) { + int fail = 0; + for (int e = 0; e < E; ++e) { + const uint8_t* blob = &blobs[(size_t) e * c::BLOB]; + for (int n = 1; n <= c::MAXT; ++n) { + c::s2_expert_vnni_multi(blob, a1, n, outs, wsm); + for (int t = 0; t < n; ++t) { + c::s2_expert_vnni_q(blob, acts[t], out_single, ws); + if (std::memcmp(out_single, out_multi[t], sizeof out_single) != 0) { + std::fprintf(stderr, "FAIL expert %d n=%d token %d differs\n", e, n, t); + ++fail; + } + } + } + } + std::printf("expert_multi_test: %s\n", fail ? "FAILED" : "OK (every token bitwise equal to the single-token kernel)"); + return fail ? 1 : 0; + } + + std::printf("%d experts x %.2f MB, one thread\n", E, c::BLOB / 1e6); + double base = 0; + for (int n = 1; n <= 5; ++n) { + c::s2_expert_vnni_multi(&blobs[0], a1, n, outs, wsm); // warm the code path + const double t0 = now_ms(); + for (int e = 0; e < E; ++e) c::s2_expert_vnni_multi(&blobs[(size_t) e * c::BLOB], a1, n, outs, wsm); + const double us = 1000.0 * (now_ms() - t0) / E; + if (n == 1) base = us; + std::printf("tokens per expert %d: %7.1f us per expert (%.2fx one token; extra token = %.2f of one read), %.1f GB/s\n", + n, us, us / base, n > 1 ? (us / base - 1.0) / (n - 1) : 0.0, c::BLOB / (us * 1e3)); + } + return 0; +} diff --git a/src/kernels/cpu/expert_parity.cpp b/src/kernels/cpu/expert_parity.cpp new file mode 100644 index 00000000..39f24a1c --- /dev/null +++ b/src/kernels/cpu/expert_parity.cpp @@ -0,0 +1,184 @@ +// src/kernels/cpu/expert_parity.cpp - P2.S3's test for the CPU expert path. +// +// WHAT IS BEING CHECKED, and the two tolerances are different questions: +// +// 1. VNNI vs the scalar oracle with `quant_acts = true`. Both sides consume the SAME INT8 activation +// values, so the only difference is FP32 evaluation ORDER. This is the check that proves the KERNEL - +// the VNNI unpack, the 32-element chunk split, the scalar correction term - and P2.S3 asks for 3e-3. +// 2. VNNI vs the oracle with `quant_acts = false`. Now the oracle uses exact FP32 activations, so the gap +// IS the activation contract (`docs/activation-contract.md`). It is REPORTED, not asserted tightly, +// because it is a property of the model's quantisation and not of this code. +// +// The activation contract is asserted OBSERVABLE between the two: if rounding the activation to Q8_1 made no +// difference, then one of the two paths is not doing what it claims and check 1 would be vacuous. +// +// A SELF-CONSISTENT TEST CANNOT CATCH A WRONG FILE OFFSET. Both sides read the same blob, so pointing at the +// wrong expert would still agree perfectly. What is checked instead is structural: the blob must decode to +// finite, non-zero scales, and the blob at a DIFFERENT layer must be different data - which is what a wrong +// stride would break. +#include "strata/kernels/cpu/expert.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace cpu = strata::kernels::cpu; + +namespace { + +/// Normalised L1 difference, with the reference magnitude returned for reporting. +double rel_l1(const float* a, const float* b, int n, double* mag_out = nullptr) { + double d = 0, mag = 0; + for (int i = 0; i < n; ++i) { + d += std::fabs((double) a[i] - (double) b[i]); + mag += std::fabs((double) a[i]); + } + if (mag_out) *mag_out = mag / n; + return d / (mag > 1e-30 ? mag : 1e-30); +} + +bool read_blob(const char* path, long long index, std::vector& out) { + out.assign(cpu::BLOB, 0); + std::FILE* f = std::fopen(path, "rb"); + if (!f) return false; +#if defined(_MSC_VER) + if (_fseeki64(f, (long long) index * (long long) cpu::BLOB, SEEK_SET) != 0) { std::fclose(f); return false; } +#else + if (fseeko(f, (off_t) index * (off_t) cpu::BLOB, SEEK_SET) != 0) { std::fclose(f); return false; } +#endif + const size_t got = std::fread(out.data(), 1, out.size(), f); + std::fclose(f); + return got == out.size(); +} + +/// The fp16 scale at a byte offset, decoded by the SAME rule the kernel uses. Used only for the structural +/// sanity check, so it is deliberately a second, independent transcription of the three lines in question. +float f16_at(const std::vector& b, size_t off) { + uint16_t h; + std::memcpy(&h, b.data() + off, 2); + const uint32_t sign = (uint32_t) (h >> 15) & 1u; + uint32_t exp = (h >> 10) & 0x1Fu, man = h & 0x3FFu, f; + if (exp == 0) { + if (man == 0) f = sign << 31; + else { exp = 127 - 15 + 1; while (!(man & 0x400u)) { man <<= 1; --exp; } man &= 0x3FFu; + f = (sign << 31) | (exp << 23) | (man << 13); } + } else if (exp == 31) { + f = (sign << 31) | 0x7F800000u | (man << 13); + } else { + f = (sign << 31) | ((exp - 15 + 127) << 23) | (man << 13); + } + float o; + std::memcpy(&o, &f, 4); + return o; +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + const char* path = "pack/full/experts.bin"; + long long layer = 0, expert = 0; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--selftest") selftest = true; + else if (a == "--file" && i + 1 < argc) path = argv[++i]; + else if (a == "--layer" && i + 1 < argc) layer = std::atoll(argv[++i]); + else if (a == "--expert" && i + 1 < argc) expert = std::atoll(argv[++i]); + else { std::fprintf(stderr, "usage: expert_parity [--selftest] [--file P] [--layer N] [--expert N]\n"); + return 2; } + } + + int bad = 0; + + // ---- the CPU must be able to run the kernel, and the report must say WHICH feature is missing + const cpu::CpuFeatures feat = cpu::cpu_features(); + std::printf(" %-44s %s\n", "AVX512 F/BW/VL/VNNI/VBMI", + feat.usable() ? "all present" : feat.reason()); + if (!feat.usable()) { + std::printf(" the VNNI path cannot run here; only the scalar oracle can be exercised.\n"); + std::printf(" P2.S3's kernel check is SKIPPED, not passed.\n"); + std::printf("\nexpert_parity: 0 failures, 1 SKIPPED\n"); + return 0; + } + + // ---- a real blob out of the pack: layer stride is 512 experts + const long long idx = layer * 512 + expert; + std::vector blob; + if (!read_blob(path, idx, blob)) { + std::fprintf(stderr, "cannot read expert %lld (layer %lld) from %s\n", expert, layer, path); + return 2; + } + + // ---- structural sanity, because the agreement checks below are self-consistent and would pass on the + // wrong blob. A mis-strided read lands on a different expert's data, which is still plausible Q2_0, + // so the check that has teeth is the DIFFERENCE between layers. + { + int nonzero = 0; + float smin = 1e30f, smax = -1e30f; + for (int b = 0; b < cpu::SC_GU; ++b) { + const float s = f16_at(blob, cpu::O_GU_SCALES + (size_t) b * 2); + if (s != 0.f) ++nonzero; + smin = std::fmin(smin, std::fabs(s)); + smax = std::fmax(smax, std::fabs(s)); + } + const bool ok = nonzero > cpu::SC_GU / 2 && std::isfinite(smin) && std::isfinite(smax) && smax > 0.f; + std::printf(" %-44s %s (%d/%d non-zero, |s| in [%.4g, %.4g])\n", "blob decodes to live scales", + ok ? "yes" : "*** NO ***", nonzero, cpu::SC_GU, (double) smin, (double) smax); + if (!ok) ++bad; + } + { + std::vector other; + if (read_blob(path, (layer * 512 + expert) + 1, other)) { + long long diff = 0; + for (size_t i = 0; i < blob.size(); ++i) if (blob[i] != other[i]) ++diff; + const double frac = (double) diff / (double) blob.size(); + const bool ok = frac > 0.1; + std::printf(" %-44s %s (%.1f%% of bytes differ)\n", "the next expert is different data", + ok ? "yes" : "*** NO ***", frac * 100); + if (!ok) ++bad; + } + } + + // ---- a realistic activation + std::mt19937 rng(4242); + std::normal_distribution gauss(0.0f, 1.0f); + std::vector x(cpu::H); + for (auto& v : x) v = gauss(rng); + + cpu::ExpertScratch ws; + std::vector vnni(cpu::H), scalar_q(cpu::H), scalar_fp(cpu::H); + cpu::s2_expert_vnni(blob.data(), x.data(), vnni.data(), ws); + cpu::s2_expert_scalar(blob.data(), x.data(), scalar_q.data(), true); + cpu::s2_expert_scalar(blob.data(), x.data(), scalar_fp.data(), false); + + double mag = 0; + const double rel_kernel = rel_l1(scalar_q.data(), vnni.data(), cpu::H, &mag); + const double rel_contract = rel_l1(scalar_fp.data(), vnni.data(), cpu::H); + const double trap_contract = rel_l1(scalar_fp.data(), scalar_q.data(), cpu::H); + + // ---- the activation contract must be OBSERVABLE, or check 1 is vacuous + { + const bool visible = trap_contract > 1e-4; + std::printf(" %-44s %s (%.4f%% apart)\n", "the Q8_1 activation contract is observable", + visible ? "yes" : "*** NO ***", trap_contract * 100); + if (!visible) ++bad; + } + + // ---- 1. the kernel, against an oracle fed the SAME activations + std::printf(" %-44s rel %.3e (P2.S3 asks <= 3e-3, mean |ref| %.4f)\n", "VNNI vs scalar (INT8 acts)", + rel_kernel, mag); + if (!(rel_kernel <= 3e-3)) { std::printf(" *** over P2.S3's 3e-3 ***\n"); ++bad; } + + // ---- 2. the activation contract's own cost, reported rather than asserted + std::printf(" %-44s rel %.3e (the cost of the contract, REPORTED)\n", + "VNNI vs scalar (FP32 acts)", rel_contract); + + std::printf("\nexpert_parity: %d failures (layer %lld expert %lld of %s)\n", bad, layer, expert, path); + if (bad) return 1; + if (selftest) std::printf("expert_parity OK\n"); + return 0; +} diff --git a/src/kernels/cpu/iq_avx512.cpp b/src/kernels/cpu/iq_avx512.cpp new file mode 100644 index 00000000..a82f79af --- /dev/null +++ b/src/kernels/cpu/iq_avx512.cpp @@ -0,0 +1,246 @@ +// src/kernels/cpu/iq_avx512.cpp - plan v0.3 P6: the i-quant expert rows in 512-bit lanes, several tokens at once. +// +// ggml-cpu's x86 dot products for these formats are AVX2 and single-token: every token re-decodes the weights +// and a sign is applied with five instructions per 32 values. Here 64 values are decoded once (8 or 16 grid +// lookups, one 64-bit sign mask, one vector of scales) and every token applies them with a masked subtract, a +// `maddubs` and a `madd`. The arithmetic is ggml's (ggml-cpu/quants.c, the `_generic` references): integer sums +// per block, times d_x * d_y * the format's constant - only the order of the float additions differs. +// +// Formats: IQ2_XXS (16), IQ2_XS (17), IQ3_XXS (18), IQ3_S (21), IQ2_S (22). IQ1_M stays on ggml-cpu. +#include "strata/kernels/cpu/iq_avx512.hpp" + +#define GGML_COMMON_DECL_CPP +#define GGML_COMMON_IMPL_CPP +#include "ggml-common.h" + +#include + +#include +#include + +namespace strata::kernels::cpu { +namespace { + +inline float h2f(uint16_t h) { return _mm_cvtss_f32(_mm_cvtph_ps(_mm_cvtsi32_si128((int) h))); } +inline uint32_t u32(const uint8_t* p) { uint32_t v; std::memcpy(&v, p, 4); return v; } +inline uint16_t u16(const uint8_t* p) { uint16_t v; std::memcpy(&v, p, 2); return v; } +inline uint64_t u64(const uint8_t* p) { uint64_t v; std::memcpy(&v, p, 8); return v; } + +// lanes 0-7 -> s0, 8-15 -> s1, 16-23 -> s2, 24-31 -> s3 (int16 lanes of a maddubs result: two values each) +inline __m512i scales4(int s0, int s1, int s2, int s3) { + const __m512i idx = _mm512_set_epi16(3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0); + const uint64_t packed = (uint64_t) (uint16_t) s0 | ((uint64_t) (uint16_t) s1 << 16) | + ((uint64_t) (uint16_t) s2 << 32) | ((uint64_t) (uint16_t) s3 << 48); + return _mm512_permutexvar_epi16(idx, _mm512_set1_epi64((long long) packed)); +} + +// ---- per format: 64 values (chunk j of a 256-value block) -> grid magnitudes, sign mask, scales +template struct Fmt; + +template <> struct Fmt<16> { // IQ2_XXS: d, qs[32] u16 + static constexpr int bytes = 66; + static constexpr float K = 0.125f; + static inline void decode(const uint8_t* b, int j, __m512i& g, __mmask64& m, __m512i& sc) { + const uint8_t* q = b + 2 + 16 * j; + const uint32_t a0 = u32(q), a1 = u32(q + 4), b0 = u32(q + 8), b1 = u32(q + 12); + g = _mm512_set_epi64((long long) iq2xxs_grid[b0 >> 24], (long long) iq2xxs_grid[(b0 >> 16) & 255], + (long long) iq2xxs_grid[(b0 >> 8) & 255], (long long) iq2xxs_grid[b0 & 255], + (long long) iq2xxs_grid[a0 >> 24], (long long) iq2xxs_grid[(a0 >> 16) & 255], + (long long) iq2xxs_grid[(a0 >> 8) & 255], (long long) iq2xxs_grid[a0 & 255]); + const uint64_t s = (uint64_t) ksigns_iq2xs[a1 & 127] | ((uint64_t) ksigns_iq2xs[(a1 >> 7) & 127] << 8) | + ((uint64_t) ksigns_iq2xs[(a1 >> 14) & 127] << 16) | ((uint64_t) ksigns_iq2xs[(a1 >> 21) & 127] << 24) | + ((uint64_t) ksigns_iq2xs[b1 & 127] << 32) | ((uint64_t) ksigns_iq2xs[(b1 >> 7) & 127] << 40) | + ((uint64_t) ksigns_iq2xs[(b1 >> 14) & 127] << 48) | ((uint64_t) ksigns_iq2xs[(b1 >> 21) & 127] << 56); + m = _cvtu64_mask64(s); + const int sa = 2 * (int) (a1 >> 28) + 1, sb = 2 * (int) (b1 >> 28) + 1; + sc = scales4(sa, sa, sb, sb); + } +}; + +template <> struct Fmt<17> { // IQ2_XS: d, qs[32] u16 (9-bit grid index + 7-bit sign index), scales[8] + static constexpr int bytes = 74; + static constexpr float K = 0.125f; + static inline void decode(const uint8_t* b, int j, __m512i& g, __mmask64& m, __m512i& sc) { + const uint8_t* q = b + 2 + 16 * j; + uint16_t v[8]; + std::memcpy(v, q, 16); + g = _mm512_set_epi64((long long) iq2xs_grid[v[7] & 511], (long long) iq2xs_grid[v[6] & 511], + (long long) iq2xs_grid[v[5] & 511], (long long) iq2xs_grid[v[4] & 511], + (long long) iq2xs_grid[v[3] & 511], (long long) iq2xs_grid[v[2] & 511], + (long long) iq2xs_grid[v[1] & 511], (long long) iq2xs_grid[v[0] & 511]); + uint64_t s = 0; + for (int l = 0; l < 8; ++l) s |= (uint64_t) ksigns_iq2xs[v[l] >> 9] << (8 * l); + m = _cvtu64_mask64(s); + const uint8_t s0 = b[66 + 2 * j], s1 = b[66 + 2 * j + 1]; + sc = scales4(2 * (s0 & 15) + 1, 2 * (s0 >> 4) + 1, 2 * (s1 & 15) + 1, 2 * (s1 >> 4) + 1); + } +}; + +template <> struct Fmt<22> { // IQ2_S: d, qs[64] (32 grid bytes, 32 sign bytes), qh[8], scales[8] + static constexpr int bytes = 82; + static constexpr float K = 0.125f; + static inline void decode(const uint8_t* b, int j, __m512i& g, __mmask64& m, __m512i& sc) { + const uint8_t* qs = b + 2 + 8 * j; + const uint8_t h0 = b[66 + 2 * j], h1 = b[66 + 2 * j + 1]; + g = _mm512_set_epi64((long long) iq2s_grid[qs[7] | ((h1 << 2) & 0x300)], (long long) iq2s_grid[qs[6] | ((h1 << 4) & 0x300)], + (long long) iq2s_grid[qs[5] | ((h1 << 6) & 0x300)], (long long) iq2s_grid[qs[4] | ((h1 << 8) & 0x300)], + (long long) iq2s_grid[qs[3] | ((h0 << 2) & 0x300)], (long long) iq2s_grid[qs[2] | ((h0 << 4) & 0x300)], + (long long) iq2s_grid[qs[1] | ((h0 << 6) & 0x300)], (long long) iq2s_grid[qs[0] | ((h0 << 8) & 0x300)]); + m = _cvtu64_mask64(u64(b + 2 + 32 + 8 * j)); + const uint8_t s0 = b[74 + 2 * j], s1 = b[74 + 2 * j + 1]; + sc = scales4(2 * (s0 & 15) + 1, 2 * (s0 >> 4) + 1, 2 * (s1 & 15) + 1, 2 * (s1 >> 4) + 1); + } +}; + +template <> struct Fmt<18> { // IQ3_XXS: d, qs[64] grid bytes, 8 x u32 (4 x 7-bit sign index + 4-bit scale) + static constexpr int bytes = 98; + static constexpr float K = 0.25f; + static inline void decode(const uint8_t* b, int j, __m512i& g, __mmask64& m, __m512i& sc) { + const uint8_t* q = b + 2 + 16 * j; + g = _mm512_set_epi32((int) iq3xxs_grid[q[15]], (int) iq3xxs_grid[q[14]], (int) iq3xxs_grid[q[13]], (int) iq3xxs_grid[q[12]], + (int) iq3xxs_grid[q[11]], (int) iq3xxs_grid[q[10]], (int) iq3xxs_grid[q[9]], (int) iq3xxs_grid[q[8]], + (int) iq3xxs_grid[q[7]], (int) iq3xxs_grid[q[6]], (int) iq3xxs_grid[q[5]], (int) iq3xxs_grid[q[4]], + (int) iq3xxs_grid[q[3]], (int) iq3xxs_grid[q[2]], (int) iq3xxs_grid[q[1]], (int) iq3xxs_grid[q[0]]); + const uint32_t a = u32(b + 2 + 64 + 8 * j), c = u32(b + 2 + 64 + 8 * j + 4); + const uint64_t s = (uint64_t) ksigns_iq2xs[a & 127] | ((uint64_t) ksigns_iq2xs[(a >> 7) & 127] << 8) | + ((uint64_t) ksigns_iq2xs[(a >> 14) & 127] << 16) | ((uint64_t) ksigns_iq2xs[(a >> 21) & 127] << 24) | + ((uint64_t) ksigns_iq2xs[c & 127] << 32) | ((uint64_t) ksigns_iq2xs[(c >> 7) & 127] << 40) | + ((uint64_t) ksigns_iq2xs[(c >> 14) & 127] << 48) | ((uint64_t) ksigns_iq2xs[(c >> 21) & 127] << 56); + m = _cvtu64_mask64(s); + const int sa = 2 * (int) (a >> 28) + 1, sb = 2 * (int) (c >> 28) + 1; + sc = scales4(sa, sa, sb, sb); + } +}; + +template <> struct Fmt<21> { // IQ3_S: d, qs[64], qh[8], signs[32], scales[4] + static constexpr int bytes = 110; + static constexpr float K = 1.0f; + static inline void decode(const uint8_t* b, int j, __m512i& g, __mmask64& m, __m512i& sc) { + const uint8_t* q = b + 2 + 16 * j; + const uint32_t h0 = b[66 + 2 * j], h1 = b[66 + 2 * j + 1]; +#define G3(k, h, kk) (int) iq3s_grid[q[k] | (((h >> kk) & 1) << 8)] + g = _mm512_set_epi32(G3(15, h1, 7), G3(14, h1, 6), G3(13, h1, 5), G3(12, h1, 4), + G3(11, h1, 3), G3(10, h1, 2), G3(9, h1, 1), G3(8, h1, 0), + G3(7, h0, 7), G3(6, h0, 6), G3(5, h0, 5), G3(4, h0, 4), + G3(3, h0, 3), G3(2, h0, 2), G3(1, h0, 1), G3(0, h0, 0)); +#undef G3 + m = _cvtu64_mask64(u64(b + 74 + 8 * j)); + const uint8_t s = b[106 + j]; + const int sa = 2 * (s & 15) + 1, sb = 2 * (s >> 4) + 1; + sc = scales4(sa, sa, sb, sb); + } +}; + +template +inline void row_dot(const uint8_t* row, int nblocks, const block_q8_K* const* y, float* res) { + __m512 accf[NT]; + for (int t = 0; t < NT; ++t) accf[t] = _mm512_setzero_ps(); + const __m512i zero = _mm512_setzero_si512(); + for (int i = 0; i < nblocks; ++i) { + const uint8_t* blk = row + (size_t) i * Fmt::bytes; + __m512i acci[NT]; + for (int t = 0; t < NT; ++t) acci[t] = _mm512_setzero_si512(); + for (int j = 0; j < 4; ++j) { + __m512i g, sc; + __mmask64 m; + Fmt::decode(blk, j, g, m, sc); + for (int t = 0; t < NT; ++t) { + const __m512i yv = _mm512_loadu_si512((const void*) (y[t][i].qs + 64 * j)); + const __m512i ys = _mm512_mask_sub_epi8(yv, m, zero, yv); + acci[t] = _mm512_add_epi32(acci[t], _mm512_madd_epi16(_mm512_maddubs_epi16(g, ys), sc)); + } + } + const float dx = h2f(u16(blk)) * Fmt::K; + for (int t = 0; t < NT; ++t) + accf[t] = _mm512_fmadd_ps(_mm512_set1_ps(dx * y[t][i].d), _mm512_cvtepi32_ps(acci[t]), accf[t]); + } + for (int t = 0; t < NT; ++t) res[t] = _mm512_reduce_add_ps(accf[t]); +} + +template +void gu_rows(const uint8_t* blob, size_t gu_row, size_t up_off, int n, const void* const* act, float* const* ff, + int r0, int r1) { + const block_q8_K* y[NT]; + for (int t = 0; t < NT; ++t) y[t] = (const block_q8_K*) act[t]; + const int nb = n / QK_K; + float g[NT], u[NT]; + for (int r = r0; r < r1; ++r) { + row_dot(blob + (size_t) r * gu_row, nb, y, g); + row_dot(blob + up_off + (size_t) r * gu_row, nb, y, u); + for (int t = 0; t < NT; ++t) ff[t][r] = (g[t] / (1.f + std::exp(-g[t]))) * u[t]; + } +} + +template +void dot_rows(const uint8_t* w, size_t row_bytes, int n, const void* const* act, float* const* out, int r0, int r1) { + const block_q8_K* y[NT]; + for (int t = 0; t < NT; ++t) y[t] = (const block_q8_K*) act[t]; + float res[NT]; + for (int r = r0; r < r1; ++r) { + row_dot(w + (size_t) r * row_bytes, n / QK_K, y, res); + for (int t = 0; t < NT; ++t) out[t][r] = res[t]; + } +} + +template +void gu_rows_nt(int nt, const uint8_t* blob, size_t gu_row, size_t up_off, int n, const void* const* act, + float* const* ff, int r0, int r1) { + switch (nt) { + case 1: gu_rows(blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 2: gu_rows(blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 3: gu_rows(blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 4: gu_rows(blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 5: gu_rows(blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 6: gu_rows(blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 7: gu_rows(blob, gu_row, up_off, n, act, ff, r0, r1); break; + default: gu_rows(blob, gu_row, up_off, n, act, ff, r0, r1); break; + } +} + +template +void dot_rows_nt(int nt, const uint8_t* w, size_t row_bytes, int n, const void* const* act, float* const* out, int r0, + int r1) { + switch (nt) { + case 1: dot_rows(w, row_bytes, n, act, out, r0, r1); break; + case 2: dot_rows(w, row_bytes, n, act, out, r0, r1); break; + case 3: dot_rows(w, row_bytes, n, act, out, r0, r1); break; + case 4: dot_rows(w, row_bytes, n, act, out, r0, r1); break; + default: for (int t0 = 0; t0 < nt; t0 += 4) { + const int k = nt - t0 < 4 ? nt - t0 : 4; + dot_rows_nt(k, w, row_bytes, n, act + t0, out + t0, r0, r1); + } + } +} + +} // namespace + +bool iq512_supported(int type) noexcept { + return type == 16 || type == 17 || type == 18 || type == 21 || type == 22; +} + +void iq512_gu_rows(int type, const uint8_t* blob, size_t gu_row, size_t up_off, int n, const void* const* act, int nt, + float* const* ff, int r0, int r1) { + switch (type) { + case 16: gu_rows_nt<16>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 17: gu_rows_nt<17>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 18: gu_rows_nt<18>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 21: gu_rows_nt<21>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break; + case 22: gu_rows_nt<22>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break; + default: break; + } +} + +void iq512_rows(int type, const uint8_t* w, size_t row_bytes, int n, const void* const* act, int nt, float* const* out, + int r0, int r1) { + switch (type) { + case 16: dot_rows_nt<16>(nt, w, row_bytes, n, act, out, r0, r1); break; + case 17: dot_rows_nt<17>(nt, w, row_bytes, n, act, out, r0, r1); break; + case 18: dot_rows_nt<18>(nt, w, row_bytes, n, act, out, r0, r1); break; + case 21: dot_rows_nt<21>(nt, w, row_bytes, n, act, out, r0, r1); break; + case 22: dot_rows_nt<22>(nt, w, row_bytes, n, act, out, r0, r1); break; + default: break; + } +} + +} // namespace strata::kernels::cpu diff --git a/src/kernels/cpu/native_expert.cpp b/src/kernels/cpu/native_expert.cpp new file mode 100644 index 00000000..660950d7 --- /dev/null +++ b/src/kernels/cpu/native_expert.cpp @@ -0,0 +1,114 @@ +// src/kernels/cpu/native_expert.cpp - plan v0.3 P6: native (GGUF-form) experts on the CPU through ggml-cpu. +// See the header. Nothing here is Strata arithmetic: the activation quantizers and the row dot products are +// ggml-cpu's, so an IQ expert computes what llama.cpp's CPU backend computes for it. +#include "strata/kernels/cpu/native_expert.hpp" +#include "strata/kernels/cpu/expert.hpp" +#include "strata/kernels/cpu/iq_avx512.hpp" +#include "strata/kernels/cpu/expert_layout.hpp" + +#include "ggml.h" +#include "ggml-cpu.h" + +#include +#include +#include + +namespace strata::kernels::cpu { +namespace { + +const ggml_type_traits_cpu* traits(int type) { return ggml_get_type_traits_cpu((ggml_type) type); } + +void init_once() { + static std::once_flag once; + std::call_once(once, [] { ggml_cpu_init(); }); +} + +} // namespace + +bool native_experts_available() noexcept { return true; } + +bool native_fmt(int gu_type, int d_type, int64_t n_embd, int64_t n_ff, NativeFmt& f, std::string& err) { + init_once(); + const ggml_type_traits_cpu* tg = traits(gu_type); + const ggml_type_traits_cpu* td = traits(d_type); + if (tg == nullptr || tg->vec_dot == nullptr || td == nullptr || td->vec_dot == nullptr) { + err = "native experts: ggml-cpu has no dot product for type " + std::to_string(tg && tg->vec_dot ? d_type : gu_type); + return false; + } + const ggml_type_traits_cpu* ag = traits(tg->vec_dot_type); + const ggml_type_traits_cpu* ad = traits(td->vec_dot_type); + if (ag == nullptr || ag->from_float == nullptr || ad == nullptr || ad->from_float == nullptr) { + err = "native experts: ggml-cpu cannot quantize an activation for this layer"; + return false; + } + if (n_embd % ggml_blck_size((ggml_type) gu_type) || n_ff % ggml_blck_size((ggml_type) d_type) || + n_embd % ggml_blck_size(tg->vec_dot_type) || n_ff % ggml_blck_size(td->vec_dot_type)) { + err = "native experts: expert geometry is not whole blocks"; + return false; + } + f.gu_type = gu_type; + f.d_type = d_type; + f.gu_act = (int) tg->vec_dot_type; + f.d_act = (int) td->vec_dot_type; + f.n_embd = n_embd; + f.n_ff = n_ff; + f.gu_row = ggml_row_size((ggml_type) gu_type, n_embd); + f.d_row = ggml_row_size((ggml_type) d_type, n_ff); + f.up_off = f.gu_row * (size_t) n_ff; + f.down_off = 2 * f.up_off; + f.bytes = f.down_off + f.d_row * (size_t) n_embd; + f.act_bytes = ggml_row_size(tg->vec_dot_type, n_embd); + f.h_bytes = ggml_row_size(td->vec_dot_type, n_ff); + if (f.act_bytes > kNativeActBytes || f.h_bytes > kNativeHBytes) { + err = "native experts: activation larger than the pool's buffers"; + return false; + } + return true; +} + +void native_quant_act(const NativeFmt& f, const float* x, void* dst) { + traits(f.gu_act)->from_float(x, dst, f.n_embd); +} + +void native_quant_h(const NativeFmt& f, const float* h, void* dst) { + traits(f.d_act)->from_float(h, dst, f.n_ff); +} + +void native_gu_rows(const NativeFmt& f, const uint8_t* blob, const void* const* act, int nt, float* const* ff, + int r0, int r1) { + // the AVX-512 kernels decode the weights once for all tokens: 2.0-2.4x ggml-cpu at three tokens, no faster at + // one (both are bound by the codebook lookups, ~5 GB/s per core), measured by native_expert_parity + static const bool avx512 = cpu_avx512_ok() && std::getenv("STRATA_NO_IQ512") == nullptr; + if (avx512 && nt >= 2 && iq512_supported(f.gu_type)) { // one token: ggml-cpu is as fast or faster + iq512_gu_rows(f.gu_type, blob, f.gu_row, f.up_off, (int) f.n_embd, act, nt, ff, r0, r1); + return; + } + const ggml_vec_dot_t dot = traits(f.gu_type)->vec_dot; + const int n = (int) f.n_embd; + for (int r = r0; r < r1; ++r) { + const uint8_t* gr = blob + (size_t) r * f.gu_row; + const uint8_t* ur = blob + f.up_off + (size_t) r * f.gu_row; + for (int t = 0; t < nt; ++t) { + float g = 0.f, u = 0.f; + dot(n, &g, 0, gr, 0, act[t], 0, 1); + dot(n, &u, 0, ur, 0, act[t], 0, 1); + ff[t][r] = (g / (1.f + std::exp(-g))) * u; + } + } +} + +void native_down_rows(const NativeFmt& f, const uint8_t* blob, const void* const* hq, int nt, float* const* out, + int r0, int r1) { + const ggml_vec_dot_t dot = traits(f.d_type)->vec_dot; + const int n = (int) f.n_ff; + for (int r = r0; r < r1; ++r) { + const uint8_t* dr = blob + f.down_off + (size_t) r * f.d_row; + for (int t = 0; t < nt; ++t) { + float s = 0.f; + dot(n, &s, 0, dr, 0, hq[t], 0, 1); + out[t][r] = s; + } + } +} + +} // namespace strata::kernels::cpu diff --git a/src/kernels/cpu/pool.cpp b/src/kernels/cpu/pool.cpp new file mode 100644 index 00000000..2b963ad0 --- /dev/null +++ b/src/kernels/cpu/pool.cpp @@ -0,0 +1,397 @@ +// src/kernels/cpu/pool.cpp - P2.S3: the CPU expert pool. Read pool.hpp first; it explains the protocol. +#include "strata/kernels/cpu/pool.hpp" +#include "strata/kernels/cpu/expert_layout.hpp" + +#include +#include +#include +#include + +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#endif + +namespace strata::kernels::cpu { + +std::vector physical_cores(bool skip_first) { + std::vector cores; +#if defined(_WIN32) + // Ask the OS rather than assuming a layout. `hardware_concurrency()` returns LOGICAL processors, and on + // every SMT machine half of them are siblings - pinning one worker to each of the first N would put two + // workers on each physical core and halve the bandwidth the expert kernel is bound by. + DWORD len = 0; + GetLogicalProcessorInformationEx(RelationProcessorCore, nullptr, &len); + if (len == 0) { + for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) cores.push_back((int) i); + } else { + std::vector buf(len); + if (GetLogicalProcessorInformationEx(RelationProcessorCore, + (PSYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX) buf.data(), &len)) { + const char* p = buf.data(); + const char* end = p + len; + while (p < end) { + const auto* e = (const SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX*) p; + if (e->Relationship == RelationProcessorCore) { + const GROUP_AFFINITY& g = e->Processor.GroupMask[0]; + for (int bit = 0; bit < 64; ++bit) + if (g.Mask & (1ull << bit)) { cores.push_back((int) (g.Group * 64 + bit)); break; } + } + p += e->Size; + } + } + } +#else + cpu_set_t set; + CPU_ZERO(&set); + if (sched_getaffinity(0, sizeof set, &set) == 0) + for (int i = 0; i < CPU_SETSIZE; ++i) + if (CPU_ISSET(i, &set)) cores.push_back(i); + else + for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) cores.push_back((int) i); +#endif + if (skip_first && !cores.empty()) cores.erase(cores.begin()); + return cores; +} + +namespace { + +void pin_this_thread(int core) { + if (core < 0) return; +#if defined(_WIN32) + SetThreadAffinityMask(GetCurrentThread(), (DWORD_PTR) 1 << (core & 63)); +#else + cpu_set_t set; + CPU_ZERO(&set); + CPU_SET(core, &set); + pthread_setaffinity_np(pthread_self(), sizeof set, &set); +#endif +} + +} // namespace + +long long pin_current_thread(int core) { + if (core < 0) return -1; +#if defined(_WIN32) + // `SetThreadAffinityMask` RETURNS the previous mask, or 0 on failure - so 0 doubles as the error, which is + // why the caller must not treat it as a restorable value. + const DWORD_PTR prev = SetThreadAffinityMask(GetCurrentThread(), (DWORD_PTR) 1 << (core & 63)); + return prev == 0 ? -1 : (long long) prev; +#else + cpu_set_t prev; + CPU_ZERO(&prev); + if (pthread_getaffinity_np(pthread_self(), sizeof prev, &prev) != 0) return -1; + unsigned long mask = 0; + for (int i = 0; i < CPU_SETSIZE && i < 64; ++i) + if (CPU_ISSET(i, &prev)) mask |= 1ul << i; + pin_this_thread(core); + return (long long) mask; +#endif +} + +void restore_thread_affinity(long long previous) { + if (previous <= 0) return; +#if defined(_WIN32) + SetThreadAffinityMask(GetCurrentThread(), (DWORD_PTR) previous); +#else + cpu_set_t set; + CPU_ZERO(&set); + for (int i = 0; i < 64; ++i) + if ((previous >> i) & 1) CPU_SET(i, &set); + pthread_setaffinity_np(pthread_self(), sizeof set, &set); +#endif +} + +ExpertPool::ExpertPool(int n_workers, bool pin, bool host_works) : host_works_(host_works) { + const std::vector cores = physical_cores(true); + n_ = n_workers > 0 ? n_workers : (int) cores.size(); + if (n_ < 1) n_ = 1; + scratch_.resize((size_t) n_); + split_.resize((size_t) kMaxSplit); + split_multi_.resize((size_t) kMaxSplitMulti); + threads_.reserve((size_t) n_); + for (int i = 0; i < n_; ++i) { + const int core = pin ? (i < (int) cores.size() ? cores[(size_t) i] : -1) : -1; + threads_.emplace_back([this, i, core] { + pin_this_thread(core); + worker(i); + }); + } +} + +ExpertPool::~ExpertPool() { + stop_.store(true, std::memory_order_release); + // Bump the epoch so a PARKED worker notices the stop flag rather than sleeping through it. + epoch_.fetch_add(1, std::memory_order_release); + for (auto& t : threads_) t.join(); +} + +void ExpertPool::worker(int id) { + uint32_t seen = 0; + // ARRIVE at the park before the first wait, so `parked_ == n_` is true from construction. Counting only + // on the RETURN from a drain leaves `parked_` at 0 until each worker has finished one batch, and the first + // `run()` - which waits for `parked_ == n_` before publishing - then deadlocks. It deadlocks on the very + // first call, which is the good case; a version that deadlocked on the second would be far worse. + parked_.fetch_add(1, std::memory_order_acq_rel); + for (;;) { + // Park: wait for work. `_mm_pause` rather than a bare spin because it yields the pipeline to the + // sibling hyperthread; `epoch_` is bumped once per LAYER, not once per expert, so most of these + // iterations are spent here with nothing to do. + // + // **AND NOTHING ELSE HAPPENS IN HERE.** This loop used to do `pauses_.fetch_add(1)` on every iteration + // - a locked read-modify-write, five workers against one cache line - so the workers spent their wait + // invalidating each other's caches and the very line the host writes to publish work. The counter was + // diagnostic and nothing branched on it. See the note on the atomics in pool.hpp. + while (epoch_.load(std::memory_order_acquire) == seen) { + if (stop_.load(std::memory_order_relaxed)) return; + _mm_pause(); + } + if (stop_.load(std::memory_order_acquire)) return; + seen = epoch_.load(std::memory_order_relaxed); + parked_.fetch_sub(1, std::memory_order_acq_rel); // leaving the park + + // Drain: one claim per iteration, so a slow worker takes fewer experts and a fast one takes more. + // Every job is the same size (all experts are 1,382,400 bytes), so there is nothing to schedule. + drain(id, scratch_[(size_t) id]); + parked_.fetch_add(1, std::memory_order_acq_rel); // back at the park + } +} + +void ExpertPool::drain(int id, ExpertScratch& scratch) { + (void) id; + for (;;) { + const uint32_t i = head_.fetch_add(1, std::memory_order_relaxed); + if (i >= (uint32_t) njobs_) break; + if (mode_ == 0) { + const ExpertJob& j = jobs_[i]; + s2_expert_vnni_q(j.blob, *j.act, j.out, scratch); + } else if (mode_ == 1) { + const int e = (int) i / parts_a_, part = (int) i % parts_a_; + const int r0 = FF * part / parts_a_, r1 = FF * (part + 1) / parts_a_; + s2_expert_gu_rows(jobs_[e].blob, *jobs_[e].act, split_[(size_t) e].ff, r0, r1); + } else if (mode_ == 2) { + const int e = (int) i / parts_b_, part = (int) i % parts_b_; + const int r0 = H * part / parts_b_, r1 = H * (part + 1) / parts_b_; + s2_expert_down_rows(jobs_[e].blob, split_[(size_t) e].a2, jobs_[e].out, r0, r1); + } else if (mode_ >= 5) { + // plan v0.3 P6: native layers, 5 = gate/up rows, 6 = down rows + const int per = mode_ == 5 ? FF : H; + const int64_t g0 = mrows_ * (int64_t) i / mtasks_, g1 = mrows_ * (int64_t) (i + 1) / mtasks_; + for (int64_t r = g0; r < g1;) { + const int e = (int) (r / per), r0 = (int) (r % per); + const int r1 = (int) std::min(per, r0 + (g1 - r)); + SplitBufMulti& sb = split_multi_[(size_t) e]; + if (mode_ == 5 && nfmt_->gu_type == 42) { + // a native Q2_0 pack: gate and up rows on the Q2_0 kernels, then SwiGLU + thread_local float gbuf[MAXT][FF], ubuf[MAXT][FF]; + float* gp[MAXT]; + float* up[MAXT]; + for (int t = 0; t < mjobs_[e].nt; ++t) { gp[t] = gbuf[t]; up[t] = ubuf[t]; } + const int nbk = (int) (nfmt_->n_embd / 64); + q2_rows_any(mjobs_[e].blob, nfmt_->gu_row, nbk, mjobs_[e].act, mjobs_[e].nt, gp, r0, r1); + q2_rows_any(mjobs_[e].blob + nfmt_->up_off, nfmt_->gu_row, nbk, mjobs_[e].act, mjobs_[e].nt, up, r0, r1); + for (int t = 0; t < mjobs_[e].nt; ++t) + for (int r = r0; r < r1; ++r) + sb.ff[t][r] = (gbuf[t][r] / (1.f + std::exp(-gbuf[t][r]))) * ubuf[t][r]; + } else if (mode_ == 5) { + float* ff[MAXT]; + for (int t = 0; t < mjobs_[e].nt; ++t) ff[t] = sb.ff[t]; + native_gu_rows(*nfmt_, mjobs_[e].blob, mjobs_[e].nact, mjobs_[e].nt, ff, r0, r1); + } else if (nfmt_->d_type == 42) { + // Q2_0 down (most IQ layers): the AVX-512 kernel, ggml-cpu has only a scalar one on x86 + const ActQ* a2[MAXT]; + for (int t = 0; t < mjobs_[e].nt; ++t) a2[t] = &sb.a2[t]; + q2_rows_any(mjobs_[e].blob + nfmt_->down_off, nfmt_->d_row, (int) (nfmt_->n_ff / 64), a2, + mjobs_[e].nt, mjobs_[e].out, r0, r1); + } else { + const void* hq[MAXT]; + for (int t = 0; t < mjobs_[e].nt; ++t) hq[t] = sb.hq[t]; + native_down_rows(*nfmt_, mjobs_[e].blob, hq, mjobs_[e].nt, mjobs_[e].out, r0, r1); + } + r += r1 - r0; + } + } else { + // plan v0.3 P6: an equal range of the phase's rows across ALL its experts (a range may span two) + const int per = mode_ == 3 ? FF : H; + const int64_t g0 = mrows_ * (int64_t) i / mtasks_, g1 = mrows_ * (int64_t) (i + 1) / mtasks_; + for (int64_t r = g0; r < g1;) { + const int e = (int) (r / per), r0 = (int) (r % per); + const int r1 = (int) std::min(per, r0 + (g1 - r)); + SplitBufMulti& sb = split_multi_[(size_t) e]; + if (mode_ == 3) { + float* ff[MAXT]; + for (int t = 0; t < mjobs_[e].nt; ++t) ff[t] = sb.ff[t]; + s2_expert_gu_rows_multi(mjobs_[e].blob, mjobs_[e].act, mjobs_[e].nt, ff, r0, r1); + } else { + const ActQ* a2[MAXT]; + for (int t = 0; t < mjobs_[e].nt; ++t) a2[t] = &sb.a2[t]; + s2_expert_down_rows_multi(mjobs_[e].blob, a2, mjobs_[e].nt, mjobs_[e].out, r0, r1); + } + r += r1 - r0; + } + } + done_.fetch_add(1, std::memory_order_release); + } +} + +void ExpertPool::run_phase(int mode, int n_tasks) { + while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) _mm_pause(); + mode_ = mode; + njobs_ = n_tasks; + head_.store(0, std::memory_order_relaxed); + done_.store(0, std::memory_order_relaxed); + epoch_.fetch_add(1, std::memory_order_release); + if (host_works_) drain(-1, host_scratch_); + while (done_.load(std::memory_order_acquire) != (uint32_t) n_tasks) _mm_pause(); + while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) _mm_pause(); +} + +void ExpertPool::run_split(ExpertJob* jobs, int n) { + if (n <= 0) return; + if (n > kMaxSplit || n_ == 1 || expert_oracle_q8_0_enabled()) { run(jobs, n); return; } + const auto t0 = std::chrono::steady_clock::now(); + jobs_ = jobs; + const int threads = n_ + (host_works_ ? 1 : 0); + // about three tasks per thread in each phase, so the tail is short + parts_a_ = (std::max)(1, (3 * threads + n - 1) / n); + parts_b_ = parts_a_; + run_phase(1, n * parts_a_); + for (int e = 0; e < n; ++e) act_quant_q8_1(split_[(size_t) e].ff, FF, split_[(size_t) e].a2); + run_phase(2, n * parts_b_); + mode_ = 0; + ms_drain_ += std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); +} + +void ExpertPool::run_split_multi(ExpertJobMulti* jobs, int n) { + if (n <= 0) return; + if (n > kMaxSplitMulti || expert_oracle_q8_0_enabled()) { + // one token at a time through the single-token path (the oracle contract has no multi kernel) + std::vector single; + for (int e = 0; e < n; ++e) + for (int t = 0; t < jobs[e].nt; ++t) { + ExpertJob j; + j.blob = jobs[e].blob; + j.act = jobs[e].act[t]; + j.out = jobs[e].out[t]; + single.push_back(j); + } + for (size_t i = 0; i < single.size(); i += kMaxSplit) + run_split(single.data() + i, (int) (std::min)((size_t) kMaxSplit, single.size() - i)); + return; + } + const auto t0 = std::chrono::steady_clock::now(); + mjobs_ = jobs; + const int threads = n_ + (host_works_ ? 1 : 0); + mtasks_ = 3 * threads; + mrows_ = (int64_t) n * FF; + run_phase(3, mtasks_); + const auto t1 = std::chrono::steady_clock::now(); + for (int e = 0; e < n; ++e) + for (int t = 0; t < jobs[e].nt; ++t) + act_quant_q8_1(split_multi_[(size_t) e].ff[t], FF, split_multi_[(size_t) e].a2[t]); + const auto t2 = std::chrono::steady_clock::now(); + mrows_ = (int64_t) n * H; + run_phase(4, mtasks_); + const auto t3 = std::chrono::steady_clock::now(); + ms_multi_gu += std::chrono::duration(t1 - t0).count(); + ms_multi_q += std::chrono::duration(t2 - t1).count(); + ms_multi_down += std::chrono::duration(t3 - t2).count(); + multi_bytes += (int64_t) n * (int64_t) BLOB; + mode_ = 0; + ms_drain_ += std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); +} + +void ExpertPool::run_split_multi_native(const NativeFmt& f, ExpertJobMulti* jobs, int n) { + if (n <= 0) return; + const auto t0 = std::chrono::steady_clock::now(); + // more distinct experts than buffers: run them in batches + for (int b0 = 0; b0 < n; b0 += kMaxSplitMulti) { + const int nb = (std::min)(kMaxSplitMulti, n - b0); + mjobs_ = jobs + b0; + nfmt_ = &f; + const int threads = n_ + (host_works_ ? 1 : 0); + mtasks_ = 3 * threads; + mrows_ = (int64_t) nb * FF; + const auto a = std::chrono::steady_clock::now(); + run_phase(5, mtasks_); + const auto b = std::chrono::steady_clock::now(); + for (int e = 0; e < nb; ++e) + for (int t = 0; t < mjobs_[e].nt; ++t) + if (f.d_type == 42) act_quant_any(split_multi_[(size_t) e].ff[t], FF, split_multi_[(size_t) e].a2[t]); + else native_quant_h(f, split_multi_[(size_t) e].ff[t], split_multi_[(size_t) e].hq[t]); + const auto c = std::chrono::steady_clock::now(); + mrows_ = (int64_t) nb * H; + run_phase(6, mtasks_); + const auto d = std::chrono::steady_clock::now(); + ms_multi_gu += std::chrono::duration(b - a).count(); + ms_multi_q += std::chrono::duration(c - b).count(); + ms_multi_down += std::chrono::duration(d - c).count(); + } + multi_bytes += (int64_t) n * (int64_t) f.bytes; + mode_ = 0; + ms_drain_ += std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); +} + +void ExpertPool::run(ExpertJob* jobs, int n) { + if (n <= 0) return; + if (n_ == 1) { // no workers: run inline, so a single-core machine still produces a token + for (int i = 0; i < n; ++i) s2_expert_vnni_q(jobs[i].blob, *jobs[i].act, jobs[i].out, scratch_[0]); + return; + } + // Wait for every worker to be parked BEFORE touching the batch, so the publish below is the only thing + // that can move a worker into the drain loop. + // + // THE THREE PHASES ARE TIMED SEPARATELY. They were one number, which cannot distinguish a pool that is + // slow at the WORK from one that is slow at the SYNCHRONISATION - and those need opposite fixes. + const auto t_a = std::chrono::steady_clock::now(); + while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) _mm_pause(); + const auto t_b = std::chrono::steady_clock::now(); + jobs_ = jobs; + njobs_ = n; + mode_ = 0; + head_.store(0, std::memory_order_relaxed); + done_.store(0, std::memory_order_relaxed); + epoch_.fetch_add(1, std::memory_order_release); // release: jobs_/njobs_ are visible before the bump + + // ---- **THE HOST DRAINS TOO (R2.2), INSTEAD OF SPINNING ON `done_`.** + // + // The loop below used to be `while (done_ != n) _mm_pause();`. The host is pinned to core 0 - the core + // `physical_cores(true)` deliberately keeps the five workers off - so for the whole drain that core was + // idle while five cores did six cores' worth of work. Measured before the change: 33.7 GB/s against + // 5/6 x 44.14 = 36.8 for five workers and 44.14 for six. + // + // The host claims through the SAME `head_` counter, so this is not a second scheduler and nothing about + // the ordering changes: `head_` is a single `fetch_add`, every job is the same size, and a thread that + // arrives late simply claims nothing. `done_` is still the completion signal and the host still waits for + // it - what changed is only that the host arrives at that wait having done a share of the work. + // + // The host's `done_.fetch_add` is a release for the same reason a worker's is: `j.out` is read by the + // device after `run()` returns, so the write must be published, not merely performed. + if (host_works_) { + for (;;) { + const uint32_t i = head_.fetch_add(1, std::memory_order_relaxed); + if (i >= (uint32_t) n) break; + const ExpertJob& j = jobs_[i]; + s2_expert_vnni_q(j.blob, *j.act, j.out, host_scratch_); + done_.fetch_add(1, std::memory_order_release); + } + } + + while (done_.load(std::memory_order_acquire) != (uint32_t) n) _mm_pause(); + // And park again, so the next `run` starts from a known state. See the header for why `done` alone is + // not enough. + const auto t_c = std::chrono::steady_clock::now(); + while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) _mm_pause(); + const auto t_d = std::chrono::steady_clock::now(); + + ms_wait_park_ += std::chrono::duration(t_b - t_a).count(); + ms_drain_ += std::chrono::duration(t_c - t_b).count(); + ms_repark_ += std::chrono::duration(t_d - t_c).count(); +} + +} // namespace strata::kernels::cpu diff --git a/src/kernels/cpu/pool_test.cpp b/src/kernels/cpu/pool_test.cpp new file mode 100644 index 00000000..dbc7288a --- /dev/null +++ b/src/kernels/cpu/pool_test.cpp @@ -0,0 +1,224 @@ +// src/kernels/cpu/pool_test.cpp - P2.S3's test for the expert pool. +// +// The pool's correctness claims are small and specific, so they are checked directly rather than through a +// timing number: +// +// 1. THE SAME ANSWER AS SERIAL. Every job's output must be bit-identical to running that expert serially. +// Each worker owns its own `ExpertScratch`, and the jobs share one read-only activation, so there is no +// legitimate source of difference - "close enough" here would be hiding a data race. +// 2. EVERY JOB RUNS EXACTLY ONCE. Claiming is `head.fetch_add`, and an off-by-one in the bound is the +// classic pool bug: it either drops a job or runs one twice. Checked with a sentinel-filled output +// buffer, so a dropped job is visible as an untouched slot rather than as a slightly wrong number. +// 3. REPEATED BATCHES. A pool that works once and hangs or corrupts on the second `run()` is the failure +// mode the park protocol exists to prevent, so `run()` is called many times in a row. +// 4. A BATCH BIGGER AND SMALLER THAN THE WORKER COUNT, because `n < workers` leaves most workers claiming +// nothing and `n > workers` is the real case (10 experts, 5 workers). +#include "strata/kernels/cpu/pool.hpp" +#include "strata/kernels/cpu/expert.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cpu = strata::kernels::cpu; + +namespace { + +bool read_blob(const char* path, long long index, std::vector& out) { + out.assign(cpu::BLOB, 0); + std::FILE* f = std::fopen(path, "rb"); + if (!f) return false; +#if defined(_MSC_VER) + if (_fseeki64(f, index * (long long) cpu::BLOB, SEEK_SET) != 0) { std::fclose(f); return false; } +#else + if (fseeko(f, (off_t) index * (off_t) cpu::BLOB, SEEK_SET) != 0) { std::fclose(f); return false; } +#endif + const size_t got = std::fread(out.data(), 1, out.size(), f); + std::fclose(f); + return got == out.size(); +} + +double now_ms() { + using namespace std::chrono; + return duration(steady_clock::now().time_since_epoch()).count(); +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + const char* path = "pack/full/experts.bin"; + long long layer = 0; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--selftest") selftest = true; + else if (a == "--file" && i + 1 < argc) path = argv[++i]; + else if (a == "--layer" && i + 1 < argc) layer = std::atoll(argv[++i]); + else { std::fprintf(stderr, "usage: pool_test [--selftest] [--file P] [--layer N]\n"); return 2; } + } + + const cpu::CpuFeatures feat = cpu::cpu_features(); + if (!feat.usable()) { + std::printf(" CPU lacks %s - the VNNI path cannot run here; pool test SKIPPED, not passed.\n", + feat.reason()); + std::printf("\npool: 0 failures, 1 SKIPPED\n"); + return 0; + } + + int bad = 0; + + // ---- ten experts off one layer, which is exactly what a token uses + const int NEXP = 10; + std::vector> blobs((size_t) NEXP); + for (int e = 0; e < NEXP; ++e) + if (!read_blob(path, layer * 512 + e, blobs[(size_t) e])) { + std::fprintf(stderr, "cannot read expert %d of layer %lld from %s\n", e, layer, path); + return 2; + } + + std::mt19937 rng(99); + std::normal_distribution gauss(0.0f, 1.0f); + std::vector x(cpu::H); + for (auto& v : x) v = gauss(rng); + + cpu::ActQ act; + cpu::act_quant_q8_1(x.data(), cpu::H, act); + + // ---- serial reference + std::vector ref((size_t) NEXP * cpu::H); + { + cpu::ExpertScratch ws; + for (int e = 0; e < NEXP; ++e) + cpu::s2_expert_vnni_q(blobs[(size_t) e].data(), act, ref.data() + (size_t) e * cpu::H, ws); + } + + // ---- the pool + const std::vector cores = cpu::physical_cores(true); + const int hw = (int) std::thread::hardware_concurrency(); + std::printf(" %-44s %d logical, %d physical (skipping the first)\n", "cores the pool will use", + hw, (int) cores.size()); + + cpu::ExpertPool pool; + std::printf(" %-44s %d\n", "workers", pool.workers()); + + std::vector jobs((size_t) NEXP); + std::vector got((size_t) NEXP * cpu::H); + std::vector weights((size_t) NEXP); + for (int e = 0; e < NEXP; ++e) weights[(size_t) e] = 0.1f * (float) (e + 1); + + // ---- 1 + 2: same answer as serial, and every job exactly once. + // The output buffer starts at a sentinel no real result can equal, so a job that never runs shows up as + // an untouched slot instead of as a plausible number. + const float SENTINEL = -1.2345e33f; + for (int e = 0; e < NEXP; ++e) { + jobs[(size_t) e].blob = blobs[(size_t) e].data(); + jobs[(size_t) e].act = &act; + jobs[(size_t) e].out = got.data() + (size_t) e * cpu::H; + jobs[(size_t) e].weight = weights[(size_t) e]; + jobs[(size_t) e].slot = e; + std::fill(jobs[(size_t) e].out, jobs[(size_t) e].out + cpu::H, SENTINEL); + } + pool.run(jobs.data(), NEXP); + + long long not_run = 0, diff = 0; + float worst = 0.f; + for (int e = 0; e < NEXP; ++e) + for (int i = 0; i < cpu::H; ++i) { + const float a = ref[(size_t) e * cpu::H + i], b = got[(size_t) e * cpu::H + i]; + if (b == SENTINEL) { ++not_run; continue; } + if (std::memcmp(&a, &b, 4) != 0) ++diff; + worst = std::fmax(worst, std::fabs(a - b)); + } + std::printf(" %-44s %s (%lld of %d outputs untouched)\n", "every job ran exactly once", + not_run ? "*** NO ***" : "yes", not_run, NEXP * cpu::H); + if (not_run) ++bad; + // BIT-IDENTICAL, not "close". Each worker has a private scratch and the activation is shared read-only, + // so any difference at all is a race or a scratch collision - and a tolerance would hide exactly that. + std::printf(" %-44s %s (%lld of %d differ, worst |d| %.3e)\n", "identical to the serial run", + diff ? "*** NO ***" : "yes", diff, NEXP * cpu::H, (double) worst); + if (diff) ++bad; + + // ---- 3: repeated batches. A park protocol that works once and corrupts on the second call is the + // exact failure this loop is here to catch. + { + int repeats_bad = 0; + for (int r = 0; r < 200; ++r) { + for (int e = 0; e < NEXP; ++e) std::fill(jobs[(size_t) e].out, jobs[(size_t) e].out + cpu::H, SENTINEL); + pool.run(jobs.data(), NEXP); + for (int e = 0; e < NEXP && !repeats_bad; ++e) + for (int i = 0; i < cpu::H; ++i) + if (std::memcmp(&ref[(size_t) e * cpu::H + i], &got[(size_t) e * cpu::H + i], 4) != 0) { + ++repeats_bad; + break; + } + } + std::printf(" %-44s %s (200 consecutive batches)\n", "repeated batches stay correct", + repeats_bad ? "*** NO ***" : "yes"); + if (repeats_bad) ++bad; + } + + // ---- 4: batch sizes either side of the worker count + { + int size_bad = 0; + for (int n : {1, 2, pool.workers() - 1 > 0 ? pool.workers() - 1 : 1, pool.workers(), + pool.workers() + 1, NEXP}) { + if (n < 1 || n > NEXP) continue; + for (int e = 0; e < n; ++e) std::fill(jobs[(size_t) e].out, jobs[(size_t) e].out + cpu::H, SENTINEL); + pool.run(jobs.data(), n); + for (int e = 0; e < n && !size_bad; ++e) + for (int i = 0; i < cpu::H; ++i) + if (std::memcmp(&ref[(size_t) e * cpu::H + i], &got[(size_t) e * cpu::H + i], 4) != 0) { + ++size_bad; + break; + } + } + std::printf(" %-44s %s (1, w-1, w, w+1, 10)\n", "batch sizes around the worker count", + size_bad ? "*** NO ***" : "yes"); + if (size_bad) ++bad; + } + + // ---- and the number that matters for the ledger: c for one layer, 10 experts over 48 layers. + // + // BEST OF N, not a mean. The first version divided a 10-run total by 10 and reported 6.604 ms per layer - + // 2.1 GB/s against L9's measured 42.55 - purely because a CUDA build was running on the same 6-core + // machine at the time. A mean over a contended machine measures the contention; a best-of measures the + // instrument, and every other timing tool in this project already takes the best. The spread is printed + // so a contended run is visible rather than being read as a regression. + { + const int REPS = 20; + std::vector t((size_t) REPS, 0.0); + for (int r = 0; r < REPS; ++r) { + const double t0 = now_ms(); + pool.run(jobs.data(), NEXP); + t[(size_t) r] = now_ms() - t0; + } + std::vector sorted = t; + std::sort(sorted.begin(), sorted.end()); + const double best = sorted.front(), median = sorted[sorted.size() / 2], worst = sorted.back(); + const double gbs = (double) NEXP * cpu::BLOB / (best * 1e-3) / 1e9; + std::printf("\n %-44s %7.3f ms (%.1f GB/s)\n", "10 experts, one layer (best of 20)", + best, gbs); + std::printf(" %-44s %7.3f / %7.3f ms (spread %.2fx)\n", "median / worst", median, worst, + median / (best > 0 ? best : 1)); + std::printf(" %-44s %7.3f ms -> %.1f tok/s for the full 48 layers\n", + "extrapolated to 48 layers", best * 48, 1000.0 / (best * 48)); + std::printf(" L9 measured 42.55 GB/s on 6 cores; this pool uses %d workers (core 0 is left to the\n", + pool.workers()); + std::printf(" host loop), so %.1f GB/s x 6/%d = %.1f GB/s is the per-core comparison.\n", + gbs, pool.workers(), gbs * 6.0 / pool.workers()); + if (median / (best > 0 ? best : 1) > 1.5) + std::printf(" *** the spread is over 1.5x: this machine was CONTENDED and the median is not a\n" + " property of the pool. Re-run on a quiet machine before quoting it. ***\n"); + } + + std::printf("\npool: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("pool_test OK\n"); + return 0; +} diff --git a/src/kernels/cpu/q2_avx2.cpp b/src/kernels/cpu/q2_avx2.cpp new file mode 100644 index 00000000..8e276a5e --- /dev/null +++ b/src/kernels/cpu/q2_avx2.cpp @@ -0,0 +1,130 @@ +// src/kernels/cpu/q2_avx2.cpp - plan v0.3 P6: the Q2_0 expert rows and the activation quantizer for CPUs +// without AVX-512 (Intel Core 12th-14th gen and Core Ultra, AMD Zen 2/3). +// +// Compiled with AVX2 only, so nothing here can fault on those CPUs. The arithmetic is the AVX-512 kernels': +// codes 0..3 against the int8 activation per 32-value chunk, times the weight scale and the chunk scale, minus +// the weight scale times the chunk's `hx` (the -1 code offset); the quantizer is the scalar rule, bit for bit. +#include "strata/kernels/cpu/expert.hpp" + +#include + +#include +#include + +namespace strata::kernels::cpu { +namespace { + +inline float h2f(const uint8_t* p) { + uint16_t h; + std::memcpy(&h, p, 2); + return _mm_cvtss_f32(_mm_cvtph_ps(_mm_cvtsi32_si128((int) h))); +} + +// 16 bytes of 2-bit codes (value i in byte i/4, bits 2*(i%4)) -> 64 codes in value order, two 32-byte vectors +inline void unpack64(const uint8_t* codes, __m256i& lo, __m256i& hi) { + const __m128i b = _mm_loadu_si128((const __m128i*) codes); + const __m128i m3 = _mm_set1_epi8(3); + const __m128i c0 = _mm_and_si128(b, m3); + const __m128i c1 = _mm_and_si128(_mm_srli_epi16(b, 2), m3); + const __m128i c2 = _mm_and_si128(_mm_srli_epi16(b, 4), m3); + const __m128i c3 = _mm_and_si128(_mm_srli_epi16(b, 6), m3); + const __m128i a0 = _mm_unpacklo_epi8(c0, c1), a1 = _mm_unpacklo_epi8(c2, c3); // bytes 0..7 + const __m128i b0 = _mm_unpackhi_epi8(c0, c1), b1 = _mm_unpackhi_epi8(c2, c3); // bytes 8..15 + lo = _mm256_set_m128i(_mm_unpackhi_epi16(a0, a1), _mm_unpacklo_epi16(a0, a1)); // values 0..31 + hi = _mm256_set_m128i(_mm_unpackhi_epi16(b0, b1), _mm_unpacklo_epi16(b0, b1)); // values 32..63 +} + +template +inline void row_multi(const uint8_t* row, const ActQ* const* a, int nblocks, float* res) { + __m256 acc[NT]; + float corr[NT]; + for (int t = 0; t < NT; ++t) { acc[t] = _mm256_setzero_ps(); corr[t] = 0.f; } + const __m256i ones = _mm256_set1_epi16(1); + for (int b = 0; b < nblocks; ++b) { + const uint8_t* blk = row + (size_t) b * 18; + const float d = h2f(blk); + __m256i lo, hi; + unpack64(blk + 2, lo, hi); + for (int t = 0; t < NT; ++t) { + const int8_t* q = a[t]->q + b * 64; + const __m256i s0 = _mm256_madd_epi16(_mm256_maddubs_epi16(lo, _mm256_loadu_si256((const __m256i*) q)), ones); + const __m256i s1 = _mm256_madd_epi16(_mm256_maddubs_epi16(hi, _mm256_loadu_si256((const __m256i*) (q + 32))), ones); + acc[t] = _mm256_fmadd_ps(_mm256_set1_ps(d * a[t]->scale[2 * b]), _mm256_cvtepi32_ps(s0), acc[t]); + acc[t] = _mm256_fmadd_ps(_mm256_set1_ps(d * a[t]->scale[2 * b + 1]), _mm256_cvtepi32_ps(s1), acc[t]); + corr[t] += d * (a[t]->hx[2 * b] + a[t]->hx[2 * b + 1]); + } + } + for (int t = 0; t < NT; ++t) { + const __m128 h = _mm_add_ps(_mm256_castps256_ps128(acc[t]), _mm256_extractf128_ps(acc[t], 1)); + const __m128 s = _mm_add_ps(h, _mm_movehl_ps(h, h)); + res[t] = _mm_cvtss_f32(_mm_add_ss(s, _mm_movehdup_ps(s))) - corr[t]; + } +} + +template +void rows(const uint8_t* w, size_t row_bytes, int nblocks, const ActQ* const* a, float* const* out, int r0, int r1) { + float res[NT]; + for (int r = r0; r < r1; ++r) { + row_multi(w + (size_t) r * row_bytes, a, nblocks, res); + for (int t = 0; t < NT; ++t) out[t][r] = res[t]; + } +} + +} // namespace + +void q2_0_gguf_rows_multi_avx2(const uint8_t* w, size_t row_bytes, int nblocks, const ActQ* const* a, int nt, + float* const* out, int r0, int r1) { + switch (nt) { + case 1: rows<1>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 2: rows<2>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 3: rows<3>(w, row_bytes, nblocks, a, out, r0, r1); break; + case 4: rows<4>(w, row_bytes, nblocks, a, out, r0, r1); break; + default: + for (int t0 = 0; t0 < nt; t0 += 4) { + const int k = nt - t0 < 4 ? nt - t0 : 4; + q2_0_gguf_rows_multi_avx2(w, row_bytes, nblocks, a + t0, k, out + t0, r0, r1); + } + } +} + +void act_quant_q8_1_avx2(const float* x, int n, ActQ& a) { + a.nchunks = n / QKA; + const __m256 absmask = _mm256_castsi256_ps(_mm256_set1_epi32(0x7fffffff)); + const __m256 half = _mm256_set1_ps(0.5f), mhalf = _mm256_set1_ps(-0.5f), zero = _mm256_setzero_ps(); + const __m256i lo = _mm256_set1_epi32(-127), hi = _mm256_set1_epi32(127); + for (int k = 0; k < a.nchunks; ++k) { + const float* xb = x + k * QKA; + __m256 v[4]; + __m256 m = _mm256_setzero_ps(); + for (int i = 0; i < 4; ++i) { + v[i] = _mm256_loadu_ps(xb + 8 * i); + m = _mm256_max_ps(m, _mm256_and_ps(v[i], absmask)); + } + __m128 h = _mm_max_ps(_mm256_castps256_ps128(m), _mm256_extractf128_ps(m, 1)); + h = _mm_max_ps(h, _mm_movehl_ps(h, h)); + const float amax = _mm_cvtss_f32(_mm_max_ss(h, _mm_movehdup_ps(h))); + const float s = amax > 0.f ? amax / 127.f : 0.f; + const float inv = s > 0.f ? 1.f / s : 0.f; + const __m256 vinv = _mm256_set1_ps(inv); + __m256i sum = _mm256_setzero_si256(); + alignas(32) int32_t qi[QKA]; + for (int i = 0; i < 4; ++i) { + const __m256 t = _mm256_mul_ps(v[i], vinv); + const __m256 r = _mm256_add_ps(t, _mm256_blendv_ps(mhalf, half, _mm256_cmp_ps(t, zero, _CMP_GE_OQ))); + __m256i q = _mm256_cvttps_epi32(r); + q = _mm256_min_epi32(_mm256_max_epi32(q, lo), hi); + sum = _mm256_add_epi32(sum, q); + _mm256_store_si256((__m256i*) (qi + 8 * i), q); + } + for (int j = 0; j < QKA; ++j) a.q[k * QKA + j] = (int8_t) qi[j]; + __m128i s4 = _mm_add_epi32(_mm256_castsi256_si128(sum), _mm256_extracti128_si256(sum, 1)); + s4 = _mm_add_epi32(s4, _mm_shuffle_epi32(s4, 0x4E)); + s4 = _mm_add_epi32(s4, _mm_shuffle_epi32(s4, 0xB1)); + const int32_t total = _mm_cvtsi128_si32(s4); + a.scale[k] = s; + a.sum[k] = total; + a.hx[k] = s * (float) total; + } +} + +} // namespace strata::kernels::cpu diff --git a/src/kernels/cuda/bf16_gemv.cu b/src/kernels/cuda/bf16_gemv.cu new file mode 100644 index 00000000..04473ad0 --- /dev/null +++ b/src/kernels/cuda/bf16_gemv.cu @@ -0,0 +1,141 @@ +// src/kernels/cuda/bf16_gemv.cu - the BF16 GEMV. See the header for why it is not `s_gemv`. +#include "strata/kernels/bf16_gemv.hpp" + +#include "strata/kernels/bf16_bits.hpp" + +#include + +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int THREADS = 256; + +/// One thread per output row, walking it contiguously. Kept as the naive reference the split version is +/// checked against, and used directly for the shapes where the output width is already large. +__global__ void bf16_gemv_naive_kernel(const uint16_t* __restrict__ x, const uint16_t* __restrict__ w, + float* __restrict__ y, long long n_in, long long n_out) { + const long long o = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (o >= n_out) return; + const uint16_t* row = w + o * n_in; + float acc = 0.0f; + for (long long i = 0; i < n_in; ++i) + acc += f32_from_bf16(x[i]) * f32_from_bf16(row[i]); + y[o] = acc; +} + +/// One WARP per output row, lanes striding the reduction axis. For a fixed `i` consecutive lanes touch +/// consecutive addresses in this layout, so the load is coalesced - the property that took `gr_read` from +/// 262 ms/token to 8.9. +__global__ void bf16_gemv_warp_kernel(const uint16_t* __restrict__ x, const uint16_t* __restrict__ w, + float* __restrict__ y, long long n_in, long long n_out) { + const int warps_per_block = (int) (blockDim.x >> 5); + const long long o = (long long) blockIdx.x * warps_per_block + (threadIdx.x >> 5); + if (o >= n_out) return; + const int lane = threadIdx.x & 31; + const uint16_t* row = w + o * n_in; + float acc = 0.0f; + for (long long i = lane; i < n_in; i += 32) + acc += f32_from_bf16(x[i]) * f32_from_bf16(row[i]); + for (int off = 16; off > 0; off >>= 1) acc += __shfl_down_sync(0xFFFFFFFFu, acc, off); + if (lane == 0) y[o] = acc; +} + +/// TPR lanes cooperate on ONE row's reduction: `threads_per_row` threads each take a strided slice and the +/// block reduces through shared memory. Used when the output width is too small to fill the machine. +__global__ void bf16_gemv_split_kernel(const uint16_t* __restrict__ x, const uint16_t* __restrict__ w, + float* __restrict__ y, long long n_in, long long n_out, int tpr) { + extern __shared__ float scratch[]; + const long long o = blockIdx.x; + if (o >= n_out) return; + const int t = threadIdx.x; // 0 .. tpr-1 + const uint16_t* row = w + o * n_in; + float acc = 0.0f; + for (long long i = t; i < n_in; i += tpr) + acc += f32_from_bf16(x[i]) * f32_from_bf16(row[i]); + // block reduction; `tpr` is at most a few hundred, so a tree in shared is enough + scratch[t] = acc; + __syncthreads(); + for (int off = tpr >> 1; off > 0; off >>= 1) { + if (t < off) scratch[t] += scratch[t + off]; + __syncthreads(); + } + if (t == 0) y[o] = scratch[0]; +} + +inline void finish(void* stream, const char* what) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "%s launch: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } + if (stream != nullptr) return; + const cudaError_t s = cudaDeviceSynchronize(); + if (s != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(s)); + std::exit(1); + } +} + +} // namespace + +void bf16_gemv(const uint16_t* x, const uint16_t* w, float* y, int64_t n_in, int64_t n_out, void* stream) { + if (n_in <= 0 || n_out <= 0) return; + // **THIS USED TO ALWAYS USE THE NAIVE KERNEL, AND THE NAIVE KERNEL IS UNCOALESCED.** + // + // `bf16_gemv_naive_kernel` gives one THREAD per output row and walks the row contiguously, so at a fixed + // `i` the 32 threads of a warp read `w[(o+k)*n_in + i]` - addresses `n_in * 2` bytes apart. Every load in + // the kernel's inner loop costs 32 transactions instead of 1. The comment above that kernel claims it is + // "used directly for the shapes where the output width is already large", but nothing ever made that + // choice: this function called it unconditionally, at every shape. + // + // What that costs, from the nsys per-kernel table: **4.66 ms/token across 24 calls, 194 us each** - the + // third largest kernel in the engine at the time, moving 1.19 GiB/token at ~210 GB/s where a plain read at + // this geometry measures 641 GB/s (`bench/micro/p32_floor.cu`). + // + // `bf16_gemv_warp_kernel` exists, is checked against this one by `bf16_gemv_parity`, and is coalesced - + // consecutive lanes touch consecutive addresses - which is the property `gr_read`'s comment records as + // having taken it from 262 ms/token to 8.9. The threshold below is where warp-per-row has enough rows to + // fill the machine: 8 warps per block, so 64 rows is 8 blocks and 128 is 16, against 48 SMs. + if (n_out >= 64) { + const int warps = THREADS / 32; + const unsigned grid = (unsigned) ((n_out + warps - 1) / warps); + bf16_gemv_warp_kernel<<>>(x, w, y, n_in, n_out); + finish(stream, "bf16_gemv(warp)"); + return; + } + // Below the threshold the warp kernel would leave most of the machine idle, and the naive one is at least + // not wasting warps. It is still uncoalesced, so this is the branch to revisit if a small-output caller + // ever shows up hot in the profile. + const unsigned grid = (unsigned) ((n_out + THREADS - 1) / THREADS); + bf16_gemv_naive_kernel<<>>(x, w, y, n_in, n_out); + finish(stream, "bf16_gemv"); +} + +void bf16_gemv_split(const uint16_t* x, const uint16_t* w, float* y, int64_t n_in, int64_t n_out, + int threads_per_row, void* stream) { + if (n_in <= 0 || n_out <= 0) return; + // WARP-PER-ROW when the caller asks for 32, because that path needs no shared memory and no barrier - and + // with 48 output rows it is the configuration that fills the machine. A `tpr` that is not 32 and not a + // power of two is refused rather than quietly rounded, because the tree reduction below needs one. + if (threads_per_row == 32) { + const int warps = THREADS / 32; + const unsigned grid = (unsigned) ((n_out + warps - 1) / warps); + bf16_gemv_warp_kernel<<>>(x, w, y, n_in, n_out); + finish(stream, "bf16_gemv_split(warp)"); + return; + } + if (threads_per_row <= 0 || (threads_per_row & (threads_per_row - 1)) != 0) { + std::fprintf(stderr, "bf16_gemv_split: threads_per_row %d must be a power of two (32 selects the " + "warp-per-row path)\n", threads_per_row); + std::exit(1); + } + const unsigned grid = (unsigned) n_out; + bf16_gemv_split_kernel<<>>(x, w, y, n_in, n_out, threads_per_row); + finish(stream, "bf16_gemv_split"); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/dequant_bf16.cu b/src/kernels/cuda/dequant_bf16.cu new file mode 100644 index 00000000..849d5118 --- /dev/null +++ b/src/kernels/cuda/dequant_bf16.cu @@ -0,0 +1,245 @@ +// src/kernels/cuda/dequant_bf16.cu - see include/strata/kernels/dequant_bf16.hpp. +// +// Arithmetic transcribed from ggml/src/ggml-quants.c at the pinned llama.cpp (MIT License, Copyright (c) 2023-2026 +// The ggml authors): dequantize_row_q2_0/q4_0/q5_0/q8_0/q3_K/q4_K/q5_K/q6_K/iq4_nl/iq4_xs. +#include "strata/kernels/dequant_bf16.hpp" +#include "strata/kernels/iq_kernels.hpp" + +#include +#include + +#include +#include + +namespace strata::kernels { +namespace { + +__device__ __forceinline__ float h2f(const uint8_t* p) { + return __half2float(__ushort_as_half((uint16_t) (p[0] | (p[1] << 8)))); +} +__device__ __forceinline__ uint16_t f2bf(float f) { + uint32_t u = __float_as_uint(f); + u += 0x7fffu + ((u >> 16) & 1u); // round to nearest even + return (uint16_t) (u >> 16); +} +__device__ __forceinline__ void put(uint16_t* o, int i, float v) { o[i] = f2bf(v); } +struct H16 { uint16_t v; }; +__device__ __forceinline__ void put(H16* o, int i, float v) { o[i].v = __half_as_ushort(__float2half_rn(v)); } +__device__ __forceinline__ void put(float* o, int i, float v) { o[i] = v; } + +__constant__ int8_t kv_iq4nl[16] = {-127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113}; + +__device__ __forceinline__ void scale_min_k4(int j, const uint8_t* q, int& d, int& m) { + if (j < 4) { d = q[j] & 63; m = q[j + 4] & 63; } + else { d = (q[j + 4] & 0xF) | ((q[j - 4] >> 6) << 4); m = (q[j + 4] >> 4) | ((q[j] >> 6) << 4); } +} + +// One 32-element group `g` (row-major over the whole slice); `out` points at that group's 32 outputs. +template +__device__ __forceinline__ void group32(const uint8_t* row_blocks, int gi_in_row, T* out) { + if constexpr (TYPE == 42) { // Q2_0: 64 per block of 18 B + const uint8_t* b = row_blocks + (size_t) (gi_in_row / 2) * 18; + const float d = h2f(b); + const int e0 = (gi_in_row % 2) * 32; +#pragma unroll + for (int j = 0; j < 32; ++j) { + const int e = e0 + j; + const int q = (b[2 + e / 4] >> ((e % 4) * 2)) & 3; + put(out, j, (float) (q - 1) * d); + } + } else if constexpr (TYPE == 2) { // Q4_0 + const uint8_t* b = row_blocks + (size_t) gi_in_row * 18; + const float d = h2f(b); + for (int j = 0; j < 16; ++j) { + put(out, j, (float) ((b[2 + j] & 0x0F) - 8) * d); + put(out, j + 16, (float) ((b[2 + j] >> 4) - 8) * d); + } + } else if constexpr (TYPE == 6) { // Q5_0 + const uint8_t* b = row_blocks + (size_t) gi_in_row * 22; + const float d = h2f(b); + const uint32_t qh = (uint32_t) b[2] | ((uint32_t) b[3] << 8) | ((uint32_t) b[4] << 16) | ((uint32_t) b[5] << 24); + for (int j = 0; j < 16; ++j) { + const int xh0 = ((qh >> j) << 4) & 0x10; + const int xh1 = (qh >> (j + 12)) & 0x10; + put(out, j, (float) (((b[6 + j] & 0x0F) | xh0) - 16) * d); + put(out, j + 16, (float) (((b[6 + j] >> 4) | xh1) - 16) * d); + } + } else if constexpr (TYPE == 8) { // Q8_0 + const uint8_t* b = row_blocks + (size_t) gi_in_row * 34; + const float d = h2f(b); + for (int j = 0; j < 32; ++j) put(out, j, (float) (int8_t) b[2 + j] * d); + } else if constexpr (TYPE == 20) { // IQ4_NL + const uint8_t* b = row_blocks + (size_t) gi_in_row * 18; + const float d = h2f(b); + for (int j = 0; j < 16; ++j) { + put(out, j, d * (float) kv_iq4nl[b[2 + j] & 0xf]); + put(out, j + 16, d * (float) kv_iq4nl[b[2 + j] >> 4]); + } + } else if constexpr (TYPE == 11) { // Q3_K: hmask[32] qs[64] scales[12] d + const uint8_t* b = row_blocks + (size_t) (gi_in_row / 8) * 110; + const int gi = gi_in_row % 8, n = gi / 4, jj = gi % 4; + const uint8_t* hm = b; + const uint8_t* q = b + 32 + n * 32; + const uint8_t* sc = b + 96; + const float d_all = h2f(b + 108); + uint32_t aux[4]; + memcpy(aux, sc, 12); + const uint32_t kmask1 = 0x03030303u, kmask2 = 0x0f0f0f0fu, tmp = aux[2]; + aux[2] = ((aux[0] >> 4) & kmask2) | (((tmp >> 4) & kmask1) << 4); + aux[3] = ((aux[1] >> 4) & kmask2) | (((tmp >> 6) & kmask1) << 4); + aux[0] = (aux[0] & kmask2) | (((tmp >> 0) & kmask1) << 4); + aux[1] = (aux[1] & kmask2) | (((tmp >> 2) & kmask1) << 4); + const int8_t* scales = reinterpret_cast(aux); + const int shift = 2 * jj; + const uint8_t m = (uint8_t) (1u << (n * 4 + jj)); + for (int t = 0; t < 32; ++t) { + const int is = n * 8 + jj * 2 + (t >= 16 ? 1 : 0); + const float dl = d_all * (float) (scales[is] - 32); + put(out, t, dl * (float) ((int) ((q[t] >> shift) & 3) - ((hm[t] & m) ? 0 : 4))); + } + } else if constexpr (TYPE == 12) { // Q4_K: d dmin scales[12] qs[128] + const uint8_t* b = row_blocks + (size_t) (gi_in_row / 8) * 144; + const int gi = gi_in_row % 8, j64 = gi / 2, hi = gi % 2; + const float d = h2f(b), dmin = h2f(b + 2); + int sc, m; + scale_min_k4(gi, b + 4, sc, m); + const float d1 = d * (float) sc, m1 = dmin * (float) m; + const uint8_t* q = b + 16 + 32 * j64; + for (int l = 0; l < 32; ++l) put(out, l, d1 * (float) (hi ? (q[l] >> 4) : (q[l] & 0xF)) - m1); + } else if constexpr (TYPE == 13) { // Q5_K: d dmin scales[12] qh[32] qs[128] + const uint8_t* b = row_blocks + (size_t) (gi_in_row / 8) * 176; + const int gi = gi_in_row % 8, j64 = gi / 2, hi = gi % 2; + const float d = h2f(b), dmin = h2f(b + 2); + int sc, m; + scale_min_k4(gi, b + 4, sc, m); + const float d1 = d * (float) sc, m1 = dmin * (float) m; + const uint8_t* qh = b + 16; + const uint8_t* ql = b + 48 + 32 * j64; + const uint8_t u = (uint8_t) (1u << (2 * j64 + hi)); + for (int l = 0; l < 32; ++l) { + const int nib = hi ? (ql[l] >> 4) : (ql[l] & 0xF); + put(out, l, d1 * (float) (nib + ((qh[l] & u) ? 16 : 0)) - m1); + } + } else if constexpr (TYPE == 14) { // Q6_K: ql[128] qh[64] scales[16] d + const uint8_t* b = row_blocks + (size_t) (gi_in_row / 8) * 210; + const int gi = gi_in_row % 8, n = gi / 4, qu = gi % 4; + const uint8_t* ql = b + 64 * n; + const uint8_t* qh = b + 128 + 32 * n; + const int8_t* sc = reinterpret_cast(b + 192) + 8 * n; + const float d = h2f(b + 208); + for (int l = 0; l < 32; ++l) { + const int is = l / 16; + int q; + if (qu == 0) q = (ql[l] & 0xF) | (((qh[l] >> 0) & 3) << 4); + else if (qu == 1) q = (ql[l + 32] & 0xF) | (((qh[l] >> 2) & 3) << 4); + else if (qu == 2) q = (ql[l] >> 4) | (((qh[l] >> 4) & 3) << 4); + else q = (ql[l + 32] >> 4) | (((qh[l] >> 6) & 3) << 4); + put(out, l, d * (float) sc[is + 2 * qu] * (float) (q - 32)); + } + } else if constexpr (TYPE == 23) { // IQ4_XS: d scales_h scales_l[4] qs[128] + const uint8_t* b = row_blocks + (size_t) (gi_in_row / 8) * 136; + const int ib = gi_in_row % 8; + const float d = h2f(b); + const uint16_t scales_h = (uint16_t) (b[2] | (b[3] << 8)); + const int ls = ((b[4 + ib / 2] >> (4 * (ib % 2))) & 0xf) | (((scales_h >> (2 * ib)) & 3) << 4); + const float dl = d * (float) (ls - 32); + const uint8_t* qs = b + 8 + 16 * ib; + for (int j = 0; j < 16; ++j) { + put(out, j, dl * (float) kv_iq4nl[qs[j] & 0xf]); + put(out, j + 16, dl * (float) kv_iq4nl[qs[j] >> 4]); + } + } +} + +template +__global__ void dequant_kernel(const uint8_t* __restrict__ blocks, int64_t row_bytes, int64_t row0, int64_t rows, + int64_t groups_per_row, T* __restrict__ out) { + const int64_t g = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (g >= rows * groups_per_row) return; + const int64_t r = g / groups_per_row, gi = g % groups_per_row; + group32(blocks + (row0 + r) * row_bytes, (int) gi, out + r * groups_per_row * 32 + gi * 32); +} + +bool geometry(int type, int& block_elems, int& block_bytes) { + switch (type) { + case 2: block_elems = 32; block_bytes = 18; return true; + case 6: block_elems = 32; block_bytes = 22; return true; + case 8: block_elems = 32; block_bytes = 34; return true; + case 20: block_elems = 32; block_bytes = 18; return true; + case 11: block_elems = 256; block_bytes = 110; return true; + case 12: block_elems = 256; block_bytes = 144; return true; + case 13: block_elems = 256; block_bytes = 176; return true; + case 14: block_elems = 256; block_bytes = 210; return true; + case 23: block_elems = 256; block_bytes = 136; return true; + case 42: block_elems = 64; block_bytes = 18; return true; + default: return false; + } +} + +template +void launch(int type, const void* blocks, int64_t row0, int64_t rows, int64_t cols, T* out, void* stream) { + int be = 0, bb = 0; + if (!geometry(type, be, bb) || cols % be != 0 || rows <= 0) { + std::fprintf(stderr, "dequant: unsupported type %d or shape %lld x %lld\n", type, (long long) rows, + (long long) cols); + std::exit(1); + } + const int64_t row_bytes = cols / be * bb, gpr = cols / 32, total = rows * gpr; + const unsigned grid = (unsigned) ((total + 255) / 256); + const uint8_t* p = (const uint8_t*) blocks; + cudaStream_t st = (cudaStream_t) stream; +#define STRATA_DQ(TY) dequant_kernel<<>>(p, row_bytes, row0, rows, gpr, out); break + switch (type) { + case 2: STRATA_DQ(2); + case 6: STRATA_DQ(6); + case 8: STRATA_DQ(8); + case 11: STRATA_DQ(11); + case 12: STRATA_DQ(12); + case 13: STRATA_DQ(13); + case 14: STRATA_DQ(14); + case 20: STRATA_DQ(20); + case 23: STRATA_DQ(23); + case 42: STRATA_DQ(42); + } +#undef STRATA_DQ + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "dequant launch: %s\n", cudaGetErrorString(e)); std::exit(1); } +} + +} // namespace + +bool dequant_bf16_supported(int ggml_type) noexcept { + int a, b; + return geometry(ggml_type, a, b); +} + +namespace { +// plan v0.3 P6: the i-quant formats (llama.cpp's dequantizers, iq_kernels.cu) +bool iq_only(int t) { return t == 16 || t == 17 || t == 18 || t == 21 || t == 22 || t == 29; } +} // namespace + +void dequant_bf16(int ggml_type, const void* blocks, int64_t row0, int64_t rows, int64_t cols, uint16_t* out, + void* stream) { + launch(ggml_type, blocks, row0, rows, cols, out, stream); +} + +void dequant_f16(int ggml_type, const void* blocks, int64_t row0, int64_t rows, int64_t cols, uint16_t* out, + void* stream) { + if (iq_only(ggml_type)) { + iq_dequant_f16(ggml_type, (const uint8_t*) blocks + (size_t) row0 * iq_row_bytes(ggml_type, cols), rows * cols, + out, stream); + return; + } + launch(ggml_type, blocks, row0, rows, cols, reinterpret_cast(out), stream); +} + +void dequant_f32(int ggml_type, const void* blocks, int64_t row0, int64_t rows, int64_t cols, float* out, void* stream) { + if (iq_only(ggml_type)) { + iq_dequant_f32(ggml_type, (const uint8_t*) blocks + (size_t) row0 * iq_row_bytes(ggml_type, cols), rows * cols, + out, stream); + return; + } + launch(ggml_type, blocks, row0, rows, cols, out, stream); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/dequant_s2.cu b/src/kernels/cuda/dequant_s2.cu new file mode 100644 index 00000000..c141b933 --- /dev/null +++ b/src/kernels/cuda/dequant_s2.cu @@ -0,0 +1,51 @@ +// src/kernels/cuda/dequant_s2.cu - P2.S2's first naive kernel. +// +// S2 is Q2_0's canonical form and 31.64 GiB of the artifact is Q2_0, so this is the single hottest decode in +// the engine. The canonical plane layout is `docs/pack-format.md`'s: codes packed 4 per byte LSB-first +// (element i at byte i/4, bits (i%4)*2 - Q2_0's own convention, kept for every form) and one FP32 scale per +// group (64 elements for S2). The decode is `(code + code_bias) * scale` with `code_bias = -1`, the +// subtraction inside the INTEGER domain, which is what makes it bit-exact (docs/pack-format.md §3.1). +// +// Naive on purpose: one thread per block, no vectors, no shared memory, no unrolling. Phase 3 changes this +// file and the parity test in src/kernels/dequant_s2_parity.cpp is what says whether a change is still right. +#include "strata/kernels/dequant_s2.hpp" + +#include + +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int QK = 64; // S2 group = Q2_0 block = 64 elements +constexpr int CODES_PER_BYTE = 4; + +__global__ void dequant_s2_kernel(const uint8_t* __restrict__ codes, const float* __restrict__ scales, + float* __restrict__ out, long long n_blocks) { + const long long b = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (b >= n_blocks) return; + const float d = scales[b]; + const uint8_t* c = codes + b * (QK / CODES_PER_BYTE); + float* y = out + b * QK; + for (int j = 0; j < QK; ++j) { + const int code = (c[j / CODES_PER_BYTE] >> ((j % CODES_PER_BYTE) * 2)) & 0x03; + y[j] = (float)(code - 1) * d; // the -1 is applied to the CODE, then ONE multiply + } +} + +} // namespace + +void dequant_s2(const uint8_t* codes, const float* scales, float* out, int64_t n_blocks) { + if (n_blocks <= 0) return; + const int threads = 256; + const long long blocks = (n_blocks + threads - 1) / threads; + dequant_s2_kernel<<<(unsigned) blocks, threads>>>(codes, scales, out, n_blocks); + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "dequant_s2: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/elementwise.cu b/src/kernels/cuda/elementwise.cu new file mode 100644 index 00000000..420db2aa --- /dev/null +++ b/src/kernels/cuda/elementwise.cu @@ -0,0 +1,289 @@ +// src/kernels/cuda/elementwise.cu - P2.S5's glue kernels. See the header for why each exists. +#include "strata/kernels/elementwise.hpp" + +#include "strata/kernels/bf16_bits.hpp" +#include "strata/kernels/f16_bits.hpp" + +#include + +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int THREADS = 256; + +__global__ void embedding_gather_kernel(const uint8_t* __restrict__ codes, + const float* __restrict__ scales, + const float* __restrict__ offsets, int64_t n, + int code_bits, int code_bias, int group_elems, + float* __restrict__ out) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n) return; + const int per_byte = 8 / code_bits; + const unsigned mask = (1u << code_bits) - 1u; + const int code = (codes[i / per_byte] >> ((i % per_byte) * code_bits)) & mask; + const int64_t group = i / group_elems; + const float product = __fmul_rn((float) (code + code_bias), scales[group]); + out[i] = __fadd_rn(product, offsets ? offsets[group] : 0.0f); +} + +/// `ggml_compute_softplus_f32`: `log1p(exp(x))`, with the large-x branch that avoids overflow. +/// +/// The branch is not cosmetic. `exp(89)` overflows f32 and `exp(20)` is already 4.85e8 where `log1p` loses +/// relative precision; above 20 the function is `x` to within f32 anyway. +__device__ __forceinline__ float softplus_dev(float x) { return x > 20.0f ? x : log1pf(expf(x)); } + +__global__ void gdn_gate_kernel(const float* __restrict__ alpha, const float* __restrict__ dt, + const float* __restrict__ ssm_a, float* __restrict__ gate, int64_t h_v) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= h_v) return; + const int64_t t = i / h_v; // `n_tokens` is the leading dim; the real call has one token + const int64_t h = i % h_v; + gate[i] = softplus_dev(alpha[i] + dt[h]) * ssm_a[h]; + (void) t; +} + +__global__ void scale_kernel(float* __restrict__ x, int64_t n, float s) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) x[i] *= s; +} + +// `dst[i] += src[i]`. See the header: this is what lets R4's GPU half and CPU half run at the same +// time and still add up to one `parts` buffer. +__global__ void add_kernel(float* __restrict__ dst, const float* __restrict__ src, long long n) { + const long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) dst[i] += src[i]; +} + +__global__ void to_f16_kernel(const float* __restrict__ x, uint16_t* __restrict__ y, int64_t n) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) y[i] = f16_from_f32(x[i]); +} + +__global__ void to_bf16_kernel(const float* __restrict__ x, uint16_t* __restrict__ y, int64_t n) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) y[i] = bf16_from_f32(x[i]); +} + +__global__ void silu_kernel(float* __restrict__ x, int64_t n) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n) return; + // DOUBLE then cast, matching `ref/gdn.py`'s numpy: its arrays are f32 but `np.exp` on an f32 array is + // computed to f32 precision by a different algorithm than `expf`, and the reference is the oracle. The + // difference is in the last bits and this is one line. + const double v = (double) x[i]; + x[i] = (float) (v / (1.0 + exp(-v))); +} + +/// A bounded launch: a zero-length grid is illegal, and `n == 0` is a real call (an empty selection). +inline unsigned grid_for(int64_t n) { return (unsigned) ((n + THREADS - 1) / THREADS); } + +/// One WARP per row, reduced through shuffles. The rows here are short and few - (24, 256), (2, 256), +/// (4, 128) - so a block-per-row tree would spend its time in `__syncthreads` for 8 warps of 32, and the whole +/// call is 30 rows. A warp reduction with NO shared memory and NO barrier is the shape that fits. +__global__ void rms_norm_weighted_kernel(float* __restrict__ x, const float* __restrict__ w, int64_t rows, + int64_t cols, float eps) { + + const int lane = threadIdx.x & 31; + const int64_t row = ((int64_t) blockIdx.x * (blockDim.x >> 5)) + (threadIdx.x >> 5); + // **THE ROW GUARD IS NOT DECORATION, AND ITS ABSENCE WAS THE QSA BUG.** The launcher rounds the grid up to + // whole 4-warp blocks, so a call with `rows` = 2 - the QSA k-norm, the only non-multiple-of-4 row count in + // the engine - runs EIGHT warps: rows 0 and 1 are the data, and rows 2 and 3 write 2*cols floats PAST the + // end of `b.kcur`, which in the arena is exactly where `b.vcur` begins. `b.vcur` was therefore silently + // replaced by two rows of `rms_norm(...) * attn_k_norm`: a normalized vector with l2 ~20.6 against V's + // ~5.7, which the attention then attended to. `rope_neox_kernel` has had this guard all along. + if (row >= rows) return; + float* r = x + row * cols; + float acc = 0.0f; + for (int64_t c = lane; c < cols; c += 32) acc += r[c] * r[c]; + for (int off = 16; off > 0; off >>= 1) acc += __shfl_down_sync(0xFFFFFFFFu, acc, off); + // The MEAN, not the sum: `ref/qsa.py::rms_norm` divides by `np.mean(np.square(x))`. Broadcasting the + // reciprocal from lane 0 keeps all 32 lanes on the same value - computing `rsqrt` per lane would be the + // same number but a needless 32-way divergence in the last bit. + float inv = 0.0f; + if (lane == 0) inv = rsqrtf(acc / (float) cols + eps); + inv = __shfl_sync(0xFFFFFFFFu, inv, 0); + for (int64_t c = lane; c < cols; c += 32) r[c] = (w ? r[c] * w[c] : r[c]) * inv; +} + +void sync_if_needed(void* stream, const char* what) { + if (stream != nullptr) return; + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +bool check_launch(const char* what) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "%s launch: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } + return true; +} + +} // namespace + +void embedding_gather(const uint8_t* codes, const float* scales, const float* offsets, + int64_t n, int code_bits, int code_bias, int group_elems, + float* out, void* stream) { + if (n <= 0) return; + embedding_gather_kernel<<>>( + codes, scales, offsets, n, code_bits, code_bias, group_elems, out); + check_launch("embedding_gather"); +} + +void gdn_gate(const float* alpha, const float* dt, const float* ssm_a, float* gate, int64_t n_tokens, + int64_t h_v, void* stream) { + if (n_tokens <= 0 || h_v <= 0) return; + const int64_t n = n_tokens * h_v; + gdn_gate_kernel<<>>(alpha, dt, ssm_a, gate, h_v); + check_launch("gdn_gate"); + sync_if_needed(stream, "gdn_gate"); +} + +void scale_inplace(float* x, int64_t n, float s, void* stream) { + if (n <= 0) return; + scale_kernel<<>>(x, n, s); + check_launch("scale_inplace"); + sync_if_needed(stream, "scale_inplace"); +} + +void add_inplace(float* dst, const float* src, int64_t n, void* stream) { + if (n <= 0) return; + add_kernel<<>>(dst, src, n); + check_launch("add_inplace"); + sync_if_needed(stream, "add_inplace"); +} + +void f32_to_f16_bulk(const float* x, uint16_t* y, int64_t n, void* stream) { + if (n <= 0) return; + to_f16_kernel<<>>(x, y, n); + check_launch("f32_to_f16_bulk"); + sync_if_needed(stream, "f32_to_f16_bulk"); +} + +void f32_to_bf16_bulk(const float* x, uint16_t* y, int64_t n, void* stream) { + if (n <= 0) return; + to_bf16_kernel<<>>(x, y, n); + check_launch("f32_to_bf16_bulk"); + sync_if_needed(stream, "f32_to_bf16_bulk"); +} + +void silu_inplace(float* x, int64_t n, void* stream) { + if (n <= 0) return; + silu_kernel<<>>(x, n); + check_launch("silu_inplace"); + sync_if_needed(stream, "silu_inplace"); +} + +/// THE DOORBELL. One thread, one INCREMENT - the cost is the launch, and inside a graph that is paid once. +/// +/// **IT INCREMENTS THE MEMORY, AND IT DOES NOT TAKE THE VALUE AS AN ARGUMENT.** The first version did - +/// doorbell_ring(d_seq, *(h_seq) + 1u) - and that is a CLONED LITERAL: the expression is evaluated on the HOST +/// at CAPTURE time, so the graph rings 1 on every replay and a host waiting for a change waits forever. It +/// would have looked like a working doorbell on the first token, which is the worst way for it to be wrong. +/// **THE FENCE IS NOT DECORATION, AND ITS ABSENCE WAS COSTING ~10 ms PER TOKEN ON THE HOST SIDE.** +/// +/// The ring PUBLISHES three buffers the host is about to read - `h_x_f`, `h_ids`, `h_weights` - so the increment +/// must be ordered after their writes, or the host can observe the ring and then read a payload that has not +/// landed. `__threadfence_system()` is what orders them, and it covers the HOST as well as the device, which is +/// the whole point: the reader is the CPU. +/// +/// Without it the host loop was compensating with a driver call. `session_loop` polled with `cudaEventQuery` +/// on EVERY spin iteration, because round 195 had measured that a memory-only spin never saw the datum flip - +/// and that measurement was right about the symptom and wrong about the cause. The cause is this missing fence: +/// the write was not ordered into host-visible memory, so no amount of reading it would show it, and the driver +/// call was flushing the whole pipeline enough to make it appear. A 10-22 us driver call per iteration is a +/// very expensive substitute for one fence instruction. +__global__ void doorbell_ring_kernel(uint32_t* seq) { + __threadfence_system(); + *seq = *seq + 1u; +} + +__global__ void doorbell_wait_kernel(const volatile uint32_t* flag, const volatile uint32_t* seq) { + const uint32_t want = *seq; + while (*flag != want) __nanosleep(100); + __threadfence_system(); +} + +void doorbell_wait(const uint32_t* d_flag, const uint32_t* d_seq, void* stream) { + if (d_flag == nullptr || d_seq == nullptr) return; + doorbell_wait_kernel<<<1, 1, 0, (cudaStream_t) stream>>>(d_flag, d_seq); + check_launch("doorbell_wait"); +} + +__global__ void copy_from_mapped_kernel(float4* __restrict__ dst, const volatile float4* src, int64_t n4) { + for (int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; i < n4; i += (int64_t) gridDim.x * blockDim.x) { + const float4 v = const_cast(src)[i]; + dst[i] = v; + } +} + +void copy_from_mapped(float* dst, const float* src, int64_t n, void* stream) { + if (n <= 0) return; + if ((n & 3) != 0 || ((uintptr_t) dst & 15) != 0 || ((uintptr_t) src & 15) != 0) { + std::fprintf(stderr, "copy_from_mapped: n must be a multiple of 4 and both pointers 16-byte aligned\n"); + std::exit(1); + } + const int64_t n4 = n / 4; + const int blocks = (int) ((n4 + 255) / 256 < 64 ? (n4 + 255) / 256 : 64); + copy_from_mapped_kernel<<>>((float4*) dst, (const volatile float4*) src, n4); + check_launch("copy_from_mapped"); +} + +__global__ void doorbell_publish_kernel(const float* __restrict__ x, const int32_t* __restrict__ ids, + const float* __restrict__ w, int n, int k, float* x_out, int32_t* ids_out, + float* w_out, uint32_t* seq) { + for (int i = threadIdx.x; i < n; i += blockDim.x) x_out[i] = x[i]; + if ((int) threadIdx.x < k) { ids_out[threadIdx.x] = ids[threadIdx.x]; w_out[threadIdx.x] = w[threadIdx.x]; } + __threadfence_system(); + __syncthreads(); + if (threadIdx.x == 0) { + __threadfence_system(); + *(volatile uint32_t*) seq = *(volatile uint32_t*) seq + 1u; + } +} + +void doorbell_publish(const float* x, const int32_t* ids, const float* weights, int64_t n, int64_t k, float* x_out, + int32_t* ids_out, float* weights_out, uint32_t* d_seq, void* stream) { + if (k > 1024) { std::fprintf(stderr, "doorbell_publish: k too large\n"); std::exit(1); } + doorbell_publish_kernel<<<1, 1024, 0, (cudaStream_t) stream>>>(x, ids, weights, (int) n, (int) k, x_out, ids_out, + weights_out, d_seq); + check_launch("doorbell_publish"); +} + +__global__ void copy_i32_from_mapped_kernel(int32_t* __restrict__ dst, const volatile int32_t* src, int n) { + for (int i = threadIdx.x; i < n; i += blockDim.x) dst[i] = src[i]; +} + +void copy_i32_from_mapped(int32_t* dst, const int32_t* src, int64_t n, void* stream) { + if (n <= 0) return; + copy_i32_from_mapped_kernel<<<1, 128, 0, (cudaStream_t) stream>>>(dst, (const volatile int32_t*) src, (int) n); + check_launch("copy_i32_from_mapped"); +} + +void doorbell_ring(uint32_t* d_seq, void* stream) { + if (d_seq == nullptr) return; + doorbell_ring_kernel<<<1, 1, 0, (cudaStream_t) stream>>>(d_seq); + check_launch("doorbell_ring"); + sync_if_needed(stream, "doorbell_ring"); +} + +void rms_norm_weighted(float* x, const float* w, int64_t rows, int64_t cols, float eps, void* stream) { + if (rows <= 0 || cols <= 0) return; + // 4 warps per block, so a row count that is not a multiple of 4 wastes at most 3 warps rather than + // launching a block per row for a 2-row call. + const unsigned warps_per_block = 4; + const unsigned grid = (unsigned) ((rows + warps_per_block - 1) / warps_per_block); + rms_norm_weighted_kernel<<>>(x, w, rows, cols, eps); + check_launch("rms_norm_weighted"); + sync_if_needed(stream, "rms_norm_weighted"); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/fused_gdn.cu b/src/kernels/cuda/fused_gdn.cu new file mode 100644 index 00000000..ef2e7787 --- /dev/null +++ b/src/kernels/cuda/fused_gdn.cu @@ -0,0 +1,166 @@ +// src/kernels/cuda/fused_gdn.cu - see include/strata/kernels/fused_gdn.hpp. +#include "strata/kernels/fused_gdn.hpp" + +#include + +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int S = 128; // state size (rows = cols = 128) +constexpr int RG = 4; // row groups +constexpr int RPG = S / RG; // 32 rows per thread + +__global__ void __launch_bounds__(S * RG) gdn_step_norm_kernel(float* __restrict__ state, const float* __restrict__ q, + const float* __restrict__ k, const float* __restrict__ v, + const float* __restrict__ gate, + const float* __restrict__ beta, + const float* __restrict__ z, + const float* __restrict__ gamma, float eps, + float* __restrict__ y, int h_k, int h_v) { + __shared__ float sk[S], sq[S]; + __shared__ float red[RG][S]; + __shared__ float wsum[S * RG / 32]; + const int head = blockIdx.x; + const int col = threadIdx.x; // 0..127 + const int rg = threadIdx.y; // 0..3 + const int tid = rg * S + col; + const int qh = head % h_k; + if (tid < S) { sk[tid] = k[qh * S + tid]; sq[tid] = q[qh * S + tid]; } + float s[RPG]; + float* base = state + ((size_t) (rg * RPG) * h_v + head) * S + col; + const size_t row_stride = (size_t) h_v * S; +#pragma unroll + for (int r = 0; r < RPG; ++r) s[r] = base[r * row_stride]; + __syncthreads(); + const float g = __expf(gate[head]); + float kv = 0.0f; +#pragma unroll + for (int r = 0; r < RPG; ++r) kv = fmaf(s[r], sk[rg * RPG + r], kv); + red[rg][col] = kv; + __syncthreads(); + const float kv_col = red[0][col] + red[1][col] + red[2][col] + red[3][col]; + const float delta = (v[head * S + col] - g * kv_col) * beta[head]; + float o = 0.0f; +#pragma unroll + for (int r = 0; r < RPG; ++r) { + s[r] = fmaf(g, s[r], sk[rg * RPG + r] * delta); + o = fmaf(s[r], sq[rg * RPG + r], o); + base[r * row_stride] = s[r]; + } + __syncthreads(); // every thread has read red[] for kv_col + red[rg][col] = o; + __syncthreads(); + float oc = 0.0f, sq_part = 0.0f; + if (rg == 0) { + oc = (red[0][col] + red[1][col] + red[2][col] + red[3][col]) * rsqrtf((float) S); + sq_part = oc * oc; + } + // RMS over the head's 128 outputs: warps of row group 0 are threads 0..127. + for (int o2 = 16; o2 > 0; o2 >>= 1) sq_part += __shfl_xor_sync(0xffffffffu, sq_part, o2); + if ((tid & 31) == 0) wsum[tid >> 5] = sq_part; + __syncthreads(); + if (rg == 0) { + const float ss = wsum[0] + wsum[1] + wsum[2] + wsum[3]; + const float scale = rsqrtf(ss / (float) S + eps); + const float zz = z[head * S + col]; + y[head * S + col] = oc * scale * gamma[col] * (1.0f / (1.0f + __expf(-zz))); + } +} + +__global__ void __launch_bounds__(S) gdn_conv_l2_kernel(float* __restrict__ hist, const float* __restrict__ qkv, + const float* __restrict__ w, float* __restrict__ h, + int qk_heads, float eps) { + __shared__ float part[S / 32]; + const int c = blockIdx.x * S + threadIdx.x; + const float v0 = hist[c * 3], v1 = hist[c * 3 + 1], v2 = hist[c * 3 + 2], x = qkv[c]; + float sum = v0 * w[c * 4] + v1 * w[c * 4 + 1] + v2 * w[c * 4 + 2] + x * w[c * 4 + 3]; + hist[c * 3] = v1; + hist[c * 3 + 1] = v2; + hist[c * 3 + 2] = x; + float y = sum / (1.0f + __expf(-sum)); + if ((int) blockIdx.x < qk_heads) { + float sq = y * y; + for (int o = 16; o > 0; o >>= 1) sq += __shfl_xor_sync(0xffffffffu, sq, o); + if ((threadIdx.x & 31) == 0) part[threadIdx.x >> 5] = sq; + __syncthreads(); + const float ss = part[0] + part[1] + part[2] + part[3]; + y *= rsqrtf(ss + eps); + } + h[c] = y; +} + +__global__ void __launch_bounds__(256) gdn_ab_kernel(const float* __restrict__ x, const uint16_t* __restrict__ wa, + const uint16_t* __restrict__ wb, const float* __restrict__ dt, + const float* __restrict__ ssm_a, float* __restrict__ gate, + float* __restrict__ beta, int n, int h_v) { + const int row = blockIdx.x * 8 + (threadIdx.x >> 5), lane = threadIdx.x & 31; + if (row >= 2 * h_v) return; + const bool is_beta = row >= h_v; + const int r = is_beta ? row - h_v : row; + const uint4* w4 = reinterpret_cast((is_beta ? wb : wa) + (size_t) r * n); + float acc = 0.0f; + for (int j = lane; j < n / 8; j += 32) { + const uint4 wv = __ldg(w4 + j); + const float4 xa = *reinterpret_cast(x + j * 8); + const float4 xb = *reinterpret_cast(x + j * 8 + 4); + acc = fmaf(__uint_as_float(wv.x << 16), xa.x, acc); acc = fmaf(__uint_as_float(wv.x & 0xffff0000u), xa.y, acc); + acc = fmaf(__uint_as_float(wv.y << 16), xa.z, acc); acc = fmaf(__uint_as_float(wv.y & 0xffff0000u), xa.w, acc); + acc = fmaf(__uint_as_float(wv.z << 16), xb.x, acc); acc = fmaf(__uint_as_float(wv.z & 0xffff0000u), xb.y, acc); + acc = fmaf(__uint_as_float(wv.w << 16), xb.z, acc); acc = fmaf(__uint_as_float(wv.w & 0xffff0000u), xb.w, acc); + } + for (int o = 16; o > 0; o >>= 1) acc += __shfl_xor_sync(0xffffffffu, acc, o); + if (lane != 0) return; + if (is_beta) { + beta[r] = 1.0f / (1.0f + __expf(-acc)); + } else { + const float v = acc + dt[r]; + const float sp = v > 20.0f ? v : log1pf(__expf(v)); + gate[r] = sp * ssm_a[r]; + } +} + +} // namespace + +void fused_gdn_conv_l2(float* history, const float* qkv, const float* conv_w, float* h, int channels, int qk_heads, + float eps, void* stream) { + if (!history || !qkv || !conv_w || !h || channels % S != 0 || qk_heads < 0 || qk_heads > channels / S) { + std::fprintf(stderr, "fused_gdn_conv_l2: invalid arguments\n"); + std::exit(1); + } + gdn_conv_l2_kernel<<<(unsigned) (channels / S), S, 0, (cudaStream_t) stream>>>(history, qkv, conv_w, h, qk_heads, eps); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "fused_gdn_conv_l2: %s\n", cudaGetErrorString(e)); std::exit(1); } +} + +void fused_gdn_ab(const float* x, const uint16_t* w_alpha, const uint16_t* w_beta, const float* dt, const float* ssm_a, + float* gate, float* beta, int n_embd, int h_v, void* stream) { + if (!x || !w_alpha || !w_beta || !dt || !ssm_a || !gate || !beta || n_embd % 8 != 0 || h_v <= 0) { + std::fprintf(stderr, "fused_gdn_ab: invalid arguments\n"); + std::exit(1); + } + gdn_ab_kernel<<<(unsigned) ((2 * h_v + 7) / 8), 256, 0, (cudaStream_t) stream>>>(x, w_alpha, w_beta, dt, ssm_a, gate, + beta, n_embd, h_v); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "fused_gdn_ab: %s\n", cudaGetErrorString(e)); std::exit(1); } +} + +void fused_gdn_step_norm(float* state, const float* q, const float* k, const float* v, const float* gate, + const float* beta, const float* z, const float* gamma, float eps, float* y, int h_k, int h_v, + void* stream) { + if (!state || !q || !k || !v || !gate || !beta || !z || !gamma || !y || h_k <= 0 || h_v <= 0 || h_v % h_k) { + std::fprintf(stderr, "fused_gdn_step_norm: invalid arguments\n"); + std::exit(1); + } + gdn_step_norm_kernel<<<(unsigned) h_v, dim3(S, RG), 0, (cudaStream_t) stream>>>(state, q, k, v, gate, beta, z, + gamma, eps, y, h_k, h_v); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "fused_gdn_step_norm: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/fused_gr.cu b/src/kernels/cuda/fused_gr.cu new file mode 100644 index 00000000..c439ee48 --- /dev/null +++ b/src/kernels/cuda/fused_gr.cu @@ -0,0 +1,354 @@ +// src/kernels/cuda/fused_gr.cu - see include/strata/kernels/fused_gr.hpp. +#include "strata/kernels/fused_gr.hpp" +#include "strata/kernels/bf16_bits.hpp" + +#include + +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int N = 2560; // n_embd +constexpr int HC = 4; // streams +constexpr int D = N * HC; // 10240 +constexpr int LR = 320; // hc_lr +constexpr int THREADS = 256; +constexpr int WARPS = THREADS / 32; +constexpr int DOWN_BLOCKS = LR / WARPS; // 40 blocks of 8 rows; one more for the inject rows +constexpr int UP_COLS = 32; // columns d per `up` block (x 4 streams = 128 rows) +constexpr int UP_BLOCKS = N / UP_COLS; // 80 + +__device__ __forceinline__ float warp_sum(float v) { +#pragma unroll + for (int o = 16; o > 0; o >>= 1) v += __shfl_xor_sync(0xffffffffu, v, o); + return v; +} +__device__ __forceinline__ float sigmoidf_(float x) { return 1.0f / (1.0f + __expf(-x)); } + +// 8 bf16 packed in a uint4 against 8 floats. +__device__ __forceinline__ float dot8(const uint4 w, const float* x) { + float acc = 0.0f; + const uint32_t v[4] = {w.x, w.y, w.z, w.w}; +#pragma unroll + for (int j = 0; j < 4; ++j) { + acc = fmaf(__uint_as_float(v[j] << 16), x[2 * j], acc); + acc = fmaf(__uint_as_float(v[j] & 0xffff0000u), x[2 * j + 1], acc); + } + return acc; +} + +__global__ void __launch_bounds__(THREADS) gr_down_kernel(FusedGrArgs a) { + __shared__ __align__(16) float xn[D]; + __shared__ float part[WARPS][HC]; + __shared__ float s_rs[HC]; + const int t = threadIdx.x, lane = t & 31, warp = t >> 5; + float gw[HC]; +#pragma unroll + for (int c = 0; c < HC; ++c) gw[c] = a.apply ? 2.0f * sigmoidf_(a.inj_prev[c] / (float) HC) : 0.0f; + // 1. R' * w_norm into shared memory, and the per-stream sums of squares of R'. + float ss[HC] = {0.0f, 0.0f, 0.0f, 0.0f}; + for (int i = t * 4; i < D; i += THREADS * 4) { + const int c = i / N, d = i - c * N; + float4 r = *reinterpret_cast(a.R + i); + if (a.apply) { + const float4 b = *reinterpret_cast(a.bo_prev + d); + r.x = fmaf(b.x, gw[c], r.x); r.y = fmaf(b.y, gw[c], r.y); + r.z = fmaf(b.z, gw[c], r.z); r.w = fmaf(b.w, gw[c], r.w); + } + const float4 g = *reinterpret_cast(a.w_norm + i); + float sq = r.x * r.x + r.y * r.y + r.z * r.z + r.w * r.w; +#pragma unroll + for (int cc = 0; cc < HC; ++cc) if (cc == c) ss[cc] += sq; + *reinterpret_cast(xn + i) = make_float4(r.x * g.x, r.y * g.y, r.z * g.z, r.w * g.w); + } +#pragma unroll + for (int c = 0; c < HC; ++c) { + const float v = warp_sum(ss[c]); + if (lane == 0) part[warp][c] = v; + } + __syncthreads(); + if (t < HC) { + float s = 0.0f; + for (int w = 0; w < WARPS; ++w) s += part[w][t]; + s_rs[t] = rsqrtf(s / (float) N + a.eps); + if (blockIdx.x == 0) a.rs[t] = s_rs[t]; + } + __syncthreads(); + for (int i = t; i < D; i += THREADS) xn[i] *= s_rs[i / N]; + __syncthreads(); + // 2. one warp per output row: 10240 bf16 = 1280 chunks of 8, 40 per lane. + const bool inject_block = blockIdx.x == DOWN_BLOCKS; + const int row = inject_block ? warp : blockIdx.x * WARPS + warp; + if (inject_block && (a.w_inject == nullptr || warp >= HC)) return; + const uint16_t* wrow = (inject_block ? a.w_inject : a.w_down) + (size_t) row * D; + const uint4* w4 = reinterpret_cast(wrow); + float acc = 0.0f; +#pragma unroll 4 + for (int j = lane; j < D / 8; j += 32) acc += dot8(__ldg(w4 + j), xn + j * 8); + acc = warp_sum(acc); + if (lane != 0) return; + if (inject_block) { + a.inject_out[row] = acc; + } else { + const float x = acc / (float) HC; + a.lo[row] = x / (1.0f + __expf(-x)); + } +} + +__global__ void __launch_bounds__(THREADS) gr_up_kernel(FusedGrArgs a) { + __shared__ __align__(16) float lo[LR]; + __shared__ float g[HC][UP_COLS]; + const int t = threadIdx.x, lane = t & 31, warp = t >> 5; + const int d0 = blockIdx.x * UP_COLS; + for (int k = t; k < LR; k += THREADS) lo[k] = a.lo[k]; + __syncthreads(); + // 128 rows (4 streams x 32 columns), 16 per warp: 320 bf16 = 40 chunks of 8. + for (int r = warp; r < HC * UP_COLS; r += WARPS) { + const int c = r / UP_COLS, dd = r - c * UP_COLS, i = c * N + d0 + dd; + const uint4* w4 = reinterpret_cast(a.w_up + (size_t) i * LR); + float acc = dot8(__ldg(w4 + lane), lo + lane * 8); + if (lane < LR / 8 - 32) acc += dot8(__ldg(w4 + 32 + lane), lo + (32 + lane) * 8); + acc = warp_sum(acc); + if (lane == 0) { + float rv = a.R[i]; + if (a.apply) { + rv = fmaf(a.bo_prev[d0 + dd], 2.0f * sigmoidf_(a.inj_prev[c] / (float) HC), rv); + a.R_out[i] = rv; // this block owns column d0+dd of every stream + } + const float x = rv * a.w_norm[i] * a.rs[c]; + g[c][dd] = x * sigmoidf_(acc); + } + } + __syncthreads(); + if (t < UP_COLS) { + float s = 0.0f; +#pragma unroll + for (int c = 0; c < HC; ++c) s += g[c][t]; + a.mixed[d0 + t] = s / (float) HC; + } +} + +// ================================ plan v0.3 P6: T tokens, one weight read ================================ +struct GrMulti { + FusedGrArgs a[kFusedGrMaxT]; + float* xn; + int T; +}; + +// Step 1 of `gr_down_kernel`, one block per token, same threads and reduction order: rs[t] and xn[t] to global. +__global__ void __launch_bounds__(THREADS) gr_norm_multi_kernel(GrMulti m) { + __shared__ float part[WARPS][HC]; + __shared__ float s_rs[HC]; + const FusedGrArgs& a = m.a[blockIdx.x]; + float* xn = m.xn + (size_t) blockIdx.x * D; + const int t = threadIdx.x, lane = t & 31, warp = t >> 5; + float gw[HC]; +#pragma unroll + for (int c = 0; c < HC; ++c) gw[c] = a.apply ? 2.0f * sigmoidf_(a.inj_prev[c] / (float) HC) : 0.0f; + float ss[HC] = {0.0f, 0.0f, 0.0f, 0.0f}; + for (int i = t * 4; i < D; i += THREADS * 4) { + const int c = i / N, d = i - c * N; + float4 r = *reinterpret_cast(a.R + i); + if (a.apply) { + const float4 b = *reinterpret_cast(a.bo_prev + d); + r.x = fmaf(b.x, gw[c], r.x); r.y = fmaf(b.y, gw[c], r.y); + r.z = fmaf(b.z, gw[c], r.z); r.w = fmaf(b.w, gw[c], r.w); + } + const float4 g = *reinterpret_cast(a.w_norm + i); + float sq = r.x * r.x + r.y * r.y + r.z * r.z + r.w * r.w; +#pragma unroll + for (int cc = 0; cc < HC; ++cc) if (cc == c) ss[cc] += sq; + *reinterpret_cast(xn + i) = make_float4(r.x * g.x, r.y * g.y, r.z * g.z, r.w * g.w); + } +#pragma unroll + for (int c = 0; c < HC; ++c) { + const float v = warp_sum(ss[c]); + if (lane == 0) part[warp][c] = v; + } + __syncthreads(); + if (t < HC) { + float s = 0.0f; + for (int w = 0; w < WARPS; ++w) s += part[w][t]; + s_rs[t] = rsqrtf(s / (float) N + a.eps); + a.rs[t] = s_rs[t]; + } + __syncthreads(); + for (int i = t; i < D; i += THREADS) xn[i] *= s_rs[i / N]; +} + +constexpr int TILE = 2560; // xn floats per token staged at a time: 320 chunks of 8, 10 per lane +constexpr int TQ = TILE / 8 / 32; // uint4 weight chunks per lane per tile + +// Step 2 of `gr_down_kernel` for T tokens. One warp per row (so each lane accumulates the same chunks in the +// same order as the single-token kernel); per tile the lane's 10 weight chunks are loaded BEFORE the activation +// tile is staged, so the DRAM and L2 traffic are in flight together. +__global__ void __launch_bounds__(THREADS) gr_down_multi_kernel(GrMulti m) { + extern __shared__ __align__(16) float tile[]; // [T][TILE] + const int t = threadIdx.x, lane = t & 31, warp = t >> 5; + const int T = m.T; + const bool inject_block = blockIdx.x == DOWN_BLOCKS; + const int row = inject_block ? warp : blockIdx.x * WARPS + warp; + const bool active = !(inject_block && (m.a[0].w_inject == nullptr || warp >= HC)); + const uint16_t* wrow = (inject_block ? m.a[0].w_inject : m.a[0].w_down) + (size_t) (active ? row : 0) * D; + const uint4* w4 = reinterpret_cast(wrow); + float acc[kFusedGrMaxT]; +#pragma unroll + for (int k = 0; k < kFusedGrMaxT; ++k) acc[k] = 0.0f; + for (int base = 0; base < D; base += TILE) { + uint4 wv[TQ]; + if (active) { +#pragma unroll + for (int q = 0; q < TQ; ++q) wv[q] = __ldg(w4 + base / 8 + lane + 32 * q); + } + __syncthreads(); // the previous tile is consumed + const float4* src4 = reinterpret_cast(m.xn); + float4* tile4 = reinterpret_cast(tile); + for (int i = t; i < T * (TILE / 4); i += THREADS) { + const int k = i / (TILE / 4), off = i - k * (TILE / 4); + tile4[i] = src4[((size_t) k * D + base) / 4 + off]; + } + __syncthreads(); + if (!active) continue; +#pragma unroll + for (int q = 0; q < TQ; ++q) { + const int j = lane + 32 * q; +#pragma unroll + for (int k = 0; k < kFusedGrMaxT; ++k) + if (k < T) acc[k] += dot8(wv[q], tile + k * TILE + j * 8); + } + } + if (!active) return; + float s[kFusedGrMaxT]; +#pragma unroll + for (int k = 0; k < kFusedGrMaxT; ++k) s[k] = k < T ? warp_sum(acc[k]) : 0.0f; + // lane k writes token k (every lane holds every sum after the xor reduction) +#pragma unroll + for (int k = 0; k < kFusedGrMaxT; ++k) { + if (k >= T || lane != k) continue; + if (inject_block) { + m.a[k].inject_out[row] = s[k]; + } else { + const float x = s[k] / (float) HC; + m.a[k].lo[row] = x / (1.0f + __expf(-x)); + } + } +} + +constexpr int UPM_COLS = 16; // columns per block (x 4 streams = 64 rows, 8 per warp) +constexpr int UPM_BLOCKS = N / UPM_COLS; // 160 + +// `gr_up_kernel` for T tokens: each row of w_up read once; the T dots reduced by xor so every lane holds every +// sum, and lane k runs token k's epilogue - the T epilogues in parallel instead of one after another. +__global__ void __launch_bounds__(THREADS) gr_up_multi_kernel(GrMulti m) { + __shared__ __align__(16) float lo[kFusedGrMaxT][LR]; + __shared__ float g[kFusedGrMaxT][HC][UPM_COLS]; + const int t = threadIdx.x, lane = t & 31, warp = t >> 5; + const int T = m.T; + const int d0 = blockIdx.x * UPM_COLS; + for (int i = t; i < T * LR; i += THREADS) lo[i / LR][i % LR] = m.a[i / LR].lo[i % LR]; + __syncthreads(); + for (int r = warp; r < HC * UPM_COLS; r += WARPS) { + const int c = r / UPM_COLS, dd = r - c * UPM_COLS, i = c * N + d0 + dd; + const uint4* w4 = reinterpret_cast(m.a[0].w_up + (size_t) i * LR); + const uint4 wa = __ldg(w4 + lane); + const uint4 wb = lane < LR / 8 - 32 ? __ldg(w4 + 32 + lane) : make_uint4(0, 0, 0, 0); + // the epilogue inputs of this lane's token, fetched while the dots run + float rv = 0.0f, wn = 0.0f, rsc = 0.0f, bo = 0.0f, ip = 0.0f; + bool apply = false; + if (lane < T) { + const FusedGrArgs& a = m.a[lane]; + rv = a.R[i]; + wn = a.w_norm[i]; + rsc = a.rs[c]; + apply = a.apply; + if (apply) { bo = a.bo_prev[d0 + dd]; ip = a.inj_prev[c]; } + } + float mine = 0.0f; +#pragma unroll + for (int k = 0; k < kFusedGrMaxT; ++k) { + if (k >= T) break; + float acc = dot8(wa, lo[k] + lane * 8); + if (lane < LR / 8 - 32) acc += dot8(wb, lo[k] + (32 + lane) * 8); + acc = warp_sum(acc); + if (lane == k) mine = acc; + } + if (lane < T) { + if (apply) { + rv = fmaf(bo, 2.0f * sigmoidf_(ip / (float) HC), rv); + m.a[lane].R_out[i] = rv; + } + const float x = rv * wn * rsc; + g[lane][c][dd] = x * sigmoidf_(mine); + } + } + __syncthreads(); + for (int i = t; i < T * UPM_COLS; i += THREADS) { + const int k = i / UPM_COLS, col = i - k * UPM_COLS; + float s = 0.0f; +#pragma unroll + for (int c = 0; c < HC; ++c) s += g[k][c][col]; + m.a[k].mixed[d0 + col] = s / (float) HC; + } +} + +} // namespace + +void fused_gr_read_multi(const FusedGrArgs* a, int n_tok, float* xn_scratch, void* stream) { + if (n_tok < 1 || n_tok > kFusedGrMaxT || xn_scratch == nullptr) { + std::fprintf(stderr, "fused_gr_read_multi: invalid arguments\n"); + std::exit(1); + } + GrMulti m; + for (int t = 0; t < n_tok; ++t) { + m.a[t] = a[t]; + const FusedGrArgs& x = a[t]; + if (!x.R || !x.w_norm || !x.w_down || !x.w_up || !x.lo || !x.rs || !x.mixed || (x.w_inject && !x.inject_out) || + (x.apply && (!x.bo_prev || !x.inj_prev || !x.R_out)) || x.w_down != a[0].w_down || x.w_up != a[0].w_up || + x.w_inject != a[0].w_inject || x.w_norm != a[0].w_norm) { + std::fprintf(stderr, "fused_gr_read_multi: invalid arguments for token %d\n", t); + std::exit(1); + } + } + m.xn = xn_scratch; + m.T = n_tok; + cudaStream_t st = (cudaStream_t) stream; + gr_norm_multi_kernel<<>>(m); + static bool attr = false; + if (!attr) { + cudaFuncSetAttribute(gr_down_multi_kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, + (int) (kFusedGrMaxT * TILE * sizeof(float))); + attr = true; + } + gr_down_multi_kernel<<>>(m); + gr_up_multi_kernel<<>>(m); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "fused_gr_read_multi: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +bool fused_gr_supported(int64_t n_embd, int64_t hc, int64_t hc_lr) { + return n_embd == N && hc == HC && hc_lr == LR; +} + +void fused_gr_read(const FusedGrArgs& a, void* stream) { + if (!a.R || !a.w_norm || !a.w_down || !a.w_up || !a.lo || !a.rs || !a.mixed || + (a.w_inject && !a.inject_out) || (a.apply && (!a.bo_prev || !a.inj_prev || !a.R_out)) || + (a.apply && a.inj_prev == a.inject_out)) { + std::fprintf(stderr, "fused_gr_read: invalid arguments\n"); + std::exit(1); + } + cudaStream_t st = (cudaStream_t) stream; + gr_down_kernel<<>>(a); + gr_up_kernel<<>>(a); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "fused_gr_read: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/gdn.cu b/src/kernels/cuda/gdn.cu new file mode 100644 index 00000000..aad247a7 --- /dev/null +++ b/src/kernels/cuda/gdn.cu @@ -0,0 +1,248 @@ +// src/kernels/cuda/gdn.cu - P2.S2: the gated delta-net's non-projection parts. +// +// See include/strata/kernels/gdn.hpp for the state layout (S, h_v, S) and for why the recurrence needs no +// barrier at all: every line of it touches only one (j, h) column, so one thread owns a column end to end. +#include "strata/kernels/gdn.hpp" + +#include + +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int JTHREADS = 32; ///< threads along j, the state's fast axis +/// Heads staged per block. **THIS IS A LATENCY-HIDING KNOB, NOT A CAPACITY ONE, AND IT WAS SET WRONG.** +/// +/// The kernel is one thread per `(h, j)` COLUMN of the state, so the thread count is fixed at +/// `S * h_v` = 128 * 48 = **6,144** no matter how the work is packed - and `MAX_H` decides only how many of +/// those columns share a block: +/// +/// MAX_H = 8 grid = (S/32, h_v/8) = (4, 6) = 24 blocks x 32 threads -> 1 warp on 24 of 48 SMs +/// MAX_H = 1 grid = (S/32, h_v/1) = (4, 48) = 192 blocks x 32 threads -> 4 warps on every SM +/// +/// Each thread walks `i` in 0..S-1 TWICE per head with a strided global load each step, so it is entirely +/// latency-bound: a warp that issues one 128-byte transaction and then waits ~600 cycles needs other warps to +/// run meanwhile, and at 24 blocks there were none. Measured 0.2800 ms per GDN block - 10 ms of a 71.5 ms +/// token, 14%, in a kernel touching 12.6 MB that a 643 GB/s card should do in 0.020 ms. +/// +/// The arithmetic does not change at all: the same columns are computed by the same code, in different blocks. +constexpr int MAX_H = 1; ///< heads staged in shared memory per block + +/// ggml_compute_softplus_f32: log1p(exp(x)), with the large-x branch that avoids overflow. +__device__ __forceinline__ float softplus_f(float x) { return x > 20.0f ? x : log1pf(expf(x)); } +__device__ __forceinline__ float sigmoid_f(float x) { return 1.0f / (1.0f + expf(-x)); } + +/// One step of the recurrence. One thread per (h, j) column of the state. +/// +/// `ks`/`qs` stage `k[idx[h]]` and `q[idx[h]]` for the block's heads. Within a warp h is constant and j +/// varies, so the staged row is read with a single broadcast - which is also why the GLOBAL loads of +/// `q`/`k` are done once per block into shared instead of once per thread. +__global__ void gdn_step_kernel(float* __restrict__ state, const float* __restrict__ q, + const float* __restrict__ k, const float* __restrict__ v, + const float* __restrict__ gate, const float* __restrict__ beta, + float* __restrict__ o, int S, int h_k, int h_v) { + __shared__ float ks[MAX_H][128]; + __shared__ float qs[MAX_H][128]; + __shared__ float dec_s[MAX_H]; + __shared__ float beta_s[MAX_H]; + + const int h0 = blockIdx.y * MAX_H; + const int nh = min(MAX_H, h_v - h0); + const int j = blockIdx.x * JTHREADS + threadIdx.x; + + // stage this block's q/k rows and its per-head scalars + for (int hi = 0; hi < nh; ++hi) { + const int h = h0 + hi; + const int src = h % h_k; // MODULO head pairing + for (int i = threadIdx.x; i < S; i += JTHREADS) { + ks[hi][i] = k[src * S + i]; + qs[hi][i] = q[src * S + i]; + } + if (threadIdx.x == 0) { + dec_s[hi] = expf(gate[h]); + beta_s[hi] = beta[h]; + } + } + __syncthreads(); + if (j >= S) return; + + for (int hi = 0; hi < nh; ++hi) { + const int h = h0 + hi; + const float dec = dec_s[hi], b = beta_s[hi]; + float* col = state + (size_t) h * S + j; // (S, h_v, S) with j fastest + const size_t stride = (size_t) h_v * S; + + // pass 1: decay the state and contract it against k. Decay BEFORE the update - PROPERTY 5. + float sk = 0.0f; + for (int i = 0; i < S; ++i) { + const float s = col[(size_t) i * stride] * dec; + col[(size_t) i * stride] = s; + sk += s * ks[hi][i]; + } + const float d = (v[(size_t) h * S + j] - sk) * b; + // pass 2: the rank-1 update, then read out against q using the UPDATED state + float dot = 0.0f; + for (int i = 0; i < S; ++i) { + const float s = col[(size_t) i * stride] + ks[hi][i] * d; + col[(size_t) i * stride] = s; + dot += s * qs[hi][i]; + } + o[(size_t) h * S + j] = dot; + } +} + +/// `ggml_ssm_conv`: out[c] = sum_i inp[i][c] * kW[c*d_conv + i], inp = [conv_state | x], and the state slides. +/// One thread per channel; d_conv is 4, so the serial work per thread is a 4-tap dot and an unrolled shift. +__global__ void gdn_conv_kernel(float* __restrict__ cs, const float* __restrict__ x, + const float* __restrict__ kW, float* __restrict__ out, int C, int dc) { + const int c = blockIdx.x * blockDim.x + threadIdx.x; + if (c >= C) return; + float* st = cs + (size_t) c * (dc - 1); + const float* w = kW + (size_t) c * dc; + float acc = 0.0f; + for (int i = 0; i < dc - 1; ++i) acc += st[i] * w[i]; // kernel[0] reads the OLDEST state row + acc += x[c] * w[dc - 1]; // the new input lands in the LAST tap + out[c] = acc; + for (int i = 0; i < dc - 2; ++i) st[i] = st[i + 1]; // slide: drop the oldest, append the newest + st[dc - 2] = x[c]; +} + +/// `build_gdn_l2_norm`, one warp per row: x / sqrt(sum(x^2) + eps) over the last axis. +/// +/// The sum is accumulated in DOUBLE, as `ref/gdn.py` does (`l2_norm(q.astype(np.float64), eps)`), because the +/// reference deliberately computes this one in float64 and then casts - so matching its precision is part of +/// transcribing it. +__global__ void gdn_l2_kernel(float* __restrict__ x, int cols, float eps) { + const int row = blockIdx.x; + float* p = x + (size_t) row * cols; + double acc = 0.0; + for (int i = threadIdx.x; i < cols; i += blockDim.x) acc += (double) p[i] * (double) p[i]; + // warp reduction + for (int off = 16; off > 0; off >>= 1) acc += __shfl_down_sync(0xFFFFFFFFu, acc, off); + __shared__ double ssum; + if (threadIdx.x == 0) ssum = acc; + __syncthreads(); + const float inv = (float) (1.0 / sqrt(ssum + (double) eps)); + for (int i = threadIdx.x; i < cols; i += blockDim.x) p[i] *= inv; +} + +/// y = rms_norm(o, eps) * ssm_norm * sigmoid(z), one warp per head, norm over that head's S values. +__global__ void gdn_out_norm_kernel(const float* __restrict__ o, const float* __restrict__ z, + const float* __restrict__ ssm_norm, float* __restrict__ y, int S, + float eps) { + const int h = blockIdx.x; + const float* po = o + (size_t) h * S; + const float* pz = z + (size_t) h * S; + float* py = y + (size_t) h * S; + double acc = 0.0; + for (int i = threadIdx.x; i < S; i += blockDim.x) acc += (double) po[i] * (double) po[i]; + for (int off = 16; off > 0; off >>= 1) acc += __shfl_down_sync(0xFFFFFFFFu, acc, off); + __shared__ double ssum; + if (threadIdx.x == 0) ssum = acc; + __syncthreads(); + const float inv = (float) (1.0 / sqrt(ssum / (double) S + (double) eps)); + for (int i = threadIdx.x; i < S; i += blockDim.x) + py[i] = po[i] * inv * ssm_norm[i] * sigmoid_f(pz[i]); +} + +} // namespace + +void gdn_step(float* state, const float* q, const float* k, const float* v, const float* gate, + const float* beta, float* o, const GdnShapes& s, void* stream) { + if (s.S <= 0 || s.h_k <= 0 || s.h_v <= 0) return; + if (s.S > 128) { + std::fprintf(stderr, "gdn_step: S = %lld exceeds the staged 128 (`ks`/`qs` are [8][128])\n", + (long long) s.S); + std::exit(1); + } + const dim3 grid((unsigned) ((s.S + JTHREADS - 1) / JTHREADS), + (unsigned) ((s.h_v + MAX_H - 1) / MAX_H)); + gdn_step_kernel<<>>(state, q, k, v, gate, beta, o, (int) s.S, + (int) s.h_k, (int) s.h_v); + if (stream == nullptr) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "gdn_step: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +void gdn_conv_step(float* conv_state, const float* x, const float* kW, float* out, int64_t channels, + int64_t d_conv, void* stream) { + if (channels <= 0 || d_conv < 1) return; + const int blocks = (int) ((channels + 255) / 256); + gdn_conv_kernel<<>>(conv_state, x, kW, out, (int) channels, + (int) d_conv); + if (stream == nullptr) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "gdn_conv_step: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +void gdn_l2_norm(float* x, int64_t rows, int64_t cols, float eps, void* stream) { + if (rows <= 0 || cols <= 0) return; + if (cols > 1024) { + std::fprintf(stderr, "gdn_l2_norm: cols = %lld exceeds the 1024-wide warp reduction\n", (long long) cols); + std::exit(1); + } + gdn_l2_kernel<<<(unsigned) rows, 32, 0, (cudaStream_t) stream>>>(x, (int) cols, eps); + if (stream == nullptr) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "gdn_l2_norm: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +/// `beta = sigmoid(beta)`, in place, over the `h_v` per-head scalars. +/// +/// **THIS IS `build_layer_attn_linear` L889, AND LEAVING IT OUT WAS THE C1 BUG.** The reference computes +/// `beta = ggml_sigmoid(ssm_beta @ cur)` and hands the RESULT to the delta net; `gdn_step_kernel` is documented +/// as `d[h,j] = (v[h,j] - sk[h,j]) * beta[h]` and applies no sigmoid of its own, so the layer glue owes it one. +/// The engine passed the raw pre-activation, which is unbounded and signed - the delta-net write strength then +/// stops being a fraction and the whole recurrence is wrong from layer 0. +/// +/// **IT SURVIVED `gdn_parity` BECAUSE THAT TEST SUPPLIES ITS OWN `beta`.** The kernel was always right; only +/// the glue between the projection and the kernel was wrong, and `ref/gdn.py` (`beta = sigmoid(wbeta @ x)`) and +/// `ref/model.py` (`beta = G.sigmoid(proj(...))`) both get it right - so the two transcriptions agreed with +/// each other and neither was ever compared against the C++ that actually runs. +__global__ void gdn_beta_gate_kernel(float* __restrict__ beta, int n) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) beta[i] = sigmoid_f(beta[i]); +} + +void gdn_beta_gate(float* beta, int64_t h_v, void* stream) { + if (beta == nullptr || h_v <= 0) return; + const int n = (int) h_v; + gdn_beta_gate_kernel<<<(unsigned) ((n + 127) / 128), 128, 0, (cudaStream_t) stream>>>(beta, n); + if (stream == nullptr) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "gdn_beta_gate: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +void gdn_out_norm(const float* o, const float* z, const float* ssm_norm, float* y, int64_t h_v, int64_t S, + float eps, void* stream) { + if (h_v <= 0 || S <= 0) return; + gdn_out_norm_kernel<<<(unsigned) h_v, 32, 0, (cudaStream_t) stream>>>(o, z, ssm_norm, y, (int) S, eps); + if (stream == nullptr) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "gdn_out_norm: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/gr.cu b/src/kernels/cuda/gr.cu new file mode 100644 index 00000000..7eea7f49 --- /dev/null +++ b/src/kernels/cuda/gr.cu @@ -0,0 +1,432 @@ +// src/kernels/cuda/gr.cu - P2.S2: the gated residual / hyper-connection, `gr_read` and `gr_write`. +// +// See include/strata/kernels/gr.hpp for the semantics, for why the weights are bf16, and for the history of +// this kernel's 134x-off-the-floor first version. The short form: it was launched `<<<1, 256>>>` so that +// `xn`/`xq` fit in shared memory, which used ONE of 48 SMs and cost 262 ms/token. +// +// THE SHAPE OF THE FIX is that each projection is an ordinary GEMV and is now mapped the way the shapes want: +// +// norm hc blocks, one per residual stream (each needs its own reduction) +// down ONE WARP PER OUTPUT ROW, lanes stride the 10240 reduction axis +// gate ONE WARP PER OUTPUT ROW, lanes stride the 320 reduction axis +// mean a flat elementwise pass over n_embd +// +// WARP-PER-ROW RATHER THAN THREAD-PER-ROW, and that is the whole difference. A thread walking one row is +// coalesced with ITSELF and 32 transactions away from its warp neighbours: `w_down[k*hc_dim + i]` for fixed k +// is contiguous, but two threads on rows k and k+1 are hc_dim*2 bytes apart. With the warp on one row, lanes +// i, i+1, i+2 touch consecutive addresses in EITHER orientation - which is why this version needs no +// transposed copies and takes both matrices exactly as the manifest stores them. +// +// Layouts are the MANIFEST's: `hc_attn_down.weight` is [10240, 320] with ne0 = hc*n_embd fast, so it holds +// `w_down[k][i]` with i contiguous - `ref/gr.py`'s (hc_lr, hc*n_embd) row-major. `hc_attn_up.weight` is +// [320, 10240] so it holds `w_up[i][k]` with k contiguous - `ref/gr.py`'s (hc*n_embd, hc_lr) row-major. Both +// index with no permutation at all. +#include "strata/kernels/gr.hpp" +#include "strata/kernels/bf16_bits.hpp" +#include "strata/kernels/bf16_gemv.hpp" +#include "strata/kernels/native_gr_norm.hpp" +#include "strata/kernels/native_gr_postops.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int THREADS = 256; +constexpr int WARPS = THREADS / 32; +bool fp32_activations = false; +bool native_mmvf = false; + +/// The bf16 conversions live in `strata/kernels/bf16_bits.hpp`, and this file used to carry its own copies. +/// Two files with a private copy of a conversion whose failure mode is silent wrong bits is one too many - +/// and the reason the shared header exists is that `bf16` and `fp16` are NOT two spellings of one idea, so a +/// routine written for one and reused for the other is wrong in a way that still produces numbers. +/// +/// The local names are kept as thin wrappers so the body below is untouched by the swap. +__device__ __forceinline__ uint16_t f32_to_bf16_bits(float f) { return bf16_from_f32(f); } +__device__ __forceinline__ float bf16_bits_to_f32(uint16_t h) { return f32_from_bf16(h); } +__device__ __forceinline__ float activation_f32(float x) { return x; } +__device__ __forceinline__ float activation_f32(uint16_t x) { return bf16_bits_to_f32(x); } +__device__ __forceinline__ void store_activation(float* dst, int i, float x) { dst[i] = x; } +__device__ __forceinline__ void store_activation(uint16_t* dst, int i, float x) { + dst[i] = f32_to_bf16_bits(x); +} + +/// silu and sigmoid exactly as `ref/gr.py` writes them - in FLOAT, not double. +/// +/// This is the opposite of `shared_expert.cu`, which uses double, and the difference is not stylistic: +/// `ref/gr.py`'s arrays are float32, so `x / (1.0 + np.exp(-x))` evaluates in float32 under NEP 50, while +/// `ref/moe.py` works in float64. Matching the reference's PRECISION is part of transcribing it. +__device__ __forceinline__ float silu_f(float x) { return x / (1.0f + expf(-x)); } +__device__ __forceinline__ float sigmoid_f(float x) { return 1.0f / (1.0f + expf(-x)); } + +__device__ __forceinline__ double warp_sum(double v) { + for (int off = 16; off > 0; off >>= 1) v += __shfl_down_sync(0xFFFFFFFFu, v, off); + return __shfl_sync(0xFFFFFFFFu, v, 0); +} + +__device__ __forceinline__ float warp_sumf(float v) { + for (int off = 16; off > 0; off >>= 1) v += __shfl_down_sync(0xFFFFFFFFu, v, off); + return __shfl_sync(0xFFFFFFFFu, v, 0); +} + +/// The FP32 block-wide sum, for the reason the review's G5 states: this is a GeForce part and FP64 runs at a +/// small fraction of FP32, so a `double` reduction turns a bandwidth kernel into a latency kernel. +/// +/// The scratch is taken as `double*` and reinterpreted as `float*` rather than changing the callers' shared +/// arrays, because `double scratch[8]` is 64 bytes and so is at least as large as the `float[8]` this needs - +/// the cast cannot overrun, and it keeps the change to one function instead of one per caller. +__device__ float block_sumf(float v, double* scratch_raw) { + float* scratch = (float*) scratch_raw; + // The leading barrier is not decoration - see the note on `block_sum` above: the result is read straight out + // of `scratch[0]` by every thread and a later call reuses the array, so without it a fast thread can + // overwrite `scratch[0]` before a slow one has read the previous result. + __syncthreads(); + const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5; + v = warp_sumf(v); + if (lane == 0) scratch[warp] = v; + __syncthreads(); + const int nw = ((int) blockDim.x + 31) >> 5; + v = (threadIdx.x < nw) ? scratch[threadIdx.x] : 0.0f; + if (warp == 0) v = warp_sumf(v); + if (threadIdx.x == 0) scratch[0] = v; + __syncthreads(); + return scratch[0]; +} + +/// Block-wide sum in DOUBLE, broadcast to every thread. +/// +/// Double because `ref/gr.py` reduces in float64 for the norm and the bottleneck, and because a block-wide +/// f32 sum of 2560 squares is a different number from the reference's; the shuffle tree here also reorders +/// the additions, so the only defence is enough precision that the order stops mattering. +/// +/// The leading `__syncthreads()` is not decoration: the result is read straight out of `scratch[0]` by every +/// thread, and a later call reuses the same array. Without a barrier on entry a fast thread can overwrite +/// `scratch[0]` before a slow one has read the previous result - a race that is invisible in most runs. +__device__ double block_sum(double v, double* scratch) { + __syncthreads(); + const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5; + v = warp_sum(v); + if (lane == 0) scratch[warp] = v; + __syncthreads(); + const int nw = ((int) blockDim.x + 31) >> 5; + v = (threadIdx.x < nw) ? scratch[threadIdx.x] : 0.0; + if (warp == 0) v = warp_sum(v); + if (threadIdx.x == 0) scratch[0] = v; + __syncthreads(); + return scratch[0]; +} + +/// `hc` blocks, one per residual stream: per-stream RMSNorm, then the activated stream in both forms. +/// +/// ggml_rms_norm reduces over ne[0], and ne[0] is n_embd, so the reduction is PER STREAM. The `[n_embd, hc]` +/// gamma then scales each stream individually. +/// +/// A per-thread accumulator is only a sum when the block is narrower than the row, which is why this is a +/// block reduction: with `n_embd` equal to the thread count every thread would hold exactly one element and +/// normalise by its own square. That mistake is silent - it produces a plausible vector of the right shape - +/// and it is what `gr_parity`'s `mixed vs reference` caught at 2.0e+01 in round 196. +template +__global__ void gr_norm_kernel(const float* __restrict__ R, const float* __restrict__ w_norm, float eps, + int n_embd, float* __restrict__ xn, uint16_t* __restrict__ xq) { + __shared__ double scratch[8]; + const int c = blockIdx.x; + const float* Rc = R + (size_t) c * n_embd; + float* xnc = xn + (size_t) c * n_embd; + uint16_t* xqc = xq + (size_t) c * n_embd; + + // FP32, for the same reason as `gr_down_kernel` and `gr_inject_kernel`: this is a GeForce part, FP64 runs + // at a small fraction of FP32, and the sum of squares is a pure reduction. It was the last `double` on the + // `gr_read` path. `gr_norm` is only 0.80 ms/token, so this is hygiene rather than a headline - but it is + // 12 of the FP64 sites the review's G5 counted in this file, and the rule it states ("no FP64 on any decode + // or prefill path") is worth being able to check mechanically rather than case by case. + float ss = 0.0f; + for (int d = threadIdx.x; d < n_embd; d += blockDim.x) { + const float v = Rc[d]; + ss += v * v; + } + const float ms = block_sumf(ss, scratch) / (float) n_embd; + const float rs = rsqrtf(ms + eps); + for (int d = threadIdx.x; d < n_embd; d += blockDim.x) { + const float x = Rc[d] * rs * w_norm[(size_t) c * n_embd + d]; + xnc[d] = x; + if constexpr (!FP32_ACT) xqc[d] = f32_to_bf16_bits(x); + } +} + +/// `lo = silu((bf16(xn) @ w_down.T) / hc)`, ONE BLOCK PER OUTPUT ROW with its warps splitting the reduction. +template +__global__ void gr_down_kernel(const Activation* __restrict__ xq, const uint16_t* __restrict__ w_down, int hc_dim, + int hc_lr, int hc, Activation* __restrict__ lq) { + const int k = blockIdx.x; + if (k >= hc_lr) return; + const int lane = threadIdx.x & 31; + const int warp = threadIdx.x >> 5; + const int nw = (int) (blockDim.x >> 5); // warps per row == warps per block + const uint16_t* row = w_down + (size_t) k * hc_dim; + // **THE OCCUPANCY HERE IS 10% AND IT IS REAL, BUT FIXING IT DOES NOT PAY.** `hc_lr` is 320, one warp per + // row, 8 warps per block, so this launches 40 blocks = 320 warps: **6.7 warps per SM out of the 64 a + // Blackwell SM holds**, each lane walking 10,240 elements in 320 dependent steps. + // + // Four independent accumulators were tried to break the dependency chain and put four loads in flight per + // lane. **Measured: 39.86 ms/token of pure GPU against 39.80, i.e. no change**, with `gr_parity` green + // either way. Removed, on the rule this file already states about `#pragma unroll 4`: a hint that does not + // change the timing is a claim that is not true. + // + // The reason is that `gr_read`'s cost is not here. Its four kernels are the norm (4 blocks), this (40), + // `gr_gate_kernel` (1,280) and the mean (10), and the 1,280-block gate kernel reads 6.55 MB of weights with + // good occupancy - so `gr_read`'s ~93 us is mostly the gate kernel at its own memory rate, and the ~165 GB/s + // whole-call figure is an average over kernels that do not share a rate. + // **FP32, AND THE FP64 THAT WAS HERE COST MORE THAN EVERYTHING ELSE IN `gr_read`.** + // + // This accumulated in `double`. GeForce Blackwell executes FP64 at a small fraction of FP32 - 1/64 on + // recent parts - so a 320-step dependent FP64 chain per lane turned a memory-bound reduction into a + // latency-bound one. The `nsys` per-kernel table (round 2) measured it at **5.07 ms/token, 15.3% of all + // GPU time**, against `gr_gate_kernel`'s 1.23 ms - which REFUTES the comment that used to sit here, that + // "gr_read's ~93 us is mostly the gate kernel at its own memory rate". It was not: down + inject were + // 8.77 of gr_read's 10.8 ms, and the gate was 1.23. + // + // The justification for `double` was matching `ref/gr.py`'s float64 transcription. **The ground truth is + // llama.cpp, and llama.cpp accumulates in FP32** - so this does not move away from the oracle, it moves + // toward it, and the review's G1 lists it first. + // + // Four independent accumulators were tried here BEFORE, against the FP64 version, and measured no change + // (39.86 vs 39.80 ms/token) - because FP64 throughput was the limit, not the dependency chain. With FP32 + // the kernel becomes memory-bound: it reads ~13 MB per call across xq and w_down, which at the measured + // 641 GB/s is ~20 us against the 52 us this took. + // **THE OCCUPANCY CONCLUSION ABOVE IS STALE, AND THIS IS THE RETEST IT ASKED FOR.** + // + // "Fixing it does not pay" was measured against the FP64 version, where FP64 THROUGHPUT was the limit and + // more parallelism could not help. The kernel is FP32 now (round 282: 5.07 -> 2.38 ms/token) and the limit + // moved to memory - which is the regime where occupancy does pay, because at 6.7 warps/SM there are not + // enough bytes in flight to cover DRAM latency. The old result is not wrong; it is about a different kernel. + // + // One BLOCK per row, its 8 warps splitting the 10,240-element reduction: 320 blocks x 8 = **2,560 warps = + // 53 warps/SM instead of 6.7**, with no second kernel and no partial buffer - the eight partials meet in + // shared memory. Lanes within a warp still read consecutive addresses, because warp `w` starts at + // `w * 32` and strides by `nw * 32`. + float acc = 0.0f; + for (int i = warp * 32 + lane; i < hc_dim; i += nw * 32) + acc += activation_f32(xq[i]) * bf16_bits_to_f32(row[i]); + acc = warp_sumf(acc); + + __shared__ float part[WARPS]; + if (lane == 0) part[warp] = acc; + __syncthreads(); + if (warp == 0) { + float t = (lane < nw) ? part[lane] : 0.0f; + t = warp_sumf(t); + // NOTE: the reduction order differs from the one-warp version, so this is a NUMERICS CHANGE. C1 is the + // gate that judges it - not this comment, and not `gr_parity`, which compares against the reference on + // a fixture that a changed summation order can still pass. + if (lane == 0) store_activation(lq, k, silu_f(t / (float) hc)); + } +} + +/// `gated[i] = xn[i] * sigmoid(bf16(lo) @ w_up.T)`, one warp per output row, lanes striding hc_lr. +template +__global__ void gr_gate_kernel(const Activation* __restrict__ lq, const uint16_t* __restrict__ w_up, + const float* __restrict__ xn, int hc_dim, int hc_lr, float* __restrict__ gated) { + const int i = blockIdx.x * WARPS + (threadIdx.x >> 5); + if (i >= hc_dim) return; + const int lane = threadIdx.x & 31; + const uint16_t* row = w_up + (size_t) i * hc_lr; + float acc = 0.0f; + for (int k = lane; k < hc_lr; k += 32) + acc += activation_f32(lq[k]) * bf16_bits_to_f32(row[k]); + acc = warp_sumf(acc); + // Every lane computes the sigmoid (the shuffle broadcasts acc), so there is no second sync point and no + // branch; lane 0 stores. + if (lane == 0) gated[i] = xn[i] * sigmoid_f(acc); +} + +/// `mixed[d] = mean over c of gated[c][d]`. Flat elementwise: the mean is over the streams, which are +/// strided by n_embd, so no cross-thread reduction is needed at all. +__global__ void gr_mean_kernel(const float* __restrict__ gated, int n_embd, int hc, float* __restrict__ mixed) { + const int d = blockIdx.x * blockDim.x + threadIdx.x; + if (d >= n_embd) return; + float m = 0.0f; + for (int c = 0; c < hc; ++c) m += gated[(size_t) c * n_embd + d]; + mixed[d] = m / (float) hc; +} + +/// `inject[c] = bf16(xn) @ w_inject[c]`, one value per stream: ONE WARP PER STREAM, lanes striding hc_dim. +/// +/// This mirrors `gr_down_kernel` rather than using a block-wide reduction. `hc` is 4 at the real geometry, +/// so a block per stream would put 256 threads on a 10240-element dot and leave the reduction to do all the +/// work; a warp per stream is the same shape as the down projection and reuses its pattern exactly. The +/// whole kernel is one block of `32*hc` threads, which is small - and it is a 4-value output, so it is. +template +__global__ void gr_inject_kernel(const Activation* __restrict__ xq, const uint16_t* __restrict__ w_inject, + int hc_dim, int hc, float* __restrict__ inject) { + const int c = threadIdx.x >> 5; + if (c >= hc) return; + const int lane = threadIdx.x & 31; + const uint16_t* row = w_inject + (size_t) c * hc_dim; + // FP32, for the same reason as `gr_down_kernel` - and this one is worse. A ONE-BLOCK kernel doing four + // 10,240-element FP64 reductions measured **3.70 ms/token, 11.1% of all GPU time, from a single SM out of + // 48.** It is the fourth largest consumer of GPU time in the engine and it cannot use more than 1/48th of + // the machine. In FP32 the same reduction is a few microseconds and the block size stops mattering. + float acc = 0.0f; + for (int i = lane; i < hc_dim; i += 32) + acc += activation_f32(xq[i]) * bf16_bits_to_f32(row[i]); + acc = warp_sumf(acc); + if (lane == 0) inject[c] = acc; +} + +__global__ void gr_write_kernel(const float* __restrict__ R, const float* __restrict__ block_out, + const float* __restrict__ inject, int n_embd, int hc, float* __restrict__ out) { + // w = 2*sigmoid(inject/hc), computed once per stream in shared rather than per element. + extern __shared__ unsigned char smem_raw[]; + float* w = (float*) smem_raw; + if ((int) threadIdx.x < hc) w[threadIdx.x] = 2.0f * sigmoid_f(inject[threadIdx.x] / (float) hc); + __syncthreads(); + const long long n = (long long) hc * n_embd; + for (long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; i < n; + i += (long long) gridDim.x * blockDim.x) { + const int c = (int) (i / n_embd), d = (int) (i % n_embd); + // every stream adds the SAME block output; only the weight differs per stream + out[i] = R[i] + block_out[d] * w[c]; + } +} + +} // namespace + +void gr_set_fp32_activations(bool enabled) { fp32_activations = enabled; } +void gr_set_native_mmvf(bool enabled) { native_mmvf = enabled; } + +size_t gr_workspace_init(const GrShapes& s, void* base, GrWorkspace& out) { + const size_t hc_dim = (size_t) s.hc * (size_t) s.n_embd; + // ONE table, indexed by the same `k` that assigns the pointers below. The first version of this function + // sized three locals named `xn`, `xq`, `lq` in a DIFFERENT order from the pointer assignments - `xq` got + // hc_lr*2 while `lq` got hc_dim*4 - so for the real geometry xq was given 640 bytes where it needed + // 20,480 and every region overlapped its neighbour. + // + // What that looked like is worth recording: `inject` came out ~20% wrong, and rewriting its reduction + // from block-wide to warp-wide produced BYTE-IDENTICAL wrong answers. Two different reductions agreeing + // exactly is not a coincidence to investigate - it means the reduction is not where the bug is, and the + // inputs are. Chasing the reduction instead cost most of a round. + const size_t sz[5] = { + hc_dim * sizeof(float), // 0: xn + hc_dim * sizeof(uint16_t), // 1: xq + (size_t) s.hc_lr * sizeof(uint16_t), // 2: lq + hc_dim * sizeof(float), // 3: gated + (size_t) s.hc_lr * sizeof(float), // 4: lo (FP32 activation experiment) + }; + size_t al[5], bytes = 0; + for (int k = 0; k < 5; ++k) { + al[k] = (sz[k] + 15) & ~(size_t) 15; + bytes += al[k]; + } + out.bytes = bytes; + if (base != nullptr) { + unsigned char* p = (unsigned char*) base; + void* ptr[5]; + for (int k = 0; k < 5; ++k) { + ptr[k] = p; + p += al[k]; + } + out.xn = (float*) ptr[0]; + out.xq = (uint16_t*) ptr[1]; + out.lq = (uint16_t*) ptr[2]; + out.gated = (float*) ptr[3]; + out.lo = (float*) ptr[4]; + } + return bytes; +} + +void gr_read(const float* R, const float* w_norm, const uint16_t* w_down, const uint16_t* w_up, + const uint16_t* w_inject, float eps, const GrShapes& s, const GrWorkspace& ws, float* mixed, + float* inject, void* stream) { + if (s.n_embd <= 0 || s.hc <= 0 || s.hc_lr <= 0) return; + if (ws.xn == nullptr || ws.xq == nullptr || ws.lq == nullptr || ws.gated == nullptr || ws.lo == nullptr) { + std::fprintf(stderr, "gr_read: GrWorkspace is not initialised (see gr_workspace_init)\n"); + std::exit(1); + } + if (ws.bytes < gr_workspace_bytes(s)) { + std::fprintf(stderr, "gr_read: GrWorkspace is %zu bytes but this geometry needs %zu\n", + ws.bytes, gr_workspace_bytes(s)); + std::exit(1); + } + const int n_embd = (int) s.n_embd, hc = (int) s.hc, hc_lr = (int) s.hc_lr; + const int hc_dim = (int) (s.hc * s.n_embd); + cudaStream_t st = (cudaStream_t) stream; + + // One setting selects every projection in this call. Captured graphs retain these kernel variants. + const bool use_native = native_mmvf; + const bool use_fp32 = fp32_activations || use_native; + if (use_native) { + if ((hc_dim & 1) != 0 || (hc_lr & 1) != 0) + throw std::invalid_argument("gr_read native MMVF requires even hc*n_embd and hc_lr"); + native_gr_rms_norm_weighted(R, w_norm, ws.xn, n_embd, hc, eps, stream); + bf16_gemv_fp32_mmvf(ws.xn, w_down, ws.lo, hc_dim, hc_lr, stream); + native_gr_down_silu(ws.lo, hc_lr, hc, stream); + bf16_gemv_fp32_mmvf(ws.lo, w_up, ws.gated, hc_lr, hc_dim, stream); + // The existing null-injection contract identifies the final mixer. + // Pinned qwen4exp uses fused HC pre for layers and the unfused graph for il=-1. + native_gr_pre_gated(ws.xn, ws.gated, mixed, n_embd, hc, w_inject != nullptr, stream); + } else if (use_fp32) { + gr_norm_kernel<<>>(R, w_norm, eps, n_embd, ws.xn, ws.xq); + gr_down_kernel<<>>(ws.xn, w_down, hc_dim, hc_lr, hc, ws.lo); + gr_gate_kernel<<<(hc_dim + WARPS - 1) / WARPS, THREADS, 0, st>>>( + ws.lo, w_up, ws.xn, hc_dim, hc_lr, ws.gated); + } else { + gr_norm_kernel<<>>(R, w_norm, eps, n_embd, ws.xn, ws.xq); + gr_down_kernel<<>>(ws.xq, w_down, hc_dim, hc_lr, hc, ws.lq); + gr_gate_kernel<<<(hc_dim + WARPS - 1) / WARPS, THREADS, 0, st>>>( + ws.lq, w_up, ws.xn, hc_dim, hc_lr, ws.gated); + } + if (!use_native) + gr_mean_kernel<<<(n_embd + THREADS - 1) / THREADS, THREADS, 0, st>>>(ws.gated, n_embd, hc, mixed); + // Absent for the final mixer, and then nothing is written - computing an injection nobody reads would be + // a claim, not a convenience. + if (w_inject != nullptr) { + const int nthreads = 32 * hc; + if (use_native) + bf16_gemv_fp32_mmvf(ws.xn, w_inject, inject, hc_dim, hc, stream); + else if (use_fp32) + gr_inject_kernel<<<1, nthreads, 0, st>>>(ws.xn, w_inject, hc_dim, hc, inject); + else + gr_inject_kernel<<<1, nthreads, 0, st>>>(ws.xq, w_inject, hc_dim, hc, inject); + } + + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "gr_read launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) { + const cudaError_t se = cudaDeviceSynchronize(); + if (se != cudaSuccess) { + std::fprintf(stderr, "gr_read: %s\n", cudaGetErrorString(se)); + std::exit(1); + } + } +} + +void gr_write(const float* R, const float* block_out, const float* inject, const GrShapes& s, float* R_out, + void* stream) { + if (s.n_embd <= 0 || s.hc <= 0) return; + const long long n = (long long) s.hc * s.n_embd; + const int blocks = (int) ((n + THREADS - 1) / THREADS); + if (native_mmvf) + native_gr_post(R, block_out, inject, R_out, (int) s.n_embd, (int) s.hc, stream); + else + gr_write_kernel<<>>( + R, block_out, inject, (int) s.n_embd, (int) s.hc, R_out); + if (stream == nullptr) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "gr_write: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/iq_kernels.cu b/src/kernels/cuda/iq_kernels.cu new file mode 100644 index 00000000..3525b91a --- /dev/null +++ b/src/kernels/cuda/iq_kernels.cu @@ -0,0 +1,720 @@ +// src/kernels/cuda/iq_kernels.cu - see include/strata/kernels/iq_kernels.hpp. +// +// The dot products (vec_dot_*_q8_1), the dequantizers and the q8_1 quantizer are transcribed from llama.cpp +// (ggml/src/ggml-cuda/vecdotq.cuh, dequantize.cuh, quantize.cu at the commit in third_party/ggml/VERSION.txt; +// MIT license, third_party/ggml/LICENSE). The block structs and codebook grids come from its ggml-common.h, +// included unchanged. +#include "strata/kernels/iq_kernels.hpp" + +#include +#include + +#define GGML_COMMON_DECL_CUDA +#define GGML_COMMON_IMPL_CUDA +#include "ggml-common.h" + +#include +#include + +namespace strata::kernels { +namespace { + +void check(const char* what) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); std::exit(1); } +} + +// ---------------------------------------------------------------- llama.cpp helpers (vecdotq.cuh) +__device__ __forceinline__ int get_int_b2(const void* x, const int& i32) { + const uint16_t* x16 = (const uint16_t*) x; + int x32 = x16[2 * i32 + 0] << 0; + x32 |= x16[2 * i32 + 1] << 16; + return x32; +} +__device__ __forceinline__ int get_int_b4(const void* x, const int& i32) { return ((const int*) x)[i32]; } +__device__ __forceinline__ uint32_t unpack_ksigns(const uint8_t v) { + const uint32_t p = __popc(v) & 1; + const uint32_t s = v ^ p << 7; + return s * 0x01010101; +} +__device__ __forceinline__ int2 get_int_from_table_16(const int& q4, const int8_t* table) { + const uint32_t* table32 = (const uint32_t*) table; + uint32_t tmp[2]; + const uint32_t low_high_selection_indices = (0x32103210 | ((q4 & 0x88888888) >> 1)); +#pragma unroll + for (uint32_t i = 0; i < 2; ++i) { + const uint32_t shift = 16 * i; + const uint32_t low = __byte_perm(table32[0], table32[1], q4 >> shift); + const uint32_t high = __byte_perm(table32[2], table32[3], q4 >> shift); + tmp[i] = __byte_perm(low, high, low_high_selection_indices >> shift); + } + return make_int2(__byte_perm(tmp[0], tmp[1], 0x6420), __byte_perm(tmp[0], tmp[1], 0x7531)); +} +#define ggml_cuda_dp4a(a, b, c) __dp4a((a), (b), (c)) + +// ---------------------------------------------------------------- the dot products (vecdotq.cuh) +__device__ __forceinline__ float vec_dot_q2_0_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1, + const int& kbx, const int& iqs) { + const block_q2_0* bq2_0 = (const block_q2_0*) vbq + kbx; + const float d2 = bq2_0->d; + const int16_t* qs = (const int16_t*) bq2_0->qs + iqs * 4; + const block_q8_1* bq8_1_chunk = bq8_1 + iqs; + int sumi = 0; +#pragma unroll + for (int j = 0; j < 4; ++j) { + const int q = qs[j]; + const int u = get_int_b4(bq8_1_chunk->qs, j * 2 + 0); + const int v = get_int_b4(bq8_1_chunk->qs, j * 2 + 1); + const int qe = __byte_perm(0x020100FF, 0x020100FF, q >> 0); + const int qo = __byte_perm(0x020100FF, 0x020100FF, q >> 2); + const int qx = __byte_perm(qe, qo, 0x5140); + const int qy = __byte_perm(qe, qo, 0x7362); + sumi = ggml_cuda_dp4a(u, qx, sumi); + sumi = ggml_cuda_dp4a(v, qy, sumi); + } + const float d8 = __low2float(bq8_1_chunk->ds); + return d2 * d8 * sumi; +} + +__device__ __forceinline__ float vec_dot_iq2_xxs_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1, + const int& kbx, const int& iqs) { + const block_iq2_xxs* bq2 = (const block_iq2_xxs*) vbq + kbx; + const int q2 = get_int_b2(bq2->qs, iqs); + const uint8_t* aux8 = (const uint8_t*) &q2; + const uint32_t aux32 = get_int_b2(bq2->qs, iqs + 1); + int sumi = 0; +#pragma unroll + for (int k0 = 0; k0 < 8; k0 += 2) { + const uint2 grid_pos = ((const uint2*) iq2xxs_grid)[aux8[k0 / 2]]; + const uint32_t signs = unpack_ksigns(aux32 >> (7 * k0 / 2)); + const int signs0 = __vcmpne4(signs & 0x08040201, 0); + const int grid0 = __vsub4(grid_pos.x ^ signs0, signs0); + const int u0 = get_int_b4(bq8_1[iqs / 2].qs, k0 + 0); + sumi = ggml_cuda_dp4a(grid0, u0, sumi); + const int signs1 = __vcmpne4(signs & 0x80402010, 0); + const int grid1 = __vsub4(grid_pos.y ^ signs1, signs1); + const int u1 = get_int_b4(bq8_1[iqs / 2].qs, k0 + 1); + sumi = ggml_cuda_dp4a(grid1, u1, sumi); + } + const int ls = aux32 >> 27 | 1; + sumi = sumi * ls / 8; + const float d = __half2float(bq2->d) * __low2float(bq8_1[iqs / 2].ds); + return d * sumi; +} + +__device__ __forceinline__ float vec_dot_iq2_xs_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1, + const int& kbx, const int& iqs) { + const block_iq2_xs* bq2 = (const block_iq2_xs*) vbq + kbx; + const int2 q2_packed = make_int2(get_int_b2(bq2->qs, iqs + 0), get_int_b2(bq2->qs, iqs + 1)); + const uint16_t* q2 = (const uint16_t*) &q2_packed; + const int ls0 = bq2->scales[iqs / 2] & 0x0F; + const int ls1 = bq2->scales[iqs / 2] >> 4; + int sumi0 = 0, sumi1 = 0; +#pragma unroll + for (int l0 = 0; l0 < 8; l0 += 2) { + const uint2 grid_pos = ((const uint2*) iq2xs_grid)[q2[l0 / 2] & 0x1FF]; + const uint32_t signs = unpack_ksigns(q2[l0 / 2] >> 9); + const int signs0 = __vcmpne4(signs & 0x08040201, 0); + const int grid_l = __vsub4(grid_pos.x ^ signs0, signs0); + const int u0 = get_int_b4(bq8_1[iqs / 2].qs, l0 + 0); + const int signs1 = __vcmpne4(signs & 0x80402010, 0); + const int grid_h = __vsub4(grid_pos.y ^ signs1, signs1); + const int u1 = get_int_b4(bq8_1[iqs / 2].qs, l0 + 1); + if (l0 < 4) { + sumi0 = ggml_cuda_dp4a(grid_l, u0, sumi0); + sumi0 = ggml_cuda_dp4a(grid_h, u1, sumi0); + } else { + sumi1 = ggml_cuda_dp4a(grid_l, u0, sumi1); + sumi1 = ggml_cuda_dp4a(grid_h, u1, sumi1); + } + } + const int sumi = (sumi0 * ls0 + sumi1 * ls1 + (sumi0 + sumi1) / 2) / 4; + const float d = __half2float(bq2->d) * __low2float(bq8_1[iqs / 2].ds); + return d * sumi; +} + +__device__ __forceinline__ float vec_dot_iq2_s_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1, + const int& kbx, const int& iqs) { + const block_iq2_s* bq2 = (const block_iq2_s*) vbq + kbx; + const int qs_packed = get_int_b2(bq2->qs, iqs / 2); + const uint8_t* qs = (const uint8_t*) &qs_packed; + const int qh = bq2->qh[iqs / 2]; + const int signs_packed_32 = get_int_b2(bq2->qs, QK_K / 32 + iqs / 2); + const uint8_t* signs_packed_8 = (const uint8_t*) &signs_packed_32; + const int ls0 = bq2->scales[iqs / 2] & 0x0F; + const int ls1 = bq2->scales[iqs / 2] >> 4; + int sumi0 = 0, sumi1 = 0; +#pragma unroll + for (int l0 = 0; l0 < 8; l0 += 2) { + const int* grid_pos = (const int*) (iq2s_grid + (qs[l0 / 2] | ((qh << (8 - l0)) & 0x300))); + const int signs0 = __vcmpne4(((signs_packed_8[l0 / 2] & 0x03) << 7) | ((signs_packed_8[l0 / 2] & 0x0C) << 21), 0x00000000); + const int signs1 = __vcmpne4(((signs_packed_8[l0 / 2] & 0x30) << 3) | ((signs_packed_8[l0 / 2] & 0xC0) << 17), 0x00000000); + const int grid_l = __vsub4(grid_pos[0] ^ signs0, signs0); + const int grid_h = __vsub4(grid_pos[1] ^ signs1, signs1); + const int u0 = get_int_b4(bq8_1[iqs / 2].qs, l0 + 0); + const int u1 = get_int_b4(bq8_1[iqs / 2].qs, l0 + 1); + if (l0 < 4) { + sumi0 = ggml_cuda_dp4a(grid_l, u0, sumi0); + sumi0 = ggml_cuda_dp4a(grid_h, u1, sumi0); + } else { + sumi1 = ggml_cuda_dp4a(grid_l, u0, sumi1); + sumi1 = ggml_cuda_dp4a(grid_h, u1, sumi1); + } + } + const int sumi = (sumi0 * ls0 + sumi1 * ls1 + (sumi0 + sumi1) / 2) / 4; + const float d = __half2float(bq2->d) * __low2float(bq8_1[iqs / 2].ds); + return d * sumi; +} + +__device__ __forceinline__ float vec_dot_iq3_xxs_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1, + const int& kbx, const int& iqs) { + const block_iq3_xxs* bq3 = (const block_iq3_xxs*) vbq + kbx; + const int2 q3_packed = make_int2(get_int_b2(bq3->qs, iqs), get_int_b2(bq3->qs, iqs + 1)); + const uint8_t* q3 = (const uint8_t*) &q3_packed; + const uint32_t aux32 = get_int_b2(bq3->qs, QK_K / 16 + iqs / 2); + int sumi = 0; +#pragma unroll + for (int l0 = 0; l0 < 8; l0 += 2) { + const int2 grid_pos = make_int2(iq3xxs_grid[q3[l0 + 0]], iq3xxs_grid[q3[l0 + 1]]); + const uint32_t signs = unpack_ksigns(aux32 >> (7 * l0 / 2)); + const int signs0 = __vcmpne4(signs & 0x08040201, 0); + const int grid_l = __vsub4(grid_pos.x ^ signs0, signs0); + const int u0 = get_int_b4(bq8_1[iqs / 2].qs, l0 + 0); + const int signs1 = __vcmpne4(signs & 0x80402010, 0); + const int grid_h = __vsub4(grid_pos.y ^ signs1, signs1); + const int u1 = get_int_b4(bq8_1[iqs / 2].qs, l0 + 1); + sumi = ggml_cuda_dp4a(grid_l, u0, sumi); + sumi = ggml_cuda_dp4a(grid_h, u1, sumi); + } + const int ls = aux32 >> 28; + sumi = (ls * sumi + sumi / 2) / 2; + const float d = __half2float(bq3->d) * __low2float(bq8_1[iqs / 2].ds); + return d * sumi; +} + +__device__ __forceinline__ float vec_dot_iq3_s_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1, + const int& kbx, const int& iqs) { + const block_iq3_s* bq3 = (const block_iq3_s*) vbq + kbx; + const int2 qs_packed = make_int2(get_int_b2(bq3->qs, iqs + 0), get_int_b2(bq3->qs, iqs + 1)); + const uint8_t* qs = (const uint8_t*) &qs_packed; + const int qh = bq3->qh[iqs / 2]; + const int signs_packed_32 = get_int_b2(bq3->signs, iqs / 2); + const uint8_t* signs_packed_8 = (const uint8_t*) &signs_packed_32; + int sumi = 0; +#pragma unroll + for (int l0 = 0; l0 < 8; l0 += 2) { + const int2 grid_pos = make_int2(iq3s_grid[qs[l0 + 0] | ((qh << (8 - l0)) & 0x100)], + iq3s_grid[qs[l0 + 1] | ((qh << (7 - l0)) & 0x100)]); + const int signs0 = __vcmpne4(((signs_packed_8[l0 / 2] & 0x03) << 7) | ((signs_packed_8[l0 / 2] & 0x0C) << 21), 0x00000000); + const int signs1 = __vcmpne4(((signs_packed_8[l0 / 2] & 0x30) << 3) | ((signs_packed_8[l0 / 2] & 0xC0) << 17), 0x00000000); + const int grid_l = __vsub4(grid_pos.x ^ signs0, signs0); + const int grid_h = __vsub4(grid_pos.y ^ signs1, signs1); + const int u0 = get_int_b4(bq8_1[iqs / 2].qs, l0 + 0); + const int u1 = get_int_b4(bq8_1[iqs / 2].qs, l0 + 1); + sumi = ggml_cuda_dp4a(grid_l, u0, sumi); + sumi = ggml_cuda_dp4a(grid_h, u1, sumi); + } + sumi *= 1 + 2 * ((bq3->scales[iqs / 4] >> ((iqs << 1) & 0x04)) & 0x0F); + const float d = __half2float(bq3->d) * __low2float(bq8_1[iqs / 2].ds); + return d * sumi; +} + +__device__ __forceinline__ float vec_dot_iq1_m_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1, + const int& kbx, const int& iqs) { + const block_iq1_m* bq1 = (const block_iq1_m*) vbq + kbx; + const int qs_packed = get_int_b4(bq1->qs, iqs); + const uint8_t* qs = (const uint8_t*) &qs_packed; + int sumi[2] = {0, 0}; + float sumf[2] = {0.0f, 0.0f}; +#pragma unroll + for (int l0 = 0; l0 < 8; l0 += 2) { + const int qhl = bq1->qh[2 * iqs + l0 / 4] >> (4 * ((l0 / 2) % 2)); + const int grid = iq1s_grid_gpu[qs[l0 / 2] | ((qhl & 0x07) << 8)]; + const int grid0 = (grid >> 0) & 0x0F0F0F0F; + const int grid1 = (grid >> 4) & 0x0F0F0F0F; + const int u0 = get_int_b4(bq8_1[iqs].qs, l0 + 0); + const int u1 = get_int_b4(bq8_1[iqs].qs, l0 + 1); + sumi[l0 / 4] = ggml_cuda_dp4a(grid0, u0, sumi[l0 / 4]); + sumi[l0 / 4] = ggml_cuda_dp4a(grid1, u1, sumi[l0 / 4]); + const float delta = -1.0f + IQ1M_DELTA - (qhl & 0x08) * (2.0f * IQ1M_DELTA / 0x08); + int sumy = 0; + sumy = ggml_cuda_dp4a(u0, 0x01010101, sumy); + sumy = ggml_cuda_dp4a(u1, 0x01010101, sumy); + sumf[l0 / 4] += delta * sumy; + } + const uint16_t* sc = (const uint16_t*) bq1->scales; + iq1m_scale_t scale; + scale.u16 = (sc[0] >> 12) | ((sc[1] >> 8) & 0x00F0) | ((sc[2] >> 4) & 0x0F00) | (sc[3] & 0xF000); + const float d = __half2float(scale.f16) * __low2float(bq8_1[iqs].ds); + const int tmp = sc[iqs / 2] >> (6 * (iqs % 2)); + const int sc0 = 2 * ((tmp >> 0) & 0x07) + 1; + const int sc1 = 2 * ((tmp >> 3) & 0x07) + 1; + return d * ((sumi[0] + sumf[0]) * sc0 + (sumi[1] + sumf[1]) * sc1); +} + +__device__ __forceinline__ float vec_dot_iq4_nl_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1, + const int& kbx, const int& iqs) { + const block_iq4_nl* bq4 = (const block_iq4_nl*) vbq + kbx; + const int* q8 = (const int*) bq8_1->qs + iqs; + int sumi = 0; +#pragma unroll + for (int l = 0; l < 2; ++l) { + const int aux_q4 = get_int_b2(bq4->qs, iqs + l); + const int2 v = get_int_from_table_16(aux_q4, kvalues_iq4nl); + sumi = ggml_cuda_dp4a(v.x, q8[l + 0], sumi); + sumi = ggml_cuda_dp4a(v.y, q8[l + 4], sumi); + } + const float d = __half2float(bq4->d) * __low2float(bq8_1->ds); + return d * sumi; +} + +// ---------------------------------------------------------------- the formats +// qk = values per block, ipb = dot calls per block (qi / vdr), step = the iqs stride between calls. +template struct Fmt; +template<> struct Fmt<16> { static constexpr int qk = 256, ipb = 8, step = 2; + __device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq2_xxs_q8_1(v, y, kbx, iqs); } }; +template<> struct Fmt<17> { static constexpr int qk = 256, ipb = 8, step = 2; + __device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq2_xs_q8_1(v, y, kbx, iqs); } }; +template<> struct Fmt<18> { static constexpr int qk = 256, ipb = 8, step = 2; + __device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq3_xxs_q8_1(v, y, kbx, iqs); } }; +template<> struct Fmt<20> { static constexpr int qk = 32, ipb = 2, step = 2; + __device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq4_nl_q8_1(v, y, kbx, iqs); } }; +template<> struct Fmt<21> { static constexpr int qk = 256, ipb = 8, step = 2; + __device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq3_s_q8_1(v, y, kbx, iqs); } }; +template<> struct Fmt<22> { static constexpr int qk = 256, ipb = 8, step = 2; + __device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq2_s_q8_1(v, y, kbx, iqs); } }; +template<> struct Fmt<29> { static constexpr int qk = 256, ipb = 8, step = 1; + __device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq1_m_q8_1(v, y, kbx, iqs); } }; +template<> struct Fmt<42> { static constexpr int qk = 64, ipb = 2, step = 1; + __device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_q2_0_q8_1(v, y, kbx, iqs); } }; + +__device__ __forceinline__ float warp_sum(float v) { +#pragma unroll + for (int o = 16; o > 0; o >>= 1) v += __shfl_xor_sync(0xffffffffu, v, o); + return v; +} + +// One row against one q8_1 activation, the whole warp: call k = (block, part) is lane-strided. +template +__device__ __forceinline__ float row_dot(const uint8_t* row, const block_q8_1* x, int nb, int lane) { + using F = Fmt; + float s = 0.0f; + for (int k = lane; k < nb * F::ipb; k += 32) { + const int kbx = k / F::ipb, iqs = F::step * (k % F::ipb); + s += F::dot(row, x + kbx * (F::qk / 32), kbx, iqs); + } + return warp_sum(s); +} + +template +__global__ void __launch_bounds__(128) mmvq_kernel(const uint8_t* __restrict__ w, size_t row_bytes, + const block_q8_1* __restrict__ x, float* __restrict__ y, int n_in, + int n_out, int ncols) { + const int row = blockIdx.x * 4 + threadIdx.y; + if (row >= n_out) return; + const int lane = threadIdx.x; + const int nb = n_in / Fmt::qk; + const uint8_t* wr = w + (size_t) row * row_bytes; + for (int c = 0; c < ncols; ++c) { + const float s = row_dot(wr, x + (size_t) c * (n_in / 32), nb, lane); + if (lane == 0) y[(size_t) c * n_out + row] = s; + } +} + +// ---------------------------------------------------------------- grouped native experts +constexpr int GU_ROWS = 8; // rows per block (one warp each) + +template +__global__ void __launch_bounds__(256) native_gu_kernel(const unsigned long long* __restrict__ grp_ptr, + const int32_t* __restrict__ grp_start, + const int32_t* __restrict__ n_groups, + const int32_t* __restrict__ ent_tok, + const block_q8_1* __restrict__ xq, NativeExpertLayout L, + float* __restrict__ gate, float* __restrict__ up) { + const int g = blockIdx.y; + if (g >= *n_groups) return; + const int warp = threadIdx.x >> 5, lane = threadIdx.x & 31; + const int row = blockIdx.x * GU_ROWS + warp; // 0 .. 2*n_ff + if (row >= 2 * L.n_ff) return; + const bool is_up = row >= L.n_ff; + const int r = is_up ? row - (int) L.n_ff : row; + const uint8_t* blob = (const uint8_t*) grp_ptr[g]; + const uint8_t* wr = blob + (is_up ? L.up_off : 0) + (size_t) r * L.gu_row; + const int nb = (int) (L.n_embd / Fmt::qk), xb = (int) (L.n_embd / 32); + const int e0 = grp_start[g], e1 = grp_start[g + 1]; + for (int e = e0; e < e1; ++e) { + const float s = row_dot(wr, xq + (size_t) ent_tok[e] * xb, nb, lane); + if (lane == 0) (is_up ? up : gate)[(size_t) e * L.n_ff + r] = s; + } +} + +__global__ void swiglu_entries_kernel(const float* __restrict__ gate, const float* __restrict__ up, float* __restrict__ h, + long long n) { + const long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n) return; + const float g = gate[i]; + h[i] = (g / (1.0f + __expf(-g))) * up[i]; +} + +template +__global__ void __launch_bounds__(256) native_down_kernel(const unsigned long long* __restrict__ grp_ptr, + const int32_t* __restrict__ grp_start, + const int32_t* __restrict__ n_groups, + const int32_t* __restrict__ ent_dst, + const block_q8_1* __restrict__ hq, NativeExpertLayout L, + float* __restrict__ out) { + const int g = blockIdx.y; + if (g >= *n_groups) return; + const int warp = threadIdx.x >> 5, lane = threadIdx.x & 31; + const int r = blockIdx.x * 8 + warp; + if (r >= L.n_embd) return; + const uint8_t* blob = (const uint8_t*) grp_ptr[g]; + const uint8_t* wr = blob + L.down_off + (size_t) r * L.d_row; + const int nb = (int) (L.n_ff / Fmt::qk), hb = (int) (L.n_ff / 32); + const int e0 = grp_start[g], e1 = grp_start[g + 1]; + for (int e = e0; e < e1; ++e) { + const float s = row_dot(wr, hq + (size_t) e * hb, nb, lane); + if (lane == 0) out[(size_t) ent_dst[e] * L.n_embd + r] = s; + } +} + +// ---------------------------------------------------------------- q8_1 (quantize.cu) +__global__ void quantize_q8_1_kernel(const float* __restrict__ x, block_q8_1* __restrict__ y, long long n) { + const long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n) return; + const float xi = x[i]; + float amax = fabsf(xi), sum = xi; +#pragma unroll + for (int o = 16; o > 0; o >>= 1) { + amax = fmaxf(amax, __shfl_xor_sync(0xffffffffu, amax, o)); + sum += __shfl_xor_sync(0xffffffffu, sum, o); + } + const float d = amax / 127.0f; + const int8_t q = amax == 0.0f ? 0 : roundf(xi / d); + const long long ib = i / 32, iqs = i % 32; + y[ib].qs[iqs] = q; + if (iqs == 0) y[ib].ds = make_half2(d, sum); +} + +// ---------------------------------------------------------------- dequant (dequantize.cuh) +template __device__ __forceinline__ dst_t cvt(float v); +template<> __device__ __forceinline__ float cvt(float v) { return v; } +template<> __device__ __forceinline__ __half cvt<__half>(float v) { return __float2half(v); } + +template +__device__ void dq_iq2_xxs(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_iq2_xxs* x = (const block_iq2_xxs*) vx; + const int64_t il = tid / 8, ib = tid % 8; + dst_t* y = yy + 32 * ib + 8 * il; + const uint16_t* q2 = x[ibs].qs + 4 * ib; + const uint8_t* aux8 = (const uint8_t*) q2; + const uint8_t* grid = (const uint8_t*) (iq2xxs_grid + aux8[il]); + const uint32_t aux32 = q2[2] | (q2[3] << 16); + const float d = (float) x[ibs].d * (0.5f + (aux32 >> 28)) * 0.25f; + const uint8_t signs = ksigns_iq2xs[(aux32 >> 7 * il) & 127]; + for (int j = 0; j < 8; ++j) y[j] = cvt(d * grid[j] * (signs & kmask_iq2xs[j] ? -1.f : 1.f)); +} +template +__device__ void dq_iq2_xs(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_iq2_xs* x = (const block_iq2_xs*) vx; + const int64_t il = tid / 8, ib = tid % 8; + dst_t* y = yy + 32 * ib + 8 * il; + const uint16_t* q2 = x[ibs].qs + 4 * ib; + const uint8_t* grid = (const uint8_t*) (iq2xs_grid + (q2[il] & 511)); + const float d = (float) x[ibs].d * (0.5f + ((x[ibs].scales[ib] >> 4 * (il / 2)) & 0xf)) * 0.25f; + const uint8_t signs = ksigns_iq2xs[q2[il] >> 9]; + for (int j = 0; j < 8; ++j) y[j] = cvt(d * grid[j] * (signs & kmask_iq2xs[j] ? -1.f : 1.f)); +} +template +__device__ void dq_iq2_s(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_iq2_s* x = (const block_iq2_s*) vx; + const int64_t il = tid / 8, ib = tid % 8; + dst_t* y = yy + 32 * ib + 8 * il; + const uint8_t* grid = (const uint8_t*) (iq2s_grid + (x[ibs].qs[4 * ib + il] | ((x[ibs].qh[ib] << (8 - 2 * il)) & 0x300))); + const float d = (float) x[ibs].d * (0.5f + ((x[ibs].scales[ib] >> 4 * (il / 2)) & 0xf)) * 0.25f; + const uint8_t signs = x[ibs].qs[QK_K / 8 + 4 * ib + il]; + for (int j = 0; j < 8; ++j) y[j] = cvt(d * grid[j] * (signs & kmask_iq2xs[j] ? -1.f : 1.f)); +} +template +__device__ void dq_iq3_xxs(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_iq3_xxs* x = (const block_iq3_xxs*) vx; + const int64_t il = tid / 8, ib = tid % 8; + dst_t* y = yy + 32 * ib + 8 * il; + const uint8_t* q3 = x[ibs].qs + 8 * ib; + const uint16_t* gas = (const uint16_t*) (x[ibs].qs + QK_K / 4) + 2 * ib; + const uint8_t* grid1 = (const uint8_t*) (iq3xxs_grid + q3[2 * il + 0]); + const uint8_t* grid2 = (const uint8_t*) (iq3xxs_grid + q3[2 * il + 1]); + const uint32_t aux32 = gas[0] | (gas[1] << 16); + const float d = (float) x[ibs].d * (0.5f + (aux32 >> 28)) * 0.5f; + const uint8_t signs = ksigns_iq2xs[(aux32 >> 7 * il) & 127]; + for (int j = 0; j < 4; ++j) { + y[j + 0] = cvt(d * grid1[j] * (signs & kmask_iq2xs[j + 0] ? -1.f : 1.f)); + y[j + 4] = cvt(d * grid2[j] * (signs & kmask_iq2xs[j + 4] ? -1.f : 1.f)); + } +} +template +__device__ void dq_iq3_s(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_iq3_s* x = (const block_iq3_s*) vx; + const int64_t il = tid / 8, ib = tid % 8; + dst_t* y = yy + 32 * ib + 8 * il; + const uint8_t* qs = x[ibs].qs + 8 * ib; + const uint8_t* grid1 = (const uint8_t*) (iq3s_grid + (qs[2 * il + 0] | ((x[ibs].qh[ib] << (8 - 2 * il)) & 256))); + const uint8_t* grid2 = (const uint8_t*) (iq3s_grid + (qs[2 * il + 1] | ((x[ibs].qh[ib] << (7 - 2 * il)) & 256))); + const float d = (float) x[ibs].d * (1 + 2 * ((x[ibs].scales[ib / 2] >> 4 * (ib % 2)) & 0xf)); + const uint8_t signs = x[ibs].signs[4 * ib + il]; + for (int j = 0; j < 4; ++j) { + y[j + 0] = cvt(d * grid1[j] * (signs & kmask_iq2xs[j + 0] ? -1.f : 1.f)); + y[j + 4] = cvt(d * grid2[j] * (signs & kmask_iq2xs[j + 4] ? -1.f : 1.f)); + } +} +template +__device__ void dq_iq1_m(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_iq1_m* x = (const block_iq1_m*) vx; + const int64_t il = tid / 8, ib = tid % 8; + dst_t* y = yy + 32 * ib + 8 * il; + const uint16_t* sc = (const uint16_t*) x[ibs].scales; + iq1m_scale_t scale; + scale.u16 = (sc[0] >> 12) | ((sc[1] >> 8) & 0x00f0) | ((sc[2] >> 4) & 0x0f00) | (sc[3] & 0xf000); + const int64_t ib16 = 2 * ib + il / 2; + const float d = (float) scale.f16 * (2 * ((sc[ib16 / 4] >> 3 * (ib16 % 4)) & 0x7) + 1); + const float delta = x[ibs].qh[2 * ib + il / 2] & (0x08 << 4 * (il % 2)) ? -1 - IQ1M_DELTA : -1 + IQ1M_DELTA; + uint32_t grid32[2]; + const int8_t* q = (const int8_t*) grid32; + grid32[0] = iq1s_grid_gpu[x[ibs].qs[4 * ib + il] | (((x[ibs].qh[2 * ib + il / 2] >> 4 * (il % 2)) & 7) << 8)]; + grid32[1] = (grid32[0] >> 4) & 0x0f0f0f0f; + grid32[0] &= 0x0f0f0f0f; + for (int j = 0; j < 8; ++j) y[j] = cvt(d * (q[j] + delta)); +} +template +__device__ void dq_iq4_nl(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_iq4_nl* x = (const block_iq4_nl*) vx + ibs * (QK_K / QK4_NL); + const int64_t il = tid / 8, ib = tid % 8; + dst_t* y = yy + 32 * ib + 4 * il; + const uint8_t* q4 = x[ib].qs + 4 * il; + const float d = (float) x[ib].d; + for (int j = 0; j < 4; ++j) { + y[j + 0] = cvt(d * kvalues_iq4nl[q4[j] & 0xf]); + y[j + 16] = cvt(d * kvalues_iq4nl[q4[j] >> 4]); + } +} +// Q3_K (the Q2_0 file's token_embd): llama.cpp's dequantize_block_q3_K, its 64 threads folded onto 32 +template +__device__ void dq_q3_k(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_q3_K* x = (const block_q3_K*) vx + ibs; + for (int tt = tid; tt < 64; tt += 32) { + const int r = tt / 4, t2 = r / 2, is0 = r % 2; + const int l0 = 16 * is0 + 4 * (tt % 4); + const int n = t2 / 4, j = t2 - 4 * n; + const uint8_t m = (uint8_t) (1 << (4 * n + j)); + const int is = 8 * n + 2 * j + is0; + const int shift = 2 * j; + const int8_t us = is < 4 ? (int8_t) ((x->scales[is - 0] & 0xF) | (((x->scales[is + 8] >> 0) & 3) << 4)) : + is < 8 ? (int8_t) ((x->scales[is - 0] & 0xF) | (((x->scales[is + 4] >> 2) & 3) << 4)) : + is < 12 ? (int8_t) ((x->scales[is - 8] >> 4) | (((x->scales[is + 0] >> 4) & 3) << 4)) : + (int8_t) ((x->scales[is - 8] >> 4) | (((x->scales[is - 4] >> 6) & 3) << 4)); + const float dl = (float) x->d * (us - 32); + dst_t* y = yy + 128 * n + 32 * j; + const uint8_t* q = x->qs + 32 * n; + const uint8_t* hm = x->hmask; + for (int l = l0; l < l0 + 4; ++l) y[l] = cvt(dl * ((int8_t) ((q[l] >> shift) & 3) - ((hm[l] & m) ? 0 : 4))); + } +} +template +__device__ void dq_iq4_xs(const void* vx, int64_t ibs, dst_t* yy, int tid) { + const block_iq4_xs* x = (const block_iq4_xs*) vx + ibs; + const int il = tid / 8, ib = tid % 8; + dst_t* y = yy + 32 * ib + 4 * il; + const uint8_t* q4 = x->qs + 16 * ib + 4 * il; + const float d = (float) x->d * ((((x->scales_l[ib / 2] >> 4 * (ib % 2)) & 0xf) | (((x->scales_h >> 2 * ib) & 3) << 4)) - 32); + for (int j = 0; j < 4; ++j) { + y[j + 0] = cvt(d * kvalues_iq4nl[q4[j] & 0xf]); + y[j + 16] = cvt(d * kvalues_iq4nl[q4[j] >> 4]); + } +} +template +__device__ void dq_q2_0(const void* vx, int64_t ibs, dst_t* yy, int tid) { + // one "superblock" = 256 values = 4 blocks of 64; thread tid writes 8 values + const block_q2_0* x = (const block_q2_0*) vx + ibs * 4; + const int b = tid / 8, part = tid % 8; // block 0..3, 8 values each + const float d = (float) x[b].d; + for (int j = 0; j < 8; ++j) { + const int i = part * 8 + j; + const int code = (x[b].qs[i / 4] >> ((i % 4) * 2)) & 3; + yy[b * 64 + i] = cvt(d * (float) (code - 1)); + } +} + +template +__device__ __forceinline__ void dq_dispatch(int ty, const void* vx, int64_t ibs, dst_t* y, int tid) { + switch (ty) { + case 16: dq_iq2_xxs(vx, ibs, y, tid); break; + case 17: dq_iq2_xs(vx, ibs, y, tid); break; + case 18: dq_iq3_xxs(vx, ibs, y, tid); break; + case 20: dq_iq4_nl(vx, ibs, y, tid); break; + case 21: dq_iq3_s(vx, ibs, y, tid); break; + case 22: dq_iq2_s(vx, ibs, y, tid); break; + case 29: dq_iq1_m(vx, ibs, y, tid); break; + case 23: dq_iq4_xs(vx, ibs, y, tid); break; + case 11: dq_q3_k(vx, ibs, y, tid); break; + case 42: dq_q2_0(vx, ibs, y, tid); break; + default: break; + } +} + +// flat: superblock i -> y + 256 i +template +__global__ void dequant_flat_kernel(int ty, const void* __restrict__ vx, dst_t* __restrict__ y) { + const int64_t i = blockIdx.x; + dq_dispatch(ty, vx, i, y + i * QK_K, threadIdx.x); +} +// gate/up: superblock i of a role matrix (n_embd/256 per row) -> interleaved row 2r + parity +__global__ void dequant_gu_kernel(int ty, const void* __restrict__ gate, const void* __restrict__ up, int64_t per_row, + __half* __restrict__ y) { + const int64_t i = blockIdx.x; + const int parity = blockIdx.y; + const int64_t r = i / per_row, c = i % per_row; + dq_dispatch<__half>(ty, parity ? up : gate, i, y + ((2 * r + parity) * per_row + c) * QK_K, threadIdx.x); +} + +bool is_iq(int t) { return t == 16 || t == 17 || t == 18 || t == 20 || t == 21 || t == 22 || t == 23 || t == 29 || t == 42 || t == 11; } + +} // namespace + +bool iq_supported(int t) noexcept { return is_iq(t); } + +size_t iq_row_bytes(int t, int64_t n) noexcept { + switch (t) { + case 16: return (size_t) (n / 256) * sizeof(block_iq2_xxs); + case 17: return (size_t) (n / 256) * sizeof(block_iq2_xs); + case 18: return (size_t) (n / 256) * sizeof(block_iq3_xxs); + case 20: return (size_t) (n / 32) * sizeof(block_iq4_nl); + case 21: return (size_t) (n / 256) * sizeof(block_iq3_s); + case 22: return (size_t) (n / 256) * sizeof(block_iq2_s); + case 29: return (size_t) (n / 256) * sizeof(block_iq1_m); + case 23: return (size_t) (n / 256) * sizeof(block_iq4_xs); + case 11: return (size_t) (n / 256) * sizeof(block_q3_K); + case 42: return (size_t) (n / 64) * sizeof(block_q2_0); + default: return 0; + } +} + +void quantize_q8_1_rows(const float* x, int64_t n_rows, int64_t n_cols, void* y, void* stream) { + const long long n = (long long) n_rows * n_cols; + if (n <= 0) return; + quantize_q8_1_kernel<<<(unsigned) ((n + 255) / 256), 256, 0, (cudaStream_t) stream>>>(x, (block_q8_1*) y, n); + check("quantize_q8_1_rows"); +} + +void iq_mmvq(int t, const void* w, const void* x_q8_1, float* y, int n_in, int n_out, int ncols, void* stream) { + const dim3 grid((unsigned) ((n_out + 3) / 4)), block(32, 4); + const size_t rb = iq_row_bytes(t, n_in); + cudaStream_t s = (cudaStream_t) stream; + const auto* W = (const uint8_t*) w; + const auto* X = (const block_q8_1*) x_q8_1; + switch (t) { + case 16: mmvq_kernel<16><<>>(W, rb, X, y, n_in, n_out, ncols); break; + case 17: mmvq_kernel<17><<>>(W, rb, X, y, n_in, n_out, ncols); break; + case 18: mmvq_kernel<18><<>>(W, rb, X, y, n_in, n_out, ncols); break; + case 20: mmvq_kernel<20><<>>(W, rb, X, y, n_in, n_out, ncols); break; + case 21: mmvq_kernel<21><<>>(W, rb, X, y, n_in, n_out, ncols); break; + case 22: mmvq_kernel<22><<>>(W, rb, X, y, n_in, n_out, ncols); break; + case 29: mmvq_kernel<29><<>>(W, rb, X, y, n_in, n_out, ncols); break; + case 42: mmvq_kernel<42><<>>(W, rb, X, y, n_in, n_out, ncols); break; + default: std::fprintf(stderr, "iq_mmvq: type %d is not supported\n", t); std::exit(1); + } + check("iq_mmvq"); +} + +void iq_dequant_f16(int t, const void* src, int64_t n, uint16_t* dst, void* stream) { + if (n % 256 != 0 || !is_iq(t)) { std::fprintf(stderr, "iq_dequant_f16: bad arguments\n"); std::exit(1); } + dequant_flat_kernel<__half><<<(unsigned) (n / 256), 32, 0, (cudaStream_t) stream>>>(t, src, (__half*) dst); + check("iq_dequant_f16"); +} + +namespace { +__global__ void embed_rows_kernel(int ty, const uint8_t* __restrict__ table, size_t row_bytes, + const int32_t* __restrict__ tokens, int64_t n_embd, float* __restrict__ y) { + const int t = blockIdx.y; + const int64_t b = blockIdx.x; + const uint8_t* row = table + (size_t) tokens[t] * row_bytes; + dq_dispatch(ty, row, b, y + (size_t) t * n_embd + b * QK_K, threadIdx.x); +} +} // namespace + +void iq_embed_rows(int t, const void* table, size_t row_bytes, const int32_t* tokens, int64_t n_tok, int64_t n_embd, + float* out, void* stream) { + if (n_tok <= 0) return; + if (n_embd % 256 != 0 || !is_iq(t)) { std::fprintf(stderr, "iq_embed_rows: bad arguments\n"); std::exit(1); } + embed_rows_kernel<<>>( + t, (const uint8_t*) table, row_bytes, tokens, n_embd, out); + check("iq_embed_rows"); +} + +void iq_dequant_f32(int t, const void* src, int64_t n, float* dst, void* stream) { + if (n % 256 != 0 || !is_iq(t)) { std::fprintf(stderr, "iq_dequant_f32: bad arguments\n"); std::exit(1); } + dequant_flat_kernel<<<(unsigned) (n / 256), 32, 0, (cudaStream_t) stream>>>(t, src, dst); + check("iq_dequant_f32"); +} + +void iq_dequant_gu_f16(int t, const void* gate, const void* up, int64_t n_ff, int64_t n_embd, uint16_t* dst, void* stream) { + const int64_t per_row = n_embd / 256; + dequant_gu_kernel<<>>(t, gate, up, per_row, + (__half*) dst); + check("iq_dequant_gu_f16"); +} + +NativeExpertLayout native_expert_layout(int gu_type, int d_type, int64_t n_embd, int64_t n_ff) { + NativeExpertLayout L; + L.gu_type = gu_type; + L.d_type = d_type; + L.n_embd = n_embd; + L.n_ff = n_ff; + L.gu_row = iq_row_bytes(gu_type, n_embd); + L.d_row = iq_row_bytes(d_type, n_ff); + L.up_off = (size_t) n_ff * L.gu_row; + L.down_off = 2 * L.up_off; + L.bytes = L.down_off + (size_t) n_embd * L.d_row; + return L; +} + +size_t native_expert_scratch_bytes(int64_t cap, int64_t n_ff) { + const size_t f = (size_t) cap * (size_t) n_ff * sizeof(float); + return 3 * ((f + 255) & ~(size_t) 255) + (((size_t) cap * (size_t) (n_ff / 32) * sizeof(block_q8_1) + 255) & ~(size_t) 255); +} + +void native_expert_grouped(const NativeExpertLayout& L, const unsigned long long* grp_ptr, const int32_t* grp_start, + const int32_t* n_groups, const int32_t* ent_dst, const int32_t* ent_tok, int64_t cap_groups, + int64_t cap_entries, const void* x_q8_1, void* scratch, float* out, void* stream) { + if (cap_groups <= 0 || cap_entries <= 0) return; + cudaStream_t s = (cudaStream_t) stream; + const size_t f = (size_t) cap_entries * (size_t) L.n_ff * sizeof(float), fa = (f + 255) & ~(size_t) 255; + float* gate = (float*) scratch; + float* up = (float*) ((uint8_t*) scratch + fa); + float* h = (float*) ((uint8_t*) scratch + 2 * fa); + block_q8_1* hq = (block_q8_1*) ((uint8_t*) scratch + 3 * fa); + const auto* X = (const block_q8_1*) x_q8_1; + const dim3 ggu((unsigned) ((2 * L.n_ff + GU_ROWS - 1) / GU_ROWS), (unsigned) cap_groups); + switch (L.gu_type) { + case 16: native_gu_kernel<16><<>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break; + case 17: native_gu_kernel<17><<>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break; + case 18: native_gu_kernel<18><<>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break; + case 21: native_gu_kernel<21><<>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break; + case 22: native_gu_kernel<22><<>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break; + case 29: native_gu_kernel<29><<>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break; + case 42: native_gu_kernel<42><<>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break; + default: std::fprintf(stderr, "native_expert_grouped: gate/up type %d\n", L.gu_type); std::exit(1); + } + check("native_expert_grouped/gu"); + const long long nh = (long long) cap_entries * L.n_ff; + swiglu_entries_kernel<<<(unsigned) ((nh + 255) / 256), 256, 0, s>>>(gate, up, h, nh); + quantize_q8_1_kernel<<<(unsigned) ((nh + 255) / 256), 256, 0, s>>>(h, hq, nh); + const dim3 gd((unsigned) ((L.n_embd + 7) / 8), (unsigned) cap_groups); + switch (L.d_type) { + case 20: native_down_kernel<20><<>>(grp_ptr, grp_start, n_groups, ent_dst, hq, L, out); break; + case 42: native_down_kernel<42><<>>(grp_ptr, grp_start, n_groups, ent_dst, hq, L, out); break; + default: std::fprintf(stderr, "native_expert_grouped: down type %d\n", L.d_type); std::exit(1); + } + check("native_expert_grouped/down"); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/kv_q8.cu b/src/kernels/cuda/kv_q8.cu new file mode 100644 index 00000000..09f65ecf --- /dev/null +++ b/src/kernels/cuda/kv_q8.cu @@ -0,0 +1,118 @@ +// src/kernels/cuda/kv_q8.cu - see include/strata/kernels/kv_q8.hpp. +#include "strata/kernels/kv_q8.hpp" +#include "strata/kernels/f16_bits.hpp" + +#include + +#include +#include + +namespace strata::kernels { +namespace { + +void check(const char* what) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "kv_q8: %s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +void validate(const QsaShapes& s, const char* what) { + if (s.head_dim % KV_Q8_GROUP != 0 || s.n_head_kv <= 0 || s.page_size <= 0) { + std::fprintf(stderr, "kv_q8: %s: head_dim %lld must be a multiple of %d\n", what, (long long) s.head_dim, + KV_Q8_GROUP); + std::exit(1); + } +} + +// One block = one 64-value group of one KV head of K (blockIdx.z = 0) or V (1); 64 threads, one value each. +__global__ void kv_append_q8_kernel(int8_t* __restrict__ k_q, int8_t* __restrict__ v_q, + uint16_t* __restrict__ k_scale, uint16_t* __restrict__ v_scale, + const int32_t* __restrict__ table, const int32_t* __restrict__ step, + const float* __restrict__ kcur, const float* __restrict__ vcur, int kv_heads, + int head_dim, int page_size) { + const long long pos = (long long) __ldg(step + kStepPos); + const int h = blockIdx.x, g = blockIdx.y, t = threadIdx.x; + const bool is_v = blockIdx.z == 1; + const int groups = head_dim / KV_Q8_GROUP; + const float x = (is_v ? vcur : kcur)[h * head_dim + g * KV_Q8_GROUP + t]; + // max |x| over the 64 values: two warps, then combine through shared memory in a fixed order + float a = fabsf(x); + for (int o = 16; o > 0; o >>= 1) a = fmaxf(a, __shfl_xor_sync(0xffffffffu, a, o)); + __shared__ float warp_max[2]; + if ((t & 31) == 0) warp_max[t >> 5] = a; + __syncthreads(); + const float amax = fmaxf(warp_max[0], warp_max[1]); + const uint16_t sbits = f16_from_f32(amax / 127.0f); + const float sf = f32_from_f16(sbits); // quantize against the STORED scale + int q = 0; + if (sf > 0.0f) { + q = __float2int_rn(x / sf); + q = q < -127 ? -127 : (q > 127 ? 127 : q); + } + const long long page = (long long) table[pos / page_size]; + const long long row = (page * kv_heads + h) * page_size + (pos % page_size); + (is_v ? v_q : k_q)[row * head_dim + g * KV_Q8_GROUP + t] = (int8_t) q; + if (t == 0) (is_v ? v_scale : k_scale)[row * groups + g] = sbits; +} + +// One thread = 4 consecutive values of one cell and head (as the FP16 gather does with uint2). +__global__ void kv_gather_q8_kernel(const int8_t* __restrict__ k_q, const int8_t* __restrict__ v_q, + const uint16_t* __restrict__ k_scale, const uint16_t* __restrict__ v_scale, + const int32_t* __restrict__ table, const int32_t* __restrict__ ids, + const int32_t* __restrict__ step, int kv_heads, int head_dim, int page_size, + uint16_t* __restrict__ k_scratch, uint16_t* __restrict__ v_scratch) { + const long long n_ids = (long long) __ldg(step + kStepWidth); + const int per = head_dim / 4; + const long long total = n_ids * kv_heads * per; + const long long i = blockIdx.x * (long long) blockDim.x + threadIdx.x; + if (i >= total) return; + const long long id = i / (kv_heads * (long long) per); + const int rem = (int) (i % (kv_heads * (long long) per)); + const int h = rem / per, q4 = rem - h * per; + const int cell = ids[id]; + const long long page = (long long) table[cell / page_size]; + const long long row = (page * kv_heads + h) * page_size + (cell % page_size); + const int d = q4 * 4; + const int groups = head_dim / KV_Q8_GROUP; + const float ks = f32_from_f16(k_scale[row * groups + d / KV_Q8_GROUP]); + const float vs = f32_from_f16(v_scale[row * groups + d / KV_Q8_GROUP]); + const char4 kc = reinterpret_cast(k_q + row * head_dim)[q4]; + const char4 vc = reinterpret_cast(v_q + row * head_dim)[q4]; + ushort4 ko, vo; + ko.x = f16_from_f32((float) kc.x * ks); ko.y = f16_from_f32((float) kc.y * ks); + ko.z = f16_from_f32((float) kc.z * ks); ko.w = f16_from_f32((float) kc.w * ks); + vo.x = f16_from_f32((float) vc.x * vs); vo.y = f16_from_f32((float) vc.y * vs); + vo.z = f16_from_f32((float) vc.z * vs); vo.w = f16_from_f32((float) vc.w * vs); + const long long dst = (id * kv_heads + h) * (long long) per + q4; + reinterpret_cast(k_scratch)[dst] = ko; + reinterpret_cast(v_scratch)[dst] = vo; +} + +} // namespace + +void kv_append_q8_step(int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale, const int32_t* page_table, + const int32_t* step, const float* kcur, const float* vcur, const QsaShapes& s, void* stream) { + validate(s, "kv_append_q8"); + const dim3 grid((unsigned) s.n_head_kv, (unsigned) (s.head_dim / KV_Q8_GROUP), 2); + kv_append_q8_kernel<<>>( + k_q, v_q, k_scale, v_scale, page_table, step, kcur, vcur, (int) s.n_head_kv, (int) s.head_dim, + (int) s.page_size); + check("kv_append_q8 launch"); +} + +void kv_gather_q8_step(const int8_t* k_q, const int8_t* v_q, const uint16_t* k_scale, const uint16_t* v_scale, + const int32_t* page_table, const int32_t* ids, const int32_t* step, int64_t max_ids, + const QsaShapes& s, uint16_t* k_scratch, uint16_t* v_scratch, void* stream) { + validate(s, "kv_gather_q8"); + if (max_ids <= 0) return; + const long long total = max_ids * s.n_head_kv * (s.head_dim / 4); + const unsigned blocks = (unsigned) ((total + 255) / 256); + kv_gather_q8_kernel<<>>( + k_q, v_q, k_scale, v_scale, page_table, ids, step, (int) s.n_head_kv, (int) s.head_dim, (int) s.page_size, + k_scratch, v_scratch); + check("kv_gather_q8 launch"); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_bf16.cu b/src/kernels/cuda/native_bf16.cu new file mode 100644 index 00000000..5131b20b --- /dev/null +++ b/src/kernels/cuda/native_bf16.cu @@ -0,0 +1,118 @@ +#include "strata/kernels/bf16_gemv.hpp" +#include "strata/kernels/bf16_bits.hpp" + +#include +#include +#include +#include + +namespace strata::kernels { +namespace { + +// The FP32-activation MMVF implementation below is adapted from llama.cpp +// 3cf03257f219afbe7334045ff7c6a06ac68c627d, ggml/src/ggml-cuda/{mmvf.cu,common.cuh}. +// Scope: ordinary contiguous BF16 matrix, one FP32 activation vector, no fusion/ids/channels. +// +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +__device__ __forceinline__ float mmvf_warp_sum(float value) { +#pragma unroll + for (int offset = 16; offset > 0; offset >>= 1) + value += __shfl_xor_sync(0xffffffffu, value, offset, 32); + return value; +} + +template +__global__ void bf16_f32_mmvf_kernel(const float* __restrict__ x, const uint16_t* __restrict__ w, + float* __restrict__ y, int n_in) { + const int t = threadIdx.x; + const uint16_t* row = w + (size_t) blockIdx.x * n_in; + const uint32_t* weights2 = reinterpret_cast(row); + const float2* inputs2 = reinterpret_cast(x); + __shared__ float partials[32]; + if constexpr (BLOCK_SIZE > 32) { + if (t < 32) partials[t] = 0.0f; + __syncthreads(); + } + float acc = 0.0f; + for (int pair = t; pair < n_in / 2; pair += BLOCK_SIZE) { + const uint32_t weight = weights2[pair]; + const float2 input = inputs2[pair]; + // Match the two ordered multiply-adds in ggml_cuda_mad, not a pair sum followed by one add. + acc = __fmaf_rn(f32_from_bf16((uint16_t) weight), input.x, acc); + acc = __fmaf_rn(f32_from_bf16((uint16_t) (weight >> 16)), input.y, acc); + } + acc = mmvf_warp_sum(acc); + if constexpr (BLOCK_SIZE > 32) { + // All lanes have the same reduced value; one store avoids a same-value shared-memory race. + if ((t & 31) == 0) partials[t / 32] = acc; + __syncthreads(); + if (t < 32) acc = mmvf_warp_sum(partials[t]); + } + if (t == 0) y[blockIdx.x] = acc; +} + +int mmvf_block_size(int64_t n_in) { + int best = 32; + int64_t best_iterations = (n_in + 63) / 64; + for (int candidate = 64; candidate <= 256; candidate += 32) { + const int64_t iterations = (n_in + 2 * candidate - 1) / (2 * candidate); + if (iterations < best_iterations) { + best_iterations = iterations; + best = candidate; + } + } + return best; +} + +} // namespace + +void bf16_gemv_fp32_mmvf(const float* x, const uint16_t* w, float* y, + int64_t n_in, int64_t n_out, void* stream) { + if (n_in <= 0 || (n_in & 1) != 0 || n_in > std::numeric_limits::max() || + n_out <= 0 || n_out > std::numeric_limits::max()) + throw std::invalid_argument("bf16_gemv_fp32_mmvf: require positive even n_in and positive n_out <= INT_MAX"); + if (x == nullptr || w == nullptr || y == nullptr || + (reinterpret_cast(x) & 7u) != 0 || + (reinterpret_cast(w) & 3u) != 0 || + (reinterpret_cast(y) & 3u) != 0) + throw std::invalid_argument("bf16_gemv_fp32_mmvf: null or misaligned pointer"); + const cudaStream_t st = (cudaStream_t) stream; +#define STRATA_MMVF_CASE(N) case N: \ + bf16_f32_mmvf_kernel<<<(unsigned) n_out, N, 0, st>>>(x, w, y, (int) n_in); break + switch (mmvf_block_size(n_in)) { + STRATA_MMVF_CASE(32); + STRATA_MMVF_CASE(64); + STRATA_MMVF_CASE(96); + STRATA_MMVF_CASE(128); + STRATA_MMVF_CASE(160); + STRATA_MMVF_CASE(192); + STRATA_MMVF_CASE(224); + STRATA_MMVF_CASE(256); + } +#undef STRATA_MMVF_CASE + const cudaError_t result = cudaGetLastError(); + if (result != cudaSuccess) + throw std::runtime_error(std::string("bf16_gemv_fp32_mmvf launch: ") + cudaGetErrorString(result)); +} + + +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_flash_attn.cu b/src/kernels/cuda/native_flash_attn.cu new file mode 100644 index 00000000..5a4f36d5 --- /dev/null +++ b/src/kernels/cuda/native_flash_attn.cu @@ -0,0 +1,212 @@ +// Specialized from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d, +// ggml/src/ggml-cuda/{fattn-vec.cuh,fattn-common.cuh,common.cuh}. +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "strata/kernels/native_flash_attn.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +template __device__ __forceinline__ float warp_sum(float x) { +#pragma unroll + for (int offset = Width / 2; offset; offset >>= 1) + x += __shfl_xor_sync(0xffffffffu, x, offset, Width); + return x; +} +__device__ __forceinline__ float warp_max(float x) { +#pragma unroll + for (int offset = 16; offset; offset >>= 1) + x = fmaxf(x, __shfl_xor_sync(0xffffffffu, x, offset, 32)); + return x; +} + +// D=256,ncols=1,F16/F16; 128 threads, nthreads_KQ=nthreads_V=8, +// four values (float2) per load, four V columns per iteration. Padded length256 +// gives ntiles_KV=ceil(256/D)=1, so the pinned launcher selects grid.y=1. +__launch_bounds__(128, 1) +__global__ void attend(const float* __restrict__ q, const half* __restrict__ k, + const half* __restrict__ v, const int32_t* __restrict__ step, + int max_context, int padded_length, float scale, float* __restrict__ out, int32_t* __restrict__ status, + const half* __restrict__ mask) { + const int lane = threadIdx.x, warp = threadIdx.y, tid = warp * 32 + lane; + const int head = blockIdx.x, kv = head / 12; + const int width = step[kStepWidth], nkv = step[kStepNKv]; + const bool valid = width >= 1 && width <= max_context && nkv == width && + step[kStepPos] == width - 1 && step[kStepNBid] == width / 4; + if (head == 0 && tid == 0) + *status = valid ? kNativeFlashAttnSuccess : kNativeFlashAttnUnsupportedStep; + if (!valid) { + out[head * 256 + tid] = __int_as_float(0x7fc00000); + out[head * 256 + tid + 128] = __int_as_float(0x7fc00000); + return; + } + float2 qreg[16]; + float2 vkq[16] = {}; + __shared__ float tile[4 * 4 * 256]; + __shared__ float max_shared[32], sum_shared[32]; + float maximum = -FLT_MAX / 2.0f, sum = 0.0f; +#pragma unroll + for (int i0 = 0; i0 < 128; i0 += 32) { + const int i = i0 + (lane % 8) * 4; +#pragma unroll + for (int j = 0; j < 4; ++j) { + const int d = 2 * (i + j); + qreg[i0 / 8 + j] = make_float2(q[head * 256 + d] * scale, + q[head * 256 + d + 1] * scale); + } + } + for (int base = 0; base < padded_length; base += 128) { + float score = 0.0f, next_max = maximum; +#pragma unroll + for (int row = 0; row < 8; ++row) { + const int cell = base + warp * 32 + (lane & ~7) + row; + float dot = 0.0f; +#pragma unroll + for (int i0 = 0; i0 < 128; i0 += 32) { + const int i = i0 + (lane % 8) * 4; +#pragma unroll + for (int j = 0; j < 4; ++j) { + const int d = 2 * (i + j); + const float a = cell < width ? __half2float(k[(cell * 2 + kv) * 256 + d]) : 0.0f; + const float b = cell < width ? __half2float(k[(cell * 2 + kv) * 256 + d + 1]) : 0.0f; + dot += a * qreg[i0 / 8 + j].x; + dot += b * qreg[i0 / 8 + j].y; + } + } + dot = warp_sum<8>(dot); + dot += cell < width ? (mask ? __half2float(mask[cell]) : 0.0f) : __int_as_float(0xff800000u); + next_max = fmaxf(next_max, dot + (3.0f * 0.6931f)); + if (lane % 8 == row) score = dot; + } +#pragma unroll + for (int offset = 8; offset < 32; offset <<= 1) + next_max = fmaxf(next_max, __shfl_xor_sync(0xffffffffu, next_max, offset, 32)); + const float rescale = expf(maximum - next_max); + maximum = next_max; + score = expf(score - maximum); + sum = sum * rescale + score; + tile[tid] = score; + __syncwarp(); +#pragma unroll + for (int k0 = 0; k0 < 32; k0 += 4) { + const int local = warp * 32 + k0 + lane / 8, cell = base + local; + const float weight = tile[local]; +#pragma unroll + for (int i0 = 0; i0 < 128; i0 += 32) { + const int i = i0 + (lane % 8) * 4; +#pragma unroll + for (int j = 0; j < 4; ++j) { + const int d = 2 * (i + j); + const float a = cell < width ? __half2float(v[(cell * 2 + kv) * 256 + d]) : 0.0f; + const float b = cell < width ? __half2float(v[(cell * 2 + kv) * 256 + d + 1]) : 0.0f; + // The pinned sm120a binary contracts the old-accumulator + // rescale with the FIRST addition: fma(rescale,old,round(V*w)). + // Later columns use fma(V,w,acc). Explicit intrinsics retain + // that order despite this adapter's masked-load branches. + if (k0 == 0) { + vkq[i0 / 8 + j].x = __fmaf_rn(rescale, vkq[i0 / 8 + j].x, __fmul_rn(a, weight)); + vkq[i0 / 8 + j].y = __fmaf_rn(rescale, vkq[i0 / 8 + j].y, __fmul_rn(b, weight)); + } else { + vkq[i0 / 8 + j].x = __fmaf_rn(a, weight, vkq[i0 / 8 + j].x); + vkq[i0 / 8 + j].y = __fmaf_rn(b, weight, vkq[i0 / 8 + j].y); + } + } + } + } + } + if (warp == 0) { max_shared[lane] = -FLT_MAX / 2.0f; sum_shared[lane] = 0.0f; } + __syncthreads(); + if (lane == 0) max_shared[warp] = maximum; + __syncthreads(); + const float global_max = warp_max(max_shared[lane]); + const float rescale = expf(maximum - global_max); +#pragma unroll + for (int i = 0; i < 16; ++i) { + vkq[i].x = __fmul_rn(vkq[i].x, rescale); + vkq[i].y = __fmul_rn(vkq[i].y, rescale); + } +#pragma unroll + for (int i0 = 0; i0 < 128; i0 += 32) { + const int start = warp * 4 * 256 + (lane / 8) * 256 + 2 * (i0 + (lane % 8) * 4); +#pragma unroll + for (int j = 0; j < 4; ++j) { + tile[start + 2 * j] = vkq[i0 / 8 + j].x; + tile[start + 2 * j + 1] = vkq[i0 / 8 + j].y; + } + } + sum *= rescale; + sum = warp_sum<32>(sum); + if (lane == 0) sum_shared[warp] = sum; + __syncthreads(); + sum = warp_sum<32>(sum_shared[lane]); +#pragma unroll + for (int i0 = 0; i0 < 256; i0 += 128) { + float result = 0.0f; +#pragma unroll + for (int w = 0; w < 4; ++w) { +#pragma unroll + for (int group = 0; group < 4; ++group) + result += tile[w * 4 * 256 + group * 256 + i0 + tid]; + } + out[head * 256 + i0 + tid] = result / sum; + } +} +struct Span { const void* p; std::size_t n, alignment; }; +void validate_spans(const Span* spans, int count) { + for (int i = 0; i < count; ++i) { + const auto a = reinterpret_cast(spans[i].p); + if (!a || a % spans[i].alignment || spans[i].n > UINTPTR_MAX - a) + throw std::invalid_argument("native FlashAttention requires nonnull aligned bounded spans"); + for (int j = 0; j < i; ++j) { + const auto b = reinterpret_cast(spans[j].p); + if (a < b + spans[j].n && b < a + spans[i].n) + throw std::invalid_argument("native FlashAttention requires disjoint buffers"); + } + } +} +} // namespace + +void native_flash_attn_short_step(const float* q, const uint16_t* k, const uint16_t* v, + const int32_t* step, int64_t capacity, int max_context, + const QsaShapes& shapes, float* output, int32_t* status, + const uint16_t* mask, void* stream) { + if (!stream || shapes.n_head != 24 || shapes.n_head_kv != 2 || shapes.head_dim != 256 || + shapes.idx_block != 4 || shapes.idx_top_k < 256 || capacity < 256 || + uint64_t(capacity) > std::numeric_limits::max() / 1024 || + max_context < 1 || max_context > 256) + throw std::invalid_argument("native FlashAttention supports only Q24x256/KV2x256, capacity>=256 and context1..256 on an explicit stream"); + const std::size_t kv_bytes = std::size_t(capacity) * 1024; + const Span spans[] = {{q, 24 * 256 * 4, 4}, {k, kv_bytes, 2}, {v, kv_bytes, 2}, + {step, kStepCount * 4, 4}, {output, 24 * 256 * 4, 4}, + {status, 4, 4}, {mask, 256 * 2, 2}}; + validate_spans(spans, mask ? 7 : 6); + attend<<<24, dim3(32, 4), 0, static_cast(stream)>>>(q, reinterpret_cast(k), + reinterpret_cast(v), step, max_context, 256, 0.0625f, output, status, reinterpret_cast(mask)); + const auto result = cudaGetLastError(); + if (result != cudaSuccess) + throw std::runtime_error(std::string("native FlashAttention launch: ") + cudaGetErrorString(result)); +} +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_gdn.cu b/src/kernels/cuda/native_gdn.cu new file mode 100644 index 00000000..6661d50f --- /dev/null +++ b/src/kernels/cuda/native_gdn.cu @@ -0,0 +1,122 @@ +// Arithmetic adapted from gated_delta_net.cu at pinned llama.cpp +// 3cf03257f219afbe7334045ff7c6a06ac68c627d. Only state addressing differs: +// each warp owns one Strata (head,column) and retains its four rows in registers. +// Compile with --use_fast_math to match the pinned CUDA implementation. +// +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +#include "strata/kernels/native_gdn.hpp" +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +std::atomic enabled{false}; +constexpr int S = 128; + +__device__ __forceinline__ float warp_sum(float value) { +#pragma unroll + for (int offset = 16; offset > 0; offset >>= 1) + value += __shfl_xor_sync(0xffffffff, value, offset, 32); + return value; +} + +__global__ void __launch_bounds__(128, 2) +step(float* __restrict__ state, const float* __restrict__ q, const float* __restrict__ k, + const float* __restrict__ v, const float* __restrict__ gate, const float* __restrict__ beta, + float* __restrict__ output, int h_k, int h_v, float scale) { + const int head = blockIdx.x; + const int lane = threadIdx.x; + const int col = blockIdx.z * blockDim.y + threadIdx.y; + const int q_head = head % h_k; + float s_shard[4], k_reg[4], q_reg[4]; +#pragma unroll + for (int r = 0; r < 4; ++r) { + const int i = r * 32 + lane; + s_shard[r] = state[(size_t(i) * h_v + head) * S + col]; + k_reg[r] = k[q_head * S + i]; + q_reg[r] = q[q_head * S + i]; + } + const float g_val = expf(gate[head]); + float kv_shard = 0.0f; +#pragma unroll + for (int r = 0; r < 4; ++r) kv_shard += s_shard[r] * k_reg[r]; + const float kv_col = warp_sum(kv_shard); + const float delta_col = (v[head * S + col] - g_val * kv_col) * beta[head]; + float attn_partial = 0.0f; +#pragma unroll + for (int r = 0; r < 4; ++r) { + s_shard[r] = g_val * s_shard[r] + k_reg[r] * delta_col; + attn_partial += s_shard[r] * q_reg[r]; + } + const float attn_col = warp_sum(attn_partial); + if (lane == 0) output[head * S + col] = attn_col * scale; +#pragma unroll + for (int r = 0; r < 4; ++r) { + const int i = r * 32 + lane; + state[(size_t(i) * h_v + head) * S + col] = s_shard[r]; + } +} + +bool valid_span(const void* pointer, size_t bytes) { + const auto address = reinterpret_cast(pointer); + return pointer && address % sizeof(float) == 0 && bytes <= UINTPTR_MAX - address; +} +bool overlap(const void* a, size_t an, const void* b, size_t bn) { + const auto ap = reinterpret_cast(a), bp = reinterpret_cast(b); + return ap < bp + bn && bp < ap + an; +} +} + +void native_gdn_set_enabled(bool value) { enabled.store(value, std::memory_order_relaxed); } +bool native_gdn_enabled() { return enabled.load(std::memory_order_relaxed); } + +void native_gdn_step(float* state, const float* q, const float* k, const float* v, + const float* gate, const float* beta, float* output, + const GdnShapes& shape, void* stream) { + if (!stream || shape.S != S || shape.h_k <= 0 || shape.h_v <= 0 || + shape.h_v > 65535 || shape.h_v % shape.h_k != 0) + throw std::invalid_argument("native GDN requires a stream, S=128 and positive divisible head counts <=65535"); + const size_t state_bytes = size_t(S) * S * size_t(shape.h_v) * sizeof(float); + const size_t qk_bytes = size_t(S) * size_t(shape.h_k) * sizeof(float); + const size_t output_bytes = size_t(S) * size_t(shape.h_v) * sizeof(float); + const size_t head_bytes = size_t(shape.h_v) * sizeof(float); + if (!valid_span(state, state_bytes) || !valid_span(output, output_bytes) || + overlap(state, state_bytes, output, output_bytes)) + throw std::invalid_argument("native GDN requires aligned, disjoint state and output spans"); + const void* inputs[] = {q, k, v, gate, beta}; + const size_t bytes[] = {qk_bytes, qk_bytes, output_bytes, head_bytes, head_bytes}; + for (int i = 0; i < 5; ++i) { + if (!valid_span(inputs[i], bytes[i]) || overlap(state, state_bytes, inputs[i], bytes[i]) || + overlap(output, output_bytes, inputs[i], bytes[i])) + throw std::invalid_argument("native GDN requires aligned input spans disjoint from state and output"); + } + const float scale = 1.0f / sqrtf(float(S)); + step<<(stream)>>>( + state, q, k, v, gate, beta, output, int(shape.h_k), int(shape.h_v), scale); + const auto error = cudaGetLastError(); + if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error)); +} +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_gdn_preprocess.cu b/src/kernels/cuda/native_gdn_preprocess.cu new file mode 100644 index 00000000..535cb31d --- /dev/null +++ b/src/kernels/cuda/native_gdn_preprocess.cu @@ -0,0 +1,199 @@ +// Adapted from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d: +// ggml/src/ggml-cuda/{norm.cu,common.cuh,unary.cu,unary.cuh,ssm-conv.cu,scale.cu}. +// Compile with --use_fast_math, as the pinned backend does. +// +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +#include "strata/kernels/native_gdn_preprocess.hpp" +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +constexpr int S = 128; + +__device__ __forceinline__ float warp_sum(float value) { +#pragma unroll + for (int offset = 16; offset > 0; offset >>= 1) + value += __shfl_xor_sync(0xffffffffu, value, offset, 32); + return value; +} +__device__ __forceinline__ float norm_sum(float value, float* sums) { + const int lane = threadIdx.x % 32; + value = warp_sum(value); + if (lane == 0) sums[threadIdx.x / 32] = value; + __syncthreads(); + value = lane < 8 ? sums[lane] : 0.0f; + return warp_sum(value); +} +__device__ __forceinline__ float sigmoid(float value) { return 1.0f / (1.0f + expf(-value)); } + +__global__ void conv_silu(float* __restrict__ history, const float* __restrict__ input, + const float* __restrict__ weights, float* __restrict__ raw_output, + float* __restrict__ silu_output, int channels) { + const int c = blockIdx.x * blockDim.x + threadIdx.x; + if (c >= channels) return; + float values[4] = {history[c * 3], history[c * 3 + 1], history[c * 3 + 2], input[c]}; + float sum = 0.0f; +#pragma unroll + for (int tap = 0; tap < 4; ++tap) sum += values[tap] * weights[c * 4 + tap]; + // The native SSM kernel adds its zero bias even when there is no bias input. + sum = __fadd_rn(sum, 0.0f); + raw_output[c] = sum; + silu_output[c] = sum / (1.0f + expf(-sum)); +#pragma unroll + for (int tap = 0; tap < 3; ++tap) history[c * 3 + tap] = values[tap + 1]; +} + +__global__ void l2_norm(float* input, float epsilon, float scale_after) { + const int col = threadIdx.x; + input += size_t(blockIdx.x) * S; + const float value = col < S ? input[col] : 0.0f; + float partial = 0.0f; + if (col < S) partial += value * value; + __shared__ float sums[32]; + partial = norm_sum(partial, sums); + const float scale = rsqrtf(partial / S + epsilon); + if (col < S) { + // Preserve the FP32 store boundary between RMSNorm and ggml_scale. + const float normalized = __fmul_rn(scale, value); + input[col] = __fmaf_rn(normalized, scale_after, 0.0f); + } +} + +__global__ void beta_sigmoid(float* beta, int count) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i < count) beta[i] = sigmoid(beta[i]); +} +__global__ void gate_softplus(const float* __restrict__ alpha, const float* __restrict__ dt, + const float* __restrict__ ssm_a, float* __restrict__ gate, int count) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i >= count) return; + const float value = __fadd_rn(alpha[i], dt[i]); + const float softplus = value > 20.0f ? value : logf(1.0f + expf(value)); + gate[i] = softplus * ssm_a[i]; +} + +__global__ void out_norm(const float* __restrict__ input, const float* __restrict__ z, + const float* __restrict__ gamma, float* __restrict__ output, float epsilon) { + const int col = threadIdx.x; + const size_t offset = size_t(blockIdx.x) * S; + const float value = col < S ? input[offset + col] : 0.0f; + float partial = 0.0f; + if (col < S) partial += value * value; + __shared__ float sums[32]; + partial = norm_sum(partial, sums); + const float scale = rsqrtf(partial / S + epsilon); + if (col < S) { + // RMSNorm+gamma is one pinned fused operator, followed by sigmoid*mul. + const float weighted = __fmul_rn(__fmul_rn(scale, value), gamma[col]); + output[offset + col] = weighted * sigmoid(z[offset + col]); + } +} + +struct Span { const void* pointer; size_t bytes; }; +void valid(Span span) { + const auto address = reinterpret_cast(span.pointer); + if (!span.pointer || address % sizeof(float) || span.bytes > UINTPTR_MAX - address) + throw std::invalid_argument("native GDN preprocessing requires aligned nonnull valid spans"); +} +void disjoint(Span a, Span b) { + const auto ap = reinterpret_cast(a.pointer), bp = reinterpret_cast(b.pointer); + if (ap < bp + b.bytes && bp < ap + a.bytes) + throw std::invalid_argument("native GDN preprocessing requires disjoint writable spans"); +} +void count_and_stream(int64_t count, void* stream) { + if (!stream || count <= 0 || count > 65535) + throw std::invalid_argument("native GDN preprocessing requires a stream and count in [1,65535]"); +} +void norm_geometry(int64_t rows, int64_t cols, float epsilon, void* stream) { + count_and_stream(rows, stream); + if (cols != S || !std::isfinite(epsilon) || epsilon < 0.0f) + throw std::invalid_argument("native GDN norm requires width 128 and finite nonnegative epsilon"); +} +void check_launch() { + const auto error = cudaGetLastError(); + if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error)); +} +} + +void native_gdn_conv_silu(float* history, const float* input, const float* weights, + float* raw_output, float* silu_output, int64_t channels, + int64_t d_conv, void* stream) { + count_and_stream(channels, stream); + if (d_conv != 4) throw std::invalid_argument("native GDN convolution requires four taps"); + const size_t bytes = size_t(channels) * sizeof(float); + const Span writable[] = {{history, 3 * bytes}, {raw_output, bytes}, {silu_output, bytes}}; + const Span inputs[] = {{input, bytes}, {weights, 4 * bytes}}; + for (auto span : writable) valid(span); + for (auto span : inputs) valid(span); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < i; ++j) disjoint(writable[i], writable[j]); + for (auto span : inputs) disjoint(writable[i], span); + } + conv_silu<<(stream)>>>( + history, input, weights, raw_output, silu_output, int(channels)); + check_launch(); +} +void native_gdn_l2_norm(float* input, int64_t rows, int64_t cols, float epsilon, void* stream) { + norm_geometry(rows, cols, epsilon, stream); + valid({input, size_t(rows) * S * sizeof(float)}); + l2_norm<<(stream)>>>(input, epsilon / S, 1.0f / sqrtf(float(S))); + check_launch(); +} +void native_gdn_beta_gate(float* beta, int64_t heads, void* stream) { + count_and_stream(heads, stream); + valid({beta, size_t(heads) * sizeof(float)}); + beta_sigmoid<<(stream)>>>(beta, int(heads)); + check_launch(); +} +void native_gdn_gate(const float* alpha, const float* dt, const float* ssm_a, + float* gate, int64_t heads, void* stream) { + count_and_stream(heads, stream); + const size_t bytes = size_t(heads) * sizeof(float); + const Span output{gate, bytes}; + valid(output); + for (auto input : {Span{alpha, bytes}, Span{dt, bytes}, Span{ssm_a, bytes}}) { + valid(input); + disjoint(output, input); + } + gate_softplus<<(stream)>>>(alpha, dt, ssm_a, gate, int(heads)); + check_launch(); +} +void native_gdn_out_norm(const float* output, const float* z, const float* gamma, + float* destination, int64_t heads, int64_t cols, + float epsilon, void* stream) { + norm_geometry(heads, cols, epsilon, stream); + const size_t bytes = size_t(heads) * S * sizeof(float); + const Span writable{destination, bytes}; + valid(writable); + for (auto input : {Span{output, bytes}, Span{z, bytes}, Span{gamma, S * sizeof(float)}}) { + valid(input); + disjoint(writable, input); + } + out_norm<<(stream)>>>(output, z, gamma, destination, epsilon); + check_launch(); +} +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_gr_norm.cu b/src/kernels/cuda/native_gr_norm.cu new file mode 100644 index 00000000..5abe013e --- /dev/null +++ b/src/kernels/cuda/native_gr_norm.cu @@ -0,0 +1,102 @@ +// Adapted from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d: +// ggml/src/ggml-cuda/{norm.cu,common.cuh}. Scope: contiguous weighted F32 RMSNorm. +// +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "strata/kernels/native_gr_norm.hpp" + +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { + +__device__ __forceinline__ float norm_warp_sum(float value) { +#pragma unroll + for (int offset = 16; offset > 0; offset >>= 1) { + value += __shfl_xor_sync(0xffffffffu, value, offset, 32); + } + return value; +} + +template +__global__ void weighted_rms_norm(const float* __restrict__ input, + const float* __restrict__ gamma, + float* __restrict__ output, int n_cols, float epsilon) { + const int tid = threadIdx.x; + const std::size_t row_offset = std::size_t(blockIdx.x) * n_cols; + input += row_offset; + gamma += row_offset; + output += row_offset; + float partial = 0.0f; + for (int col = tid; col < n_cols; col += BlockSize) { + const float value = input[col]; + partial += value * value; + } + + // Pinned block_reduce: every warp repeats the final XOR + // reduction. There is no warp-0-only broadcast or downward-shuffle tree. + __shared__ float sums[32]; + partial = norm_warp_sum(partial); + const int lane = tid % 32; + if (lane == 0) sums[tid / 32] = partial; + __syncthreads(); + partial = 0.0f; + if (lane < BlockSize / 32) partial = sums[lane]; + partial = norm_warp_sum(partial); + + const float mean = partial / n_cols; + const float scale = rsqrtf(mean + epsilon); + for (int col = tid; col < n_cols; col += BlockSize) { + output[col] = scale * input[col] * gamma[col]; + } +} + +void check_pointer(const void* p) { + if (!p || reinterpret_cast(p) % alignof(float) != 0) + throw std::invalid_argument("native GR RMSNorm requires non-null four-byte aligned pointers"); +} + +} // namespace + +void native_gr_rms_norm_weighted(const float* input, const float* gamma, float* output, + int n_cols, int n_rows, float epsilon, void* stream) { + if (n_cols <= 0 || n_rows <= 0 || !std::isfinite(epsilon) || epsilon < 0.0f) + throw std::invalid_argument("native GR RMSNorm requires positive dimensions and finite nonnegative epsilon"); + check_pointer(input); + check_pointer(gamma); + check_pointer(output); + const auto cuda_stream = static_cast(stream); + if (n_cols < 1024) + weighted_rms_norm<256><<>>(input, gamma, output, n_cols, epsilon); + else + weighted_rms_norm<1024><<>>(input, gamma, output, n_cols, epsilon); + const auto error = cudaGetLastError(); + if (error != cudaSuccess) + throw std::runtime_error(std::string("native GR RMSNorm launch: ") + cudaGetErrorString(error)); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_gr_postops.cu b/src/kernels/cuda/native_gr_postops.cu new file mode 100644 index 00000000..8616e331 --- /dev/null +++ b/src/kernels/cuda/native_gr_postops.cu @@ -0,0 +1,115 @@ +// Adapted from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d: +// ggml/src/ggml-cuda/{dsv4-hc.cu,scale.cu,unary.cu,unary.cuh}. +// +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "strata/kernels/native_gr_postops.hpp" +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +constexpr int THREADS = 256; +__device__ __forceinline__ float sigmoid(float x) { return 1.0f / (1.0f + expf(-x)); } +// ggml SCALE uses scale*x+bias, including its +0 bias. Retain this operation +// explicitly so compile-time zero does not change signed-zero behavior. +__device__ __forceinline__ float scale_zero_bias(float x, float scale) { + return __fmaf_rn(scale, x, 0.0f); +} +__global__ void down_silu(float* lo, int count, float scale) { + const std::size_t i = std::size_t(blockIdx.x) * blockDim.x + threadIdx.x; + if (i >= std::size_t(count)) return; + const float x = scale_zero_bias(lo[i], scale); + lo[i] = x / (1.0f + expf(-x)); +} +template +__global__ void pre_gated(const float* __restrict__ xn, float* __restrict__ gate, + float* __restrict__ mixed, int n_embd, int hc, float scale) { + const std::size_t d = std::size_t(blockIdx.x) * blockDim.x + threadIdx.x; + if (d >= std::size_t(n_embd)) return; + float sum = 0.0f; + for (int c = 0; c < hc; ++c) { + const std::size_t i = std::size_t(c) * n_embd + d; + const float x = xn[i], w = sigmoid(gate[i]); + const float product = __fmul_rn(x, w); + gate[i] = product; + if constexpr (Fused) sum = __fmaf_rn(x, w, sum); + else sum = c == 0 ? product : __fadd_rn(sum, product); + } + if constexpr (Fused) mixed[d] = scale * sum; + else mixed[d] = scale_zero_bias(sum, scale); +} +__global__ void post(const float* residual, const float* __restrict__ block_out, + const float* __restrict__ inject, float* output, + int n_embd, int hc, float scale) { + const std::size_t i = std::size_t(blockIdx.x) * blockDim.x + threadIdx.x; + if (i >= std::size_t(n_embd) * hc) return; + const int c = int(i / n_embd), d = int(i % n_embd); + const float weight = scale_zero_bias(sigmoid(scale_zero_bias(inject[c], scale)), 2.0f); + // Exact residual/output alias is supported; no other thread reads residual[i]. + output[i] = __fmaf_rn(block_out[d], weight, residual[i]); +} +void check_pointer(const void* p) { + if (!p || reinterpret_cast(p) % alignof(float)) + throw std::invalid_argument("native GR postops require non-null four-byte aligned pointers"); +} +void check_shape(int n, int hc) { + if (n <= 0 || hc <= 0 || std::uint64_t(n) * hc > std::uint64_t(std::numeric_limits::max())) + throw std::invalid_argument("native GR postops require positive bounded dimensions"); +} +unsigned blocks(std::size_t n) { return unsigned((n + THREADS - 1) / THREADS); } +void check_launch() { + const auto error = cudaGetLastError(); + if (error != cudaSuccess) + throw std::runtime_error(std::string("native GR postops launch: ") + cudaGetErrorString(error)); +} +} // namespace + +void native_gr_down_silu(float* lo, int hc_lr, int hc, void* stream) { + check_shape(hc_lr, hc); + check_pointer(lo); + down_silu<<(stream)>>>(lo, hc_lr, 1.0f / float(hc)); + check_launch(); +} +void native_gr_pre_gated(const float* xn, float* gate, float* mixed, + int n_embd, int hc, bool fused_layer, void* stream) { + check_shape(n_embd, hc); + check_pointer(xn); check_pointer(gate); check_pointer(mixed); + if (fused_layer) + pre_gated<<(stream)>>>(xn, gate, mixed, n_embd, hc, 1.0f / float(hc)); + else + pre_gated<<(stream)>>>(xn, gate, mixed, n_embd, hc, 1.0f / float(hc)); + check_launch(); +} +void native_gr_post(const float* residual, const float* block_out, const float* inject, + float* output, int n_embd, int hc, void* stream) { + check_shape(n_embd, hc); + check_pointer(residual); check_pointer(block_out); check_pointer(inject); check_pointer(output); + post<<(stream)>>>( + residual, block_out, inject, output, n_embd, hc, 1.0f / float(hc)); + check_launch(); +} +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_mmvq.cu b/src/kernels/cuda/native_mmvq.cu new file mode 100644 index 00000000..a7801fa7 --- /dev/null +++ b/src/kernels/cuda/native_mmvq.cu @@ -0,0 +1,1492 @@ +// Adapted from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d: +// ggml/src/ggml-cuda/{quantize.cu,vecdotq.cuh,mmvq.cu,common.cuh} +// and ggml/src/ggml-common.h. See docs/native-mmvq.md for exact scope. +// +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "strata/kernels/native_mmvq.hpp" +#include "strata/kernels/iq_kernels.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int QK = 256; +constexpr int Q8K = 32; +constexpr int QI = 32; +constexpr int VDR = 2; +constexpr int WARPS = 4; +constexpr int WARP = 32; +constexpr int QUANT_THREADS = 256; + +struct Q5KBlock { + half2 dm; + uint8_t scales[12]; + uint8_t qh[32]; + uint8_t qs[128]; +}; +struct Q81Block { + half2 ds; + int8_t qs[32]; +}; +struct Q20Block { + half d; + uint8_t qs[16]; +}; +struct Q3KBlock { + uint8_t hmask[32]; + uint8_t qs[64]; + uint8_t scales[12]; + half d; +}; +struct IQ4XSBlock { + half d; + uint16_t scales_h; + uint8_t scales_l[4]; + uint8_t qs[128]; +}; +struct Q4KBlock { + half2 dm; + uint8_t scales[12]; + uint8_t qs[128]; +}; +struct Q6KBlock { + uint8_t ql[128]; + uint8_t qh[64]; + int8_t scales[16]; + half d; +}; +struct Q40Block { + half d; + uint8_t qs[16]; +}; +struct Q50Block { + half d; + uint8_t qh[4]; + uint8_t qs[16]; +}; +struct Q80Block { + half d; + int8_t qs[32]; +}; +struct IQ4NLBlock { + half d; + uint8_t qs[16]; +}; +static_assert(sizeof(Q5KBlock) == 176 && alignof(Q5KBlock) == 4); +static_assert(sizeof(Q81Block) == 36 && alignof(Q81Block) == 4); +static_assert(sizeof(Q20Block) == 18 && alignof(Q20Block) == 2 && offsetof(Q20Block, qs) == 2); +static_assert(sizeof(Q3KBlock) == 110 && alignof(Q3KBlock) == 2 && offsetof(Q3KBlock, qs) == 32 && + offsetof(Q3KBlock, scales) == 96 && offsetof(Q3KBlock, d) == 108); +static_assert(sizeof(IQ4XSBlock) == 136 && alignof(IQ4XSBlock) == 2 && + offsetof(IQ4XSBlock, scales_h) == 2 && offsetof(IQ4XSBlock, scales_l) == 4 && + offsetof(IQ4XSBlock, qs) == 8); +static_assert(offsetof(Q5KBlock, scales) == 4 && offsetof(Q5KBlock, qh) == 16 && + offsetof(Q5KBlock, qs) == 48 && offsetof(Q81Block, qs) == 4); +static_assert(sizeof(Q4KBlock) == 144 && alignof(Q4KBlock) == 4 && + offsetof(Q4KBlock, scales) == 4 && offsetof(Q4KBlock, qs) == 16); +static_assert(sizeof(Q6KBlock) == 210 && alignof(Q6KBlock) == 2 && + offsetof(Q6KBlock, qh) == 128 && offsetof(Q6KBlock, scales) == 192 && + offsetof(Q6KBlock, d) == 208); +static_assert(sizeof(Q40Block) == 18 && alignof(Q40Block) == 2 && offsetof(Q40Block, qs) == 2); +static_assert(sizeof(Q50Block) == 22 && alignof(Q50Block) == 2 && + offsetof(Q50Block, qh) == 2 && offsetof(Q50Block, qs) == 6); +static_assert(sizeof(Q80Block) == 34 && alignof(Q80Block) == 2 && offsetof(Q80Block, qs) == 2); +static_assert(sizeof(IQ4NLBlock) == 18 && alignof(IQ4NLBlock) == 2 && offsetof(IQ4NLBlock, qs) == 2); + +__device__ __forceinline__ float warp_sum(float x) { +#pragma unroll + for (int offset = WARP / 2; offset > 0; offset >>= 1) { + x += __shfl_xor_sync(0xffffffff, x, offset, WARP); + } + return x; +} + +__device__ __forceinline__ float warp_max(float x) { +#pragma unroll + for (int offset = WARP / 2; offset > 0; offset >>= 1) { + x = fmaxf(x, __shfl_xor_sync(0xffffffff, x, offset, WARP)); + } + return x; +} + +__launch_bounds__(QUANT_THREADS, 1) +__global__ void native_quantize_q8_1_kernel(const float* __restrict__ x, + Q81Block* __restrict__ y, int n_in) { + const int i = int(blockIdx.x) * QUANT_THREADS + int(threadIdx.x); + if (i >= n_in) return; // n_in is a multiple of 32: only whole warps return. + const float xi = x[i]; + const float amax = warp_max(fabsf(xi)); + const float sum = warp_sum(xi); + const float d = amax / 127.0f; + const int8_t q = amax == 0.0f ? 0 : roundf(xi / d); + y[i / Q8K].qs[i % Q8K] = q; + if (i % Q8K == 0) y[i / Q8K].ds = make_half2(d, sum); +} + +// Exact pinned vec_dot_q5_K_q8_1_impl_vmmq expression and integer dot order. +__device__ __forceinline__ float q5_q8_dot_impl( + const int* __restrict__ vl, const int* __restrict__ vh, const int* __restrict__ u, + const uint8_t* __restrict__ sc, const uint8_t* __restrict__ m, const half2& dm5, + const float* __restrict__ d8) { + float sumf_d = 0.0f; + float sumf_m = 0.0f; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const int vl0i = (vl[0] >> (4 * i)) & 0x0f0f0f0f; + const int vl1i = (vl[1] >> (4 * i)) & 0x0f0f0f0f; + const int vh0i = ((vh[0] >> i) << 4) & 0x10101010; + const int vh1i = ((vh[1] >> i) << 4) & 0x10101010; + const int v0i = vl0i | vh0i; + const int v1i = vl1i | vh1i; + const int dot1 = __dp4a(v0i, u[2 * i], __dp4a(v1i, u[2 * i + 1], 0)); + const int dot2 = __dp4a(0x01010101, u[2 * i], __dp4a(0x01010101, u[2 * i + 1], 0)); + sumf_d += d8[i] * (dot1 * sc[i]); + sumf_m += d8[i] * (dot2 * m[i]); + } + const float2 dm5f = __half22float2(dm5); + return dm5f.x * sumf_d - dm5f.y * sumf_m; +} + +__device__ __forceinline__ float q5_q8_dot(const Q5KBlock* __restrict__ bq5, + const Q81Block* __restrict__ bq8, int iqs) { + int vl[2]; + int vh[2]; + int u[4]; + float d8[2]; + const int bq8_offset = 2 * ((iqs / 2) / 4); + const int* ql = reinterpret_cast(bq5->qs + 16 * bq8_offset + 4 * ((iqs / 2) % 4)); + const int* qh = reinterpret_cast(bq5->qh + 4 * ((iqs / 2) % 4)); + vl[0] = ql[0]; + vl[1] = ql[4]; + vh[0] = qh[0] >> bq8_offset; + vh[1] = qh[4] >> bq8_offset; + + const uint16_t* scales = reinterpret_cast(bq5->scales); + const int j = bq8_offset / 2; + const int jm = j & 1; + const uint32_t s0 = scales[jm]; + const uint32_t s2 = scales[jm + 2]; + const uint32_t s4 = scales[jm + 4]; + const uint32_t hi = uint32_t(-int32_t(j >= 2)); + uint16_t aux[2]; + aux[0] = uint16_t(((s0 & 0x3f3f) & ~hi) | + ((((s4 >> 0) & 0x0f0f) | ((s0 & 0xc0c0) >> 2)) & hi)); + aux[1] = uint16_t(((s2 & 0x3f3f) & ~hi) | + ((((s4 >> 4) & 0x0f0f) | ((s2 & 0xc0c0) >> 2)) & hi)); + const uint8_t* sc = reinterpret_cast(aux); + const uint8_t* m = sc + 2; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const Q81Block* bq8i = bq8 + bq8_offset + i; + d8[i] = __low2float(bq8i->ds); + const int* q8 = reinterpret_cast(bq8i->qs) + ((iqs / 2) % 4); + u[2 * i] = q8[0]; + u[2 * i + 1] = q8[4]; + } + return q5_q8_dot_impl(vl, vh, u, sc, m, bq5->dm, d8); +} + +// The generic ncols=1 oracle uses 4 warps and 1 row (or 4 rows for small K), +// eight weight blocks per K iteration, warp-ascending shared sum, then XOR tree. +template +__launch_bounds__(WARPS * WARP, 1) +__global__ void native_q5_k_mmvq_kernel(const Q5KBlock* __restrict__ w, + const Q81Block* __restrict__ x, + float* __restrict__ y, int n_in, int n_out) { + constexpr int ROWS = SmallK ? WARPS : 1; + constexpr int BLOCKS_PER_ITER = VDR * WARPS * WARP / QI; + const int tid = WARP * int(threadIdx.y) + int(threadIdx.x); + const int row0 = ROWS * int(blockIdx.x); + const int blocks_per_row = n_in / QK; + float tmp[ROWS] = {}; + for (int kbx = tid / (QI / VDR); kbx < blocks_per_row; kbx += BLOCKS_PER_ITER) { + const int kby = kbx * (QK / Q8K); + const int kqs = VDR * (tid % (QI / VDR)); +#pragma unroll + for (int i = 0; i < ROWS; ++i) { + // The source assumes allocator padding for partial row groups. This + // guard preserves every valid row's math without an out-of-bounds read. + if (row0 + i < n_out) { + const std::size_t block = std::size_t(row0 + i) * blocks_per_row + kbx; + tmp[i] += q5_q8_dot(w + block, x + kby, kqs); + } + } + } + __shared__ float partial[WARPS - 1][ROWS][WARP]; + if (threadIdx.y > 0) { +#pragma unroll + for (int i = 0; i < ROWS; ++i) partial[threadIdx.y - 1][i][threadIdx.x] = tmp[i]; + } + __syncthreads(); + if (threadIdx.y > 0) return; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { +#pragma unroll + for (int l = 0; l < WARPS - 1; ++l) tmp[i] += partial[l][i][threadIdx.x]; + tmp[i] = warp_sum(tmp[i]); + if (threadIdx.x == i && row0 + i < n_out) y[row0 + i] = tmp[i]; + } +} + +// Exact pinned vec_dot_q2_0_q8_1: each thread handles one 32-element chunk. +// The weight block is only 2-byte aligned, so qs is intentionally loaded as +// int16_t, unlike the naturally 4-byte aligned activation codes. +__device__ __forceinline__ float q2_q8_dot(const Q20Block* __restrict__ w, + const Q81Block* __restrict__ x, int iqs) { + const float d2 = w->d; + const int16_t* qs = reinterpret_cast(w->qs) + iqs * 4; + const Q81Block* chunk = x + iqs; + const int* q8 = reinterpret_cast(chunk->qs); + int sumi = 0; +#pragma unroll + for (int j = 0; j < 4; ++j) { + const int q = qs[j]; + const int u = q8[j * 2]; + const int v = q8[j * 2 + 1]; + const int qe = __byte_perm(0x020100ff, 0x020100ff, q >> 0); + const int qo = __byte_perm(0x020100ff, 0x020100ff, q >> 2); + const int qx = __byte_perm(qe, qo, 0x5140); + const int qy = __byte_perm(qe, qo, 0x7362); + sumi = __dp4a(u, qx, sumi); + sumi = __dp4a(v, qy, sumi); + } + const float d8 = __low2float(chunk->ds); + return d2 * d8 * sumi; +} + +// Q2_0 generic MMVQ: QK=64, QI=2, VDR=1, 64 blocks per iteration. +// Preserve the same outer accumulation and cross-warp reduction as the oracle. +template +__launch_bounds__(WARPS * WARP, 1) +__global__ void native_q2_0_mmvq_kernel(const Q20Block* __restrict__ w, + const Q81Block* __restrict__ x, + float* __restrict__ y, int n_in, int n_out) { + constexpr int ROWS = SmallK ? WARPS : 1; + constexpr int BLOCKS_PER_ITER = WARPS * WARP / 2; + const int tid = WARP * int(threadIdx.y) + int(threadIdx.x); + const int row0 = ROWS * int(blockIdx.x); + const int blocks_per_row = n_in / 64; + float tmp[ROWS] = {}; + for (int kbx = tid / 2; kbx < blocks_per_row; kbx += BLOCKS_PER_ITER) { + const int kby = kbx * 2; + const int kqs = tid % 2; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { + if (row0 + i < n_out) { + const std::size_t block = std::size_t(row0 + i) * blocks_per_row + kbx; + tmp[i] += q2_q8_dot(w + block, x + kby, kqs); + } + } + } + __shared__ float partial[WARPS - 1][ROWS][WARP]; + if (threadIdx.y > 0) { +#pragma unroll + for (int i = 0; i < ROWS; ++i) partial[threadIdx.y - 1][i][threadIdx.x] = tmp[i]; + } + __syncthreads(); + if (threadIdx.y > 0) return; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { +#pragma unroll + for (int l = 0; l < WARPS - 1; ++l) tmp[i] += partial[l][i][threadIdx.x]; + tmp[i] = warp_sum(tmp[i]); + if (threadIdx.x == i && row0 + i < n_out) y[row0 + i] = tmp[i]; + } +} + +// Q3_K's 110-byte stride gives alternate blocks only two-byte alignment. +// Preserve the pinned helper's pair of 16-bit loads and little-endian combine. +__device__ __forceinline__ int load_int_b2(const void* ptr, int i32) { + const auto* x = static_cast(ptr); + int value = x[2 * i32] << 0; + value |= x[2 * i32 + 1] << 16; + return value; +} + +__device__ __forceinline__ float q3_q8_dot_impl(int vl, int vh, const int* __restrict__ u, + const uint8_t* __restrict__ scales, + int scale_offset, float d3, + const float* __restrict__ d8) { + float sumf = 0.0f; +#pragma unroll + for (int i = 0; i < 4; ++i) { + const int isc = scale_offset + 2 * i; + const int isc_low = isc % 8; + const int sc_shift_low = 4 * (isc / 8); + const int sc_low = (scales[isc_low] >> sc_shift_low) & 0xf; + const int isc_high = isc % 4; + const int sc_shift_high = 2 * (isc / 4); + const int sc_high = ((scales[8 + isc_high] >> sc_shift_high) & 3) << 4; + const int sc = (sc_low | sc_high) - 32; + const int vil = (vl >> (2 * i)) & 0x03030303; + const int vih = ((vh >> i) << 2) & 0x04040404; + const int vi = __vsubss4(vil, vih); + sumf += d8[i] * (__dp4a(vi, u[i], 0) * sc); + } + return d3 * sumf; +} + +__device__ __forceinline__ float q3_q8_dot(const Q3KBlock* __restrict__ w, + const Q81Block* __restrict__ x, int iqs) { + const int bq8_offset = 4 * (iqs / 8); + const int scale_offset = iqs - iqs % 8 + (iqs % 8) / 4; + const float d = w->d; + const int vl = load_int_b2(w->qs, iqs); + const int vh = ~load_int_b2(w->hmask, iqs % 8) >> bq8_offset; + int u[4]; + float d8[4]; +#pragma unroll + for (int i = 0; i < 4; ++i) { + u[i] = reinterpret_cast(x[bq8_offset + i].qs)[iqs % 8]; + d8[i] = __low2float(x[bq8_offset + i].ds); + } + return q3_q8_dot_impl(vl, vh, u, w->scales, scale_offset, d, d8); +} + +// Q3_K generic MMVQ: QK=256, QI=16, VDR=1, eight blocks per iteration. +template +__launch_bounds__(WARPS * WARP, 1) +__global__ void native_q3_k_mmvq_kernel(const Q3KBlock* __restrict__ w, + const Q81Block* __restrict__ x, + float* __restrict__ y, int n_in, int n_out) { + constexpr int ROWS = SmallK ? WARPS : 1; + constexpr int BLOCKS_PER_ITER = WARPS * WARP / 16; + const int tid = WARP * int(threadIdx.y) + int(threadIdx.x); + const int row0 = ROWS * int(blockIdx.x); + const int blocks_per_row = n_in / 256; + float tmp[ROWS] = {}; + for (int kbx = tid / 16; kbx < blocks_per_row; kbx += BLOCKS_PER_ITER) { + const int kby = kbx * 8; + const int kqs = tid % 16; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { + if (row0 + i < n_out) { + const std::size_t block = std::size_t(row0 + i) * blocks_per_row + kbx; + tmp[i] += q3_q8_dot(w + block, x + kby, kqs); + } + } + } + __shared__ float partial[WARPS - 1][ROWS][WARP]; + if (threadIdx.y > 0) { +#pragma unroll + for (int i = 0; i < ROWS; ++i) partial[threadIdx.y - 1][i][threadIdx.x] = tmp[i]; + } + __syncthreads(); + if (threadIdx.y > 0) return; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { +#pragma unroll + for (int l = 0; l < WARPS - 1; ++l) tmp[i] += partial[l][i][threadIdx.x]; + tmp[i] = warp_sum(tmp[i]); + if (threadIdx.x == i && row0 + i < n_out) y[row0 + i] = tmp[i]; + } +} + +// The pinned nonlinear IQ4 codebook and its CUDA two-stage byte lookup. The +// explicit alignment satisfies the four 32-bit table loads; values are unchanged. +__device__ __align__(4) int8_t iq4nl_values[16] = { + -127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113 +}; + +__device__ __forceinline__ int2 iq4_table_lookup(int q4) { + const uint32_t* table32 = reinterpret_cast(iq4nl_values); + uint32_t tmp[2]; + const uint32_t low_high_selection_indices = 0x32103210 | ((q4 & 0x88888888) >> 1); +#pragma unroll + for (uint32_t i = 0; i < 2; ++i) { + const uint32_t shift = 16 * i; + const uint32_t low = __byte_perm(table32[0], table32[1], q4 >> shift); + const uint32_t high = __byte_perm(table32[2], table32[3], q4 >> shift); + tmp[i] = __byte_perm(low, high, low_high_selection_indices >> shift); + } + return make_int2(__byte_perm(tmp[0], tmp[1], 0x6420), __byte_perm(tmp[0], tmp[1], 0x7531)); +} + +// Exact pinned vec_dot_iq4_xs_q8_1: a lane consumes one 32-element subblock, +// computes integer dot products, applies signed scale in the integer domain, +// then multiplies the two half scales and integer sum in the original order. +__device__ __forceinline__ float iq4_xs_q8_dot(const IQ4XSBlock* __restrict__ w, + const Q81Block* __restrict__ x, int iqs) { + int sumi = 0; +#pragma unroll + for (int j = 0; j < 4; ++j) { + const int aux_q4 = reinterpret_cast(w->qs)[iqs + j]; + const int2 v = iq4_table_lookup(aux_q4); + const int u0 = reinterpret_cast(x[iqs / 4].qs)[j]; + const int u1 = reinterpret_cast(x[iqs / 4].qs)[j + 4]; + sumi = __dp4a(v.x, u0, sumi); + sumi = __dp4a(v.y, u1, sumi); + } + const int ls = ((w->scales_l[iqs / 8] >> (iqs & 0x04)) & 0x0f) | + (((w->scales_h >> (iqs / 2)) & 0x03) << 4); + sumi *= ls - 32; + const float d = __half2float(w->d) * __low2float(x[iqs / 4].ds); + return d * sumi; +} + +// IQ4_XS generic MMVQ: QK=256, QI=32, VDR=4,16 blocks per iteration. +template +__launch_bounds__(WARPS * WARP, 1) +__global__ void native_iq4_xs_mmvq_kernel(const IQ4XSBlock* __restrict__ w, + const Q81Block* __restrict__ x, + float* __restrict__ y, int n_in, int n_out) { + constexpr int ROWS = SmallK ? WARPS : 1; + constexpr int BLOCKS_PER_ITER = 4 * WARPS * WARP / 32; + const int tid = WARP * int(threadIdx.y) + int(threadIdx.x); + const int row0 = ROWS * int(blockIdx.x); + const int blocks_per_row = n_in / 256; + float tmp[ROWS] = {}; + for (int kbx = tid / 8; kbx < blocks_per_row; kbx += BLOCKS_PER_ITER) { + const int kby = kbx * 8; + const int kqs = 4 * (tid % 8); +#pragma unroll + for (int i = 0; i < ROWS; ++i) { + if (row0 + i < n_out) { + const std::size_t block = std::size_t(row0 + i) * blocks_per_row + kbx; + tmp[i] += iq4_xs_q8_dot(w + block, x + kby, kqs); + } + } + } + __shared__ float partial[WARPS - 1][ROWS][WARP]; + if (threadIdx.y > 0) { +#pragma unroll + for (int i = 0; i < ROWS; ++i) partial[threadIdx.y - 1][i][threadIdx.x] = tmp[i]; + } + __syncthreads(); + if (threadIdx.y > 0) return; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { +#pragma unroll + for (int l = 0; l < WARPS - 1; ++l) tmp[i] += partial[l][i][threadIdx.x]; + tmp[i] = warp_sum(tmp[i]); + if (threadIdx.x == i && row0 + i < n_out) y[row0 + i] = tmp[i]; + } +} + +// Exact pinned vec_dot_q4_K_q8_1_impl_vmmq expression and integer dot order. +__device__ __forceinline__ float q4_q8_dot_impl( + const int* __restrict__ v, const int* __restrict__ u, + const uint8_t* __restrict__ sc, const uint8_t* __restrict__ m, const half2& dm4, + const float* __restrict__ d8) { + float sumf_d = 0.0f; + float sumf_m = 0.0f; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const int v0i = (v[0] >> (4 * i)) & 0x0f0f0f0f; + const int v1i = (v[1] >> (4 * i)) & 0x0f0f0f0f; + const int dot1 = __dp4a(v1i, u[2 * i + 1], __dp4a(v0i, u[2 * i], 0)); + const int dot2 = __dp4a(0x01010101, u[2 * i + 1], __dp4a(0x01010101, u[2 * i], 0)); + sumf_d += d8[i] * (dot1 * sc[i]); + sumf_m += d8[i] * (dot2 * m[i]); + } + const float2 dm4f = __half22float2(dm4); + return dm4f.x * sumf_d - dm4f.y * sumf_m; +} + +__device__ __forceinline__ float q4_q8_dot(const Q4KBlock* __restrict__ bq4, + const Q81Block* __restrict__ bq8, int iqs) { + int v[2]; + int u[4]; + float d8[2]; + const int bq8_offset = 2 * ((iqs / 2) / 4); + const int* ql = reinterpret_cast(bq4->qs + 16 * bq8_offset + 4 * ((iqs / 2) % 4)); + v[0] = ql[0]; + v[1] = ql[4]; + + const uint16_t* scales = reinterpret_cast(bq4->scales); + const int j = bq8_offset / 2; + const int jm = j & 1; + const uint32_t s0 = scales[jm]; + const uint32_t s2 = scales[jm + 2]; + const uint32_t s4 = scales[jm + 4]; + const uint32_t hi = uint32_t(-int32_t(j >= 2)); + uint16_t aux[2]; + aux[0] = uint16_t(((s0 & 0x3f3f) & ~hi) | + ((((s4 >> 0) & 0x0f0f) | ((s0 & 0xc0c0) >> 2)) & hi)); + aux[1] = uint16_t(((s2 & 0x3f3f) & ~hi) | + ((((s4 >> 4) & 0x0f0f) | ((s2 & 0xc0c0) >> 2)) & hi)); + const uint8_t* sc = reinterpret_cast(aux); + const uint8_t* m = sc + 2; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const Q81Block* bq8i = bq8 + bq8_offset + i; + d8[i] = __low2float(bq8i->ds); + const int* q8 = reinterpret_cast(bq8i->qs) + ((iqs / 2) % 4); + u[2 * i] = q8[0]; + u[2 * i + 1] = q8[4]; + } + return q4_q8_dot_impl(v, u, sc, m, bq4->dm, d8); +} + +// The generic ncols=1 oracle uses 4 warps and 1 row (or 4 rows for small K), +// eight weight blocks per K iteration, warp-ascending shared sum, then XOR tree. +template +__launch_bounds__(WARPS * WARP, 1) +__global__ void native_q4_k_mmvq_kernel(const Q4KBlock* __restrict__ w, + const Q81Block* __restrict__ x, + float* __restrict__ y, int n_in, int n_out) { + constexpr int ROWS = SmallK ? WARPS : 1; + constexpr int BLOCKS_PER_ITER = VDR * WARPS * WARP / QI; + const int tid = WARP * int(threadIdx.y) + int(threadIdx.x); + const int row0 = ROWS * int(blockIdx.x); + const int blocks_per_row = n_in / QK; + float tmp[ROWS] = {}; + for (int kbx = tid / (QI / VDR); kbx < blocks_per_row; kbx += BLOCKS_PER_ITER) { + const int kby = kbx * (QK / Q8K); + const int kqs = VDR * (tid % (QI / VDR)); +#pragma unroll + for (int i = 0; i < ROWS; ++i) { + // The source assumes allocator padding for partial row groups. This + // guard preserves every valid row's math without an out-of-bounds read. + if (row0 + i < n_out) { + const std::size_t block = std::size_t(row0 + i) * blocks_per_row + kbx; + tmp[i] += q4_q8_dot(w + block, x + kby, kqs); + } + } + } + __shared__ float partial[WARPS - 1][ROWS][WARP]; + if (threadIdx.y > 0) { +#pragma unroll + for (int i = 0; i < ROWS; ++i) partial[threadIdx.y - 1][i][threadIdx.x] = tmp[i]; + } + __syncthreads(); + if (threadIdx.y > 0) return; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { +#pragma unroll + for (int l = 0; l < WARPS - 1; ++l) tmp[i] += partial[l][i][threadIdx.x]; + tmp[i] = warp_sum(tmp[i]); + if (threadIdx.x == i && row0 + i < n_out) y[row0 + i] = tmp[i]; + } +} + +// Exact pinned vec_dot_q6_K_q8_1: keep signed per-16-element scales, +// signed-byte subtraction, DP4A order, and the float accumulation sequence. +__device__ __forceinline__ float q6_q8_dot_impl(int vl, int vh, const int* __restrict__ u, + const int8_t* __restrict__ scales, + float d, const float* __restrict__ d8) { + float sumf = 0.0f; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const int sc = scales[4 * i]; + const int vil = (vl >> (4 * i)) & 0x0f0f0f0f; + const int vih = ((vh >> (4 * i)) << 4) & 0x30303030; + const int vi = __vsubss4(vil | vih, 0x20202020); + sumf += d8[i] * (__dp4a(vi, u[i], 0) * sc); + } + return d * sumf; +} + +__device__ __forceinline__ float q6_q8_dot(const Q6KBlock* __restrict__ w, + const Q81Block* __restrict__ x, int iqs) { + const int bq8_offset = 4 * (iqs / 16) + (iqs % 16) / 8; + const int scale_offset = 8 * (iqs / 16) + (iqs % 16) / 4; + const int vh_shift = 2 * ((iqs % 16) / 8); + const int vl = load_int_b2(w->ql, iqs); + const int vh = load_int_b2(w->qh, 8 * (iqs / 16) + iqs % 8) >> vh_shift; + int u[2]; + float d8[2]; +#pragma unroll + for (int i = 0; i < 2; ++i) { + u[i] = reinterpret_cast(x[bq8_offset + 2 * i].qs)[iqs % 8]; + d8[i] = __low2float(x[bq8_offset + 2 * i].ds); + } + return q6_q8_dot_impl(vl, vh, u, w->scales + scale_offset, w->d, d8); +} + +// Q6_K generic MMVQ: QK=256, QI=32, VDR=1, four blocks per iteration. +template +__launch_bounds__(WARPS * WARP, 1) +__global__ void native_q6_k_mmvq_kernel(const Q6KBlock* __restrict__ w, + const Q81Block* __restrict__ x, + float* __restrict__ y, int n_in, int n_out) { + constexpr int ROWS = SmallK ? WARPS : 1; + constexpr int BLOCKS_PER_ITER = WARPS * WARP / 32; + const int tid = WARP * int(threadIdx.y) + int(threadIdx.x); + const int row0 = ROWS * int(blockIdx.x); + const int blocks_per_row = n_in / 256; + float tmp[ROWS] = {}; + for (int kbx = tid / 32; kbx < blocks_per_row; kbx += BLOCKS_PER_ITER) { + const int kby = kbx * 8; + const int kqs = tid % 32; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { + if (row0 + i < n_out) { + const std::size_t block = std::size_t(row0 + i) * blocks_per_row + kbx; + tmp[i] += q6_q8_dot(w + block, x + kby, kqs); + } + } + } + __shared__ float partial[WARPS - 1][ROWS][WARP]; + if (threadIdx.y > 0) { +#pragma unroll + for (int i = 0; i < ROWS; ++i) partial[threadIdx.y - 1][i][threadIdx.x] = tmp[i]; + } + __syncthreads(); + if (threadIdx.y > 0) return; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { +#pragma unroll + for (int l = 0; l < WARPS - 1; ++l) tmp[i] += partial[l][i][threadIdx.x]; + tmp[i] = warp_sum(tmp[i]); + if (threadIdx.x == i && row0 + i < n_out) y[row0 + i] = tmp[i]; + } +} + +// The four 32-element formats use native two-byte loads and VDR=2. The affine +// Q4_0/Q5_0 correction consumes the original-input sum stored in Q8_1, exactly +// as the pinned CUDA dot does; a signed-integer code substitution would differ. +__device__ __forceinline__ float small_q8_dot(const Q40Block* __restrict__ w, + const Q81Block* __restrict__ x, int iqs) { + int sumi = 0; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const int v = load_int_b2(w->qs, iqs + i); + const int vi0 = (v >> 0) & 0x0f0f0f0f; + const int vi1 = (v >> 4) & 0x0f0f0f0f; + sumi = __dp4a(vi0, reinterpret_cast(x->qs)[iqs + i], sumi); + sumi = __dp4a(vi1, reinterpret_cast(x->qs)[iqs + i + 4], sumi); + } + const float2 ds = __half22float2(x->ds); + const float d = w->d; + return d * (sumi * ds.x - 4 * ds.y); +} + +__device__ __forceinline__ float small_q8_dot(const Q50Block* __restrict__ w, + const Q81Block* __restrict__ x, int iqs) { + int sumi = 0; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const int vl = load_int_b2(w->qs, iqs + i); + const int vh = load_int_b2(w->qh, 0) >> (4 * (iqs + i)); + int vi0 = (vl >> 0) & 0x0f0f0f0f; + vi0 |= (vh << 4) & 0x00000010; + vi0 |= (vh << 11) & 0x00001000; + vi0 |= (vh << 18) & 0x00100000; + vi0 |= (vh << 25) & 0x10000000; + sumi = __dp4a(vi0, reinterpret_cast(x->qs)[iqs + i], sumi); + int vi1 = (vl >> 4) & 0x0f0f0f0f; + vi1 |= (vh >> 12) & 0x00000010; + vi1 |= (vh >> 5) & 0x00001000; + vi1 |= (vh << 2) & 0x00100000; + vi1 |= (vh << 9) & 0x10000000; + sumi = __dp4a(vi1, reinterpret_cast(x->qs)[iqs + i + 4], sumi); + } + const float2 ds = __half22float2(x->ds); + const float d = w->d; + return d * (sumi * ds.x - 8 * ds.y); +} + +__device__ __forceinline__ float small_q8_dot(const Q80Block* __restrict__ w, + const Q81Block* __restrict__ x, int iqs) { + int sumi = 0; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const int v = load_int_b2(w->qs, iqs + i); + const int u = reinterpret_cast(x->qs)[iqs + i]; + sumi = __dp4a(v, u, sumi); + } + const float d0 = w->d; + const float d1 = __low2float(x->ds); + return d0 * d1 * float(sumi); +} + +__device__ __forceinline__ float small_q8_dot(const IQ4NLBlock* __restrict__ w, + const Q81Block* __restrict__ x, int iqs) { + const int* q8 = reinterpret_cast(x->qs) + iqs; + int sumi = 0; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const int2 v = iq4_table_lookup(load_int_b2(w->qs, iqs + i)); + sumi = __dp4a(v.x, q8[i], sumi); + sumi = __dp4a(v.y, q8[i + 4], sumi); + } + const float d = __half2float(w->d) * __low2float(x->ds); + return d * sumi; +} + +// QI=4 for Q4_0/Q5_0/IQ4_NL and QI=8 for Q8_0. With VDR=2 this preserves +// the pinned 64/32-block iteration and 2048/1024-element small-K thresholds. +template +__launch_bounds__(WARPS * WARP, 1) +__global__ void native_small_mmvq_kernel(const Weight* __restrict__ w, + const Q81Block* __restrict__ x, + float* __restrict__ y, int n_in, int n_out) { + constexpr int ROWS = SmallK ? WARPS : 1; + constexpr int BLOCKS_PER_ITER = 2 * WARPS * WARP / Qi; + const int tid = WARP * int(threadIdx.y) + int(threadIdx.x); + const int row0 = ROWS * int(blockIdx.x); + const int blocks_per_row = n_in / 32; + float tmp[ROWS] = {}; + for (int kbx = tid / (Qi / 2); kbx < blocks_per_row; kbx += BLOCKS_PER_ITER) { + const int kqs = 2 * (tid % (Qi / 2)); +#pragma unroll + for (int i = 0; i < ROWS; ++i) { + if (row0 + i < n_out) { + const std::size_t block = std::size_t(row0 + i) * blocks_per_row + kbx; + tmp[i] += small_q8_dot(w + block, x + kbx, kqs); + } + } + } + __shared__ float partial[WARPS - 1][ROWS][WARP]; + if (threadIdx.y > 0) { +#pragma unroll + for (int i = 0; i < ROWS; ++i) partial[threadIdx.y - 1][i][threadIdx.x] = tmp[i]; + } + __syncthreads(); + if (threadIdx.y > 0) return; +#pragma unroll + for (int i = 0; i < ROWS; ++i) { +#pragma unroll + for (int l = 0; l < WARPS - 1; ++l) tmp[i] += partial[l][i][threadIdx.x]; + tmp[i] = warp_sum(tmp[i]); + if (threadIdx.x == i && row0 + i < n_out) y[row0 + i] = tmp[i]; + } +} + +// ============================ plan v0.3 P3: ncols = 2..8 (speculative verify, small batches) ============================ +// +// One generic kernel for every format, parameterized by the format's iteration traits below, which are +// transcribed from the ncols = 1 kernels above (same thread-to-block mapping, same blocks per iteration, same +// small-K rule). Column j reads activation blocks x + j * (n_in / 32) and writes y + j * n_out. Each (column, +// row) value is accumulated over kbx in the same order, summed across warps in the same order and reduced with +// the same warp tree as the ncols = 1 kernel, so every column is BITWISE equal to a single-column call on that +// column (checked by bench/micro/native_mmvq_multi.cpp). The ncols = 1 kernels are untouched. +constexpr int MAX_NCOLS = 8; + +// Each format splits its dot product into `load` (everything that depends only on the weight block: codes, +// unpacked scales, block scale) and `apply` (the activation loads and the original *_impl expression). `load` runs +// once per (row, block) and `apply` once per column, so adding columns adds only activation work. `apply` calls +// the same impl functions, in the same order, with the same values as the ncols = 1 dot, which is what keeps +// every column bitwise equal to it. +struct Q5KTraits { + using Block = Q5KBlock; + static constexpr int DIV = QK, T = QI / VDR, KBY = QK / Q8K, BPI = VDR * WARPS * WARP / QI; + __device__ static int kqs(int tid) { return VDR * (tid % (QI / VDR)); } + struct W { int vl[2], vh[2]; uint16_t aux[2]; half2 dm; int bq8_offset; }; + __device__ static W load(const Block* __restrict__ bq5, int iqs) { + W r; + r.bq8_offset = 2 * ((iqs / 2) / 4); + const int* ql = reinterpret_cast(bq5->qs + 16 * r.bq8_offset + 4 * ((iqs / 2) % 4)); + const int* qh = reinterpret_cast(bq5->qh + 4 * ((iqs / 2) % 4)); + r.vl[0] = ql[0]; + r.vl[1] = ql[4]; + r.vh[0] = qh[0] >> r.bq8_offset; + r.vh[1] = qh[4] >> r.bq8_offset; + const uint16_t* scales = reinterpret_cast(bq5->scales); + const int j = r.bq8_offset / 2; + const int jm = j & 1; + const uint32_t s0 = scales[jm]; + const uint32_t s2 = scales[jm + 2]; + const uint32_t s4 = scales[jm + 4]; + const uint32_t hi = uint32_t(-int32_t(j >= 2)); + r.aux[0] = uint16_t(((s0 & 0x3f3f) & ~hi) | ((((s4 >> 0) & 0x0f0f) | ((s0 & 0xc0c0) >> 2)) & hi)); + r.aux[1] = uint16_t(((s2 & 0x3f3f) & ~hi) | ((((s4 >> 4) & 0x0f0f) | ((s2 & 0xc0c0) >> 2)) & hi)); + r.dm = bq5->dm; + return r; + } + __device__ static float apply(const W& r, const Q81Block* __restrict__ bq8, int iqs) { + int u[4]; + float d8[2]; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const Q81Block* bq8i = bq8 + r.bq8_offset + i; + d8[i] = __low2float(bq8i->ds); + const int* q8 = reinterpret_cast(bq8i->qs) + ((iqs / 2) % 4); + u[2 * i] = q8[0]; + u[2 * i + 1] = q8[4]; + } + const uint8_t* sc = reinterpret_cast(r.aux); + return q5_q8_dot_impl(r.vl, r.vh, u, sc, sc + 2, r.dm, d8); + } +}; +struct Q4KTraits { + using Block = Q4KBlock; + static constexpr int DIV = QK, T = QI / VDR, KBY = QK / Q8K, BPI = VDR * WARPS * WARP / QI; + __device__ static int kqs(int tid) { return VDR * (tid % (QI / VDR)); } + struct W { int v[2]; uint16_t aux[2]; half2 dm; int bq8_offset; }; + __device__ static W load(const Block* __restrict__ bq4, int iqs) { + W r; + r.bq8_offset = 2 * ((iqs / 2) / 4); + const int* ql = reinterpret_cast(bq4->qs + 16 * r.bq8_offset + 4 * ((iqs / 2) % 4)); + r.v[0] = ql[0]; + r.v[1] = ql[4]; + const uint16_t* scales = reinterpret_cast(bq4->scales); + const int j = r.bq8_offset / 2; + const int jm = j & 1; + const uint32_t s0 = scales[jm]; + const uint32_t s2 = scales[jm + 2]; + const uint32_t s4 = scales[jm + 4]; + const uint32_t hi = uint32_t(-int32_t(j >= 2)); + r.aux[0] = uint16_t(((s0 & 0x3f3f) & ~hi) | ((((s4 >> 0) & 0x0f0f) | ((s0 & 0xc0c0) >> 2)) & hi)); + r.aux[1] = uint16_t(((s2 & 0x3f3f) & ~hi) | ((((s4 >> 4) & 0x0f0f) | ((s2 & 0xc0c0) >> 2)) & hi)); + r.dm = bq4->dm; + return r; + } + __device__ static float apply(const W& r, const Q81Block* __restrict__ bq8, int iqs) { + int u[4]; + float d8[2]; +#pragma unroll + for (int i = 0; i < 2; ++i) { + const Q81Block* bq8i = bq8 + r.bq8_offset + i; + d8[i] = __low2float(bq8i->ds); + const int* q8 = reinterpret_cast(bq8i->qs) + ((iqs / 2) % 4); + u[2 * i] = q8[0]; + u[2 * i + 1] = q8[4]; + } + const uint8_t* sc = reinterpret_cast(r.aux); + return q4_q8_dot_impl(r.v, u, sc, sc + 2, r.dm, d8); + } +}; +struct Q20Traits { + using Block = Q20Block; + static constexpr int DIV = 64, T = 2, KBY = 2, BPI = WARPS * WARP / 2; + __device__ static int kqs(int tid) { return tid % 2; } + struct W { int qx[4], qy[4]; float d2; }; + __device__ static W load(const Block* __restrict__ w, int iqs) { + W r; + r.d2 = w->d; + const int16_t* qs = reinterpret_cast(w->qs) + iqs * 4; +#pragma unroll + for (int j = 0; j < 4; ++j) { + const int q = qs[j]; + const int qe = __byte_perm(0x020100ff, 0x020100ff, q >> 0); + const int qo = __byte_perm(0x020100ff, 0x020100ff, q >> 2); + r.qx[j] = __byte_perm(qe, qo, 0x5140); + r.qy[j] = __byte_perm(qe, qo, 0x7362); + } + return r; + } + __device__ static float apply(const W& r, const Q81Block* __restrict__ x, int iqs) { + const Q81Block* chunk = x + iqs; + const int* q8 = reinterpret_cast(chunk->qs); + int sumi = 0; +#pragma unroll + for (int j = 0; j < 4; ++j) { + sumi = __dp4a(q8[j * 2], r.qx[j], sumi); + sumi = __dp4a(q8[j * 2 + 1], r.qy[j], sumi); + } + const float d8 = __low2float(chunk->ds); + return r.d2 * d8 * sumi; + } +}; +struct Q3KTraits { + using Block = Q3KBlock; + static constexpr int DIV = 256, T = 16, KBY = 8, BPI = WARPS * WARP / 16; + __device__ static int kqs(int tid) { return tid % 16; } + struct W { int vl, vh; float d; const uint8_t* scales; int scale_offset, bq8_offset; }; + __device__ static W load(const Block* __restrict__ w, int iqs) { + W r; + r.bq8_offset = 4 * (iqs / 8); + r.scale_offset = iqs - iqs % 8 + (iqs % 8) / 4; + r.d = w->d; + r.vl = load_int_b2(w->qs, iqs); + r.vh = ~load_int_b2(w->hmask, iqs % 8) >> r.bq8_offset; + r.scales = w->scales; + return r; + } + __device__ static float apply(const W& r, const Q81Block* __restrict__ x, int iqs) { + int u[4]; + float d8[4]; +#pragma unroll + for (int i = 0; i < 4; ++i) { + u[i] = reinterpret_cast(x[r.bq8_offset + i].qs)[iqs % 8]; + d8[i] = __low2float(x[r.bq8_offset + i].ds); + } + return q3_q8_dot_impl(r.vl, r.vh, u, r.scales, r.scale_offset, r.d, d8); + } +}; +struct Q6KTraits { + using Block = Q6KBlock; + static constexpr int DIV = 256, T = 32, KBY = 8, BPI = WARPS * WARP / 32; + __device__ static int kqs(int tid) { return tid % 32; } + struct W { int vl, vh; float d; const int8_t* scales; int bq8_offset; }; + __device__ static W load(const Block* __restrict__ w, int iqs) { + W r; + r.bq8_offset = 4 * (iqs / 16) + (iqs % 16) / 8; + const int scale_offset = 8 * (iqs / 16) + (iqs % 16) / 4; + const int vh_shift = 2 * ((iqs % 16) / 8); + r.vl = load_int_b2(w->ql, iqs); + r.vh = load_int_b2(w->qh, 8 * (iqs / 16) + iqs % 8) >> vh_shift; + r.scales = w->scales + scale_offset; + r.d = w->d; + return r; + } + __device__ static float apply(const W& r, const Q81Block* __restrict__ x, int iqs) { + int u[2]; + float d8[2]; +#pragma unroll + for (int i = 0; i < 2; ++i) { + u[i] = reinterpret_cast(x[r.bq8_offset + 2 * i].qs)[iqs % 8]; + d8[i] = __low2float(x[r.bq8_offset + 2 * i].ds); + } + return q6_q8_dot_impl(r.vl, r.vh, u, r.scales, r.d, d8); + } +}; +struct IQ4XSTraits { + using Block = IQ4XSBlock; + static constexpr int DIV = 256, T = 8, KBY = 8, BPI = 4 * WARPS * WARP / 32; + __device__ static int kqs(int tid) { return 4 * (tid % 8); } + struct W { int2 v[4]; int ls; float dw; }; + __device__ static W load(const Block* __restrict__ w, int iqs) { + W r; +#pragma unroll + for (int j = 0; j < 4; ++j) r.v[j] = iq4_table_lookup(reinterpret_cast(w->qs)[iqs + j]); + r.ls = ((w->scales_l[iqs / 8] >> (iqs & 0x04)) & 0x0f) | (((w->scales_h >> (iqs / 2)) & 0x03) << 4); + r.dw = __half2float(w->d); + return r; + } + __device__ static float apply(const W& r, const Q81Block* __restrict__ x, int iqs) { + int sumi = 0; +#pragma unroll + for (int j = 0; j < 4; ++j) { + const int u0 = reinterpret_cast(x[iqs / 4].qs)[j]; + const int u1 = reinterpret_cast(x[iqs / 4].qs)[j + 4]; + sumi = __dp4a(r.v[j].x, u0, sumi); + sumi = __dp4a(r.v[j].y, u1, sumi); + } + sumi *= r.ls - 32; + const float d = r.dw * __low2float(x[iqs / 4].ds); + return d * sumi; + } +}; +// The four 32-element formats: `load` keeps the block pointer (their decode is a few integer ops) and `apply` is +// the unchanged small_q8_dot. Their per-column cost is small; the win above is for the K and IQ formats. +template +struct SmallTraits { + using Block = Weight; + static constexpr int DIV = 32, T = Qi / 2, KBY = 1, BPI = 2 * WARPS * WARP / Qi; + __device__ static int kqs(int tid) { return 2 * (tid % (Qi / 2)); } + struct W { const Weight* w; }; + __device__ static W load(const Block* __restrict__ w, int) { return W{w}; } + __device__ static float apply(const W& r, const Q81Block* __restrict__ x, int k) { return small_q8_dot(r.w, x, k); } +}; + +// NW warps per block and ROWS rows per block. The EXACT layout (NW = 4, ROWS = 1, or 4 for small K) is the +// ncols = 1 layout and keeps every column bitwise equal to a single-column call. The UPSTREAM layout is +// llama.cpp's generic multi-column table (ncols 2-4: 4 warps; 5-8: 2 warps; always 2 rows per block): faster, +// equal to ncols = 1 only to float rounding (the cross-warp reduction groups partial sums differently). +bool g_multi_exact = true; // until the upstream layout is timed on an idle GPU (plan rule: default only what is measured) + +template +__launch_bounds__(NW * WARP, 1) +__global__ void native_mmvq_multi_kernel(const typename F::Block* __restrict__ w, + const Q81Block* __restrict__ x, + float* __restrict__ y, int n_in, int n_out) { + constexpr int BPI = F::BPI * NW / WARPS; // blocks per iteration scale with the warp count + const int tid = WARP * int(threadIdx.y) + int(threadIdx.x); + const int row0 = ROWS * int(blockIdx.x); + const int blocks_per_row = n_in / F::DIV; + const int x_stride = n_in / Q8K; // Q8_1 blocks per activation column + float tmp[NCOLS][ROWS] = {}; + for (int kbx = tid / F::T; kbx < blocks_per_row; kbx += BPI) { + const int kby = kbx * F::KBY; + const int kqs = F::kqs(tid); +#pragma unroll + for (int i = 0; i < ROWS; ++i) { + if (row0 + i < n_out) { + const std::size_t block = std::size_t(row0 + i) * blocks_per_row + kbx; + const typename F::W wv = F::load(w + block, kqs); // once per (row, block) +#pragma unroll + for (int j = 0; j < NCOLS; ++j) // then per column + tmp[j][i] += F::apply(wv, x + std::size_t(j) * x_stride + kby, kqs); + } + } + } + __shared__ float partial[NW - 1 > 0 ? NW - 1 : 1][NCOLS][ROWS][WARP]; + if (threadIdx.y > 0) { +#pragma unroll + for (int j = 0; j < NCOLS; ++j) +#pragma unroll + for (int i = 0; i < ROWS; ++i) partial[threadIdx.y - 1][j][i][threadIdx.x] = tmp[j][i]; + } + __syncthreads(); + if (threadIdx.y > 0) return; +#pragma unroll + for (int j = 0; j < NCOLS; ++j) { +#pragma unroll + for (int i = 0; i < ROWS; ++i) { +#pragma unroll + for (int l = 0; l < NW - 1; ++l) tmp[j][i] += partial[l][j][i][threadIdx.x]; + tmp[j][i] = warp_sum(tmp[j][i]); + if (threadIdx.x == i && row0 + i < n_out) y[std::size_t(j) * n_out + row0 + i] = tmp[j][i]; + } + } +} + +template +void launch_multi_n(const void* weights, const void* x_q8_1, float* y, int n_in, int n_out, cudaStream_t s) { + const auto* w = static_cast(weights); + const auto* x = static_cast(x_q8_1); + if (!g_multi_exact) { + constexpr int NW = NCOLS <= 4 ? 4 : 2; + const unsigned blocks = unsigned((std::size_t(n_out) + 1) / 2); + native_mmvq_multi_kernel<<>>(w, x, y, n_in, n_out); + return; + } + const dim3 threads(WARP, WARPS); + if (n_in / F::DIV < F::BPI) { + const unsigned blocks = unsigned((std::size_t(n_out) + WARPS - 1) / WARPS); + native_mmvq_multi_kernel<<>>(w, x, y, n_in, n_out); + } else { + native_mmvq_multi_kernel<<>>(w, x, y, n_in, n_out); + } +} + +template +void launch_multi(const void* weights, const void* x_q8_1, float* y, int n_in, int n_out, int ncols, + void* stream) { + const auto s = static_cast(stream); + switch (ncols) { + case 2: launch_multi_n(weights, x_q8_1, y, n_in, n_out, s); break; + case 3: launch_multi_n(weights, x_q8_1, y, n_in, n_out, s); break; + case 4: launch_multi_n(weights, x_q8_1, y, n_in, n_out, s); break; + case 5: launch_multi_n(weights, x_q8_1, y, n_in, n_out, s); break; + case 6: launch_multi_n(weights, x_q8_1, y, n_in, n_out, s); break; + case 7: launch_multi_n(weights, x_q8_1, y, n_in, n_out, s); break; + case 8: launch_multi_n(weights, x_q8_1, y, n_in, n_out, s); break; + default: throw std::invalid_argument("native MMVQ multi-column launch requires 2 <= ncols <= 8"); + } +} + +void validate_shape(int n_in, int ncols, int block_elems = Q8K) { + if (n_in <= 0 || n_in % block_elems != 0) { + throw std::invalid_argument("native MMVQ requires n_in > 0 and divisible by its block element count"); + } + if (ncols < 1 || ncols > MAX_NCOLS) throw std::invalid_argument("native MMVQ requires 1 <= ncols <= 8"); +} +void validate_pointer(const void* p) { + if (!p || reinterpret_cast(p) % 4 != 0) { + throw std::invalid_argument("native MMVQ requires non-null 4-byte aligned device pointers"); + } +} +void validate_stream(void* stream) { + if (!stream) throw std::invalid_argument("native MMVQ requires an explicit non-null CUDA stream"); +} +void launch_check() { + const auto error = cudaGetLastError(); + if (error != cudaSuccess) { + throw std::runtime_error(std::string("native MMVQ launch: ") + cudaGetErrorString(error)); + } +} + +template +void small_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 32); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x_q8_1); + validate_pointer(y); + validate_stream(stream); + if (ncols > 1) { + launch_multi>(weights, x_q8_1, y, n_in, n_out, ncols, stream); + launch_check(); + return; + } + const auto* w = static_cast(weights); + const auto* x = static_cast(x_q8_1); + const auto s = static_cast(stream); + const dim3 threads(WARP, WARPS); + if (n_in / 32 < 2 * WARPS * WARP / Qi) { + const unsigned blocks = unsigned((std::size_t(n_out) + WARPS - 1) / WARPS); + native_small_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } else { + native_small_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } + launch_check(); +} + +template +void small_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 32); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x); + validate_pointer(scratch_q8_1); + validate_pointer(y); + validate_stream(stream); + native_quantize_q8_1(x, scratch_q8_1, n_in, ncols, stream); + small_mmvq(weights, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +} // namespace + +void native_mmvq_set_multi_exact(bool exact) { g_multi_exact = exact; } +bool native_mmvq_multi_exact() { return g_multi_exact; } + +std::size_t native_q8_1_bytes(int n_in, int ncols) { + validate_shape(n_in, ncols); + return std::size_t(ncols) * std::size_t(n_in / Q8K) * sizeof(Q81Block); +} + +void native_quantize_q8_1(const float* x, void* x_q8_1, int n_in, int ncols, void* stream) { + validate_shape(n_in, ncols); + validate_pointer(x); + validate_pointer(x_q8_1); + validate_stream(stream); + // Columns are contiguous and n_in is a multiple of 32, so ncols columns quantize as one vector of + // ncols * n_in elements: every 32-element block stays inside one column. + const int n_total = n_in * ncols; + const unsigned blocks = unsigned((std::size_t(n_total) + QUANT_THREADS - 1) / QUANT_THREADS); + native_quantize_q8_1_kernel<<(stream)>>>(x, static_cast(x_q8_1), n_total); + launch_check(); +} + +void native_q5_k_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, QK); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x_q8_1); + validate_pointer(y); + validate_stream(stream); + if (ncols > 1) { + launch_multi(weights, x_q8_1, y, n_in, n_out, ncols, stream); + launch_check(); + return; + } + const auto* w = static_cast(weights); + const auto* x = static_cast(x_q8_1); + const auto s = static_cast(stream); + const dim3 threads(WARP, WARPS); + if (n_in / QK < VDR * WARPS * WARP / QI) { + const unsigned blocks = unsigned((std::size_t(n_out) + WARPS - 1) / WARPS); + native_q5_k_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } else { + native_q5_k_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } + launch_check(); +} + +void native_q5_k_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + // Validate all outputs before enqueueing the first operation. + validate_shape(n_in, ncols, QK); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x); + validate_pointer(scratch_q8_1); + validate_pointer(y); + validate_stream(stream); + native_quantize_q8_1(x, scratch_q8_1, n_in, ncols, stream); + native_q5_k_mmvq(weights, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q2_0_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 64); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x_q8_1); + validate_pointer(y); + validate_stream(stream); + if (ncols > 1) { + launch_multi(weights, x_q8_1, y, n_in, n_out, ncols, stream); + launch_check(); + return; + } + const auto* w = static_cast(weights); + const auto* x = static_cast(x_q8_1); + const auto s = static_cast(stream); + const dim3 threads(WARP, WARPS); + if (n_in / 64 < WARPS * WARP / 2) { + const unsigned blocks = unsigned((std::size_t(n_out) + WARPS - 1) / WARPS); + native_q2_0_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } else { + native_q2_0_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } + launch_check(); +} + +void native_q2_0_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 64); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x); + validate_pointer(scratch_q8_1); + validate_pointer(y); + validate_stream(stream); + native_quantize_q8_1(x, scratch_q8_1, n_in, ncols, stream); + native_q2_0_mmvq(weights, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q3_k_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 256); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x_q8_1); + validate_pointer(y); + validate_stream(stream); + if (ncols > 1) { + launch_multi(weights, x_q8_1, y, n_in, n_out, ncols, stream); + launch_check(); + return; + } + const auto* w = static_cast(weights); + const auto* x = static_cast(x_q8_1); + const auto s = static_cast(stream); + const dim3 threads(WARP, WARPS); + if (n_in / 256 < WARPS * WARP / 16) { + const unsigned blocks = unsigned((std::size_t(n_out) + WARPS - 1) / WARPS); + native_q3_k_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } else { + native_q3_k_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } + launch_check(); +} + +void native_q3_k_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 256); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x); + validate_pointer(scratch_q8_1); + validate_pointer(y); + validate_stream(stream); + native_quantize_q8_1(x, scratch_q8_1, n_in, ncols, stream); + native_q3_k_mmvq(weights, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_iq4_xs_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 256); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x_q8_1); + validate_pointer(y); + validate_stream(stream); + if (ncols > 1) { + launch_multi(weights, x_q8_1, y, n_in, n_out, ncols, stream); + launch_check(); + return; + } + const auto* w = static_cast(weights); + const auto* x = static_cast(x_q8_1); + const auto s = static_cast(stream); + const dim3 threads(WARP, WARPS); + if (n_in / 256 < 4 * WARPS * WARP / 32) { + const unsigned blocks = unsigned((std::size_t(n_out) + WARPS - 1) / WARPS); + native_iq4_xs_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } else { + native_iq4_xs_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } + launch_check(); +} + +void native_iq4_xs_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 256); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x); + validate_pointer(scratch_q8_1); + validate_pointer(y); + validate_stream(stream); + native_quantize_q8_1(x, scratch_q8_1, n_in, ncols, stream); + native_iq4_xs_mmvq(weights, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q4_k_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 256); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x_q8_1); + validate_pointer(y); + validate_stream(stream); + if (ncols > 1) { + launch_multi(weights, x_q8_1, y, n_in, n_out, ncols, stream); + launch_check(); + return; + } + const auto* w = static_cast(weights); + const auto* x = static_cast(x_q8_1); + const auto s = static_cast(stream); + const dim3 threads(WARP, WARPS); + if (n_in / 256 < WARPS * WARP / 16) { + const unsigned blocks = unsigned((std::size_t(n_out) + WARPS - 1) / WARPS); + native_q4_k_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } else { + native_q4_k_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } + launch_check(); +} + +void native_q4_k_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 256); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x); + validate_pointer(scratch_q8_1); + validate_pointer(y); + validate_stream(stream); + native_quantize_q8_1(x, scratch_q8_1, n_in, ncols, stream); + native_q4_k_mmvq(weights, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q6_k_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 256); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x_q8_1); + validate_pointer(y); + validate_stream(stream); + if (ncols > 1) { + launch_multi(weights, x_q8_1, y, n_in, n_out, ncols, stream); + launch_check(); + return; + } + const auto* w = static_cast(weights); + const auto* x = static_cast(x_q8_1); + const auto s = static_cast(stream); + const dim3 threads(WARP, WARPS); + if (n_in / 256 < WARPS * WARP / 32) { + const unsigned blocks = unsigned((std::size_t(n_out) + WARPS - 1) / WARPS); + native_q6_k_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } else { + native_q6_k_mmvq_kernel<<>>(w, x, y, n_in, n_out); + } + launch_check(); +} + +void native_q6_k_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + validate_shape(n_in, ncols, 256); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + validate_pointer(weights); + validate_pointer(x); + validate_pointer(scratch_q8_1); + validate_pointer(y); + validate_stream(stream); + native_quantize_q8_1(x, scratch_q8_1, n_in, ncols, stream); + native_q6_k_mmvq(weights, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q4_0_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + small_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q4_0_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + small_f32(weights, x, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q5_0_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + small_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q5_0_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + small_f32(weights, x, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q8_0_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + small_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_q8_0_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + small_f32(weights, x, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_iq4_nl_mmvq(const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + small_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); +} + +void native_iq4_nl_f32(const void* weights, const float* x, void* scratch_q8_1, + float* y, int n_in, int n_out, int ncols, void* stream) { + small_f32(weights, x, scratch_q8_1, y, n_in, n_out, ncols, stream); +} + +bool native_mmvq_supported(int ggml_type) noexcept { + return ggml_type == 2 || ggml_type == 6 || ggml_type == 8 || ggml_type == 11 || + ggml_type == 12 || ggml_type == 13 || ggml_type == 14 || ggml_type == 20 || + ggml_type == 23 || ggml_type == 42 || ggml_type == 16 || ggml_type == 17 || ggml_type == 18 || + ggml_type == 21 || ggml_type == 22 || ggml_type == 29; +} + +std::size_t native_mmvq_weight_bytes(int ggml_type, int n_in, int n_out) { + int block_elems, block_bytes; + switch (ggml_type) { + case 2: block_elems = 32; block_bytes = 18; break; + case 6: block_elems = 32; block_bytes = 22; break; + case 8: block_elems = 32; block_bytes = 34; break; + case 20: block_elems = 32; block_bytes = 18; break; + case 11: block_elems = 256; block_bytes = 110; break; + case 12: block_elems = 256; block_bytes = 144; break; + case 13: block_elems = 256; block_bytes = 176; break; + case 14: block_elems = 256; block_bytes = 210; break; + case 23: block_elems = 256; block_bytes = 136; break; + case 42: block_elems = 64; block_bytes = 18; break; + case 16: case 17: case 18: case 21: case 22: case 29: + block_elems = 256; block_bytes = (int) iq_row_bytes(ggml_type, 256); break; + default: throw std::invalid_argument("unsupported native MMVQ GGML type"); + } + validate_shape(n_in, 1, block_elems); + if (n_out <= 0) throw std::invalid_argument("native MMVQ requires n_out > 0"); + const std::size_t row_bytes = std::size_t(n_in / block_elems) * block_bytes; + if (row_bytes > std::numeric_limits::max() / std::size_t(n_out)) { + throw std::length_error("native MMVQ weight byte count overflows size_t"); + } + return row_bytes * std::size_t(n_out); +} + +void native_mmvq(int ggml_type, const void* weights, const void* x_q8_1, float* y, + int n_in, int n_out, int ncols, void* stream) { + switch (ggml_type) { + case 2: native_q4_0_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 6: native_q5_0_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 8: native_q8_0_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 20: native_iq4_nl_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 11: native_q3_k_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 12: native_q4_k_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 13: native_q5_k_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 14: native_q6_k_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 23: native_iq4_xs_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 42: native_q2_0_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + case 16: case 17: case 18: case 21: case 22: case 29: + iq_mmvq(ggml_type, weights, x_q8_1, y, n_in, n_out, ncols, stream); break; + default: throw std::invalid_argument("unsupported native MMVQ GGML type"); + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_moe.cu b/src/kernels/cuda/native_moe.cu new file mode 100644 index 00000000..42b57ca0 --- /dev/null +++ b/src/kernels/cuda/native_moe.cu @@ -0,0 +1,74 @@ +// Arithmetic adapted from the MIT-licensed pinned ggml CUDA +// moe-weighted-reduction.cu at 3cf03257f219afbe7334045ff7c6a06ac68c627d. +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +#include "strata/kernels/native_moe.hpp" +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +std::atomic enabled{false}; +__global__ void combine(const float* __restrict__ parts, const float* __restrict__ weights, + const float* __restrict__ shared, float* __restrict__ output, + int64_t n_embd, int k) { + const int64_t col = int64_t(blockIdx.x) * blockDim.x + threadIdx.x; + if (col >= n_embd) return; + float sum = parts[col] * weights[0]; + for (int expert = 1; expert < k; ++expert) { + sum += parts[int64_t(expert) * n_embd + col] * weights[expert]; + } + if (shared) sum += shared[col]; + output[col] = sum; +} +bool valid_span(const void* p, size_t bytes) { + const auto address = reinterpret_cast(p); + return p && address % alignof(float) == 0 && bytes <= UINTPTR_MAX - address; +} +bool overlap(const void* a, size_t an, const void* b, size_t bn) { + const auto ap = reinterpret_cast(a), bp = reinterpret_cast(b); + return ap < bp + bn && bp < ap + an; +} +} +void native_moe_combine_set_enabled(bool value) { enabled.store(value, std::memory_order_relaxed); } +bool native_moe_combine_enabled() { return enabled.load(std::memory_order_relaxed); } +void native_moe_combine(const float* parts, const float* weights, const float* shared, + float* output, int64_t n_embd, int64_t k, void* stream) { + if (!stream || n_embd <= 0 || n_embd > std::numeric_limits::max() || k < 1 || k > 15) + throw std::invalid_argument("native MoE combine requires a stream, positive width and 1..15 experts"); + const size_t row_bytes = size_t(n_embd) * sizeof(float); + const size_t part_bytes = row_bytes * size_t(k), weight_bytes = size_t(k) * sizeof(float); + if (!valid_span(parts, part_bytes) || !valid_span(weights, weight_bytes) || !valid_span(output, row_bytes) + || (shared && !valid_span(shared, row_bytes)) + || overlap(output, row_bytes, parts, part_bytes) + || overlap(output, row_bytes, weights, weight_bytes) + || (shared && overlap(output, row_bytes, shared, row_bytes))) + throw std::invalid_argument("native MoE combine requires aligned spans and disjoint output"); + combine<<(stream)>>>( + parts, weights, shared, output, n_embd, int(k)); + const auto error = cudaGetLastError(); + if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error)); +} +} diff --git a/src/kernels/cuda/native_ple_postops.cu b/src/kernels/cuda/native_ple_postops.cu new file mode 100644 index 00000000..f8d2df32 --- /dev/null +++ b/src/kernels/cuda/native_ple_postops.cu @@ -0,0 +1,134 @@ +// Arithmetic adapted from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d: +// src/models/qwen4exp.cpp and ggml-cuda/{reduce_rows.cuh,sumrows.cu,unary.cu}. +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "strata/kernels/native_ple_postops.hpp" +#include "strata/kernels/native_gr_norm.hpp" +#include "strata/kernels/ngram.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +constexpr int N = 2560, H = 4, D = N * H, HISTORY = 9; +__device__ float warp_sum(float x) { + for (int offset = 16; offset; offset >>= 1) x += __shfl_xor_sync(0xffffffffu, x, offset); + return x; +} +__global__ void gate_kernel(const float* key, const float* query, float* gate, float scale) { + // SUM_ROWS selects 512 threads for four rows on the target GPU. Preserve + // its eight partial lanes and materialized MUL rounding (never a dot FMA). + float sums[8] = {}; +#pragma unroll + for (int j = 0; j < 8; ++j) { + const int d = threadIdx.x + j * 512; + const float p = d < N ? __fmul_rn(key[blockIdx.x * N + d], query[blockIdx.x * N + d]) : 0.0f; + sums[j] += p; + } + float sum = 0; +#pragma unroll + for (int j = 0; j < 8; ++j) sum += sums[j]; + __shared__ float partials[32]; + sum = warp_sum(sum); + const int lane = threadIdx.x % 32; + if (!lane) partials[threadIdx.x / 32] = sum; + __syncthreads(); + sum = lane < 16 ? partials[lane] : 0.0f; + sum = warp_sum(sum); + if (threadIdx.x == 0) { + const float s = __fmaf_rn(scale, sum, 0.0f); // ggml SCALE's zero bias + const float mag = sqrtf(fmaxf(fabsf(s), 1e-6f)); + const float sign = float((s > 0.0f) - (s < 0.0f)); + gate[blockIdx.x] = 1.0f / (1.0f + expf(-__fmul_rn(sign, mag))); + } +} +__global__ void broadcast_kernel(const float* value, const float* gate, float* gated) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i < D) gated[i] = __fmul_rn(value[i % N], gate[i / N]); +} +__global__ void conv_residual_kernel(const float* history, const float* normalized, + const uint16_t* weights, const float* hidden, + const float* gated, float* conv, float* result) { + const int c = blockIdx.x * blockDim.x + threadIdx.x; + if (c >= D) return; + float sum = 0; +#pragma unroll + for (int k = 0; k < 4; ++k) { + const float x = k == 3 ? normalized[c] : history[c * HISTORY + 3 * k]; + const float w = __half2float(__ushort_as_half(weights[c * 4 + k])); + const float term = __fmul_rn(x, w); + sum = k == 0 ? term : __fadd_rn(sum, term); + } + const float activation = sum / (1.0f + expf(-sum)); + conv[c] = activation; + // Exact hidden/result alias is safe: each thread owns one element. + result[c] = __fadd_rn(hidden[c], __fadd_rn(gated[c], activation)); +} +struct Span { const void* p; size_t bytes; size_t alignment; }; +bool overlaps(Span a, Span b) { + const auto x = reinterpret_cast(a.p), y = reinterpret_cast(b.p); + return x < y + b.bytes && y < x + a.bytes; +} +void validate(Span span) { + const auto p = reinterpret_cast(span.p); + if (!p || p % span.alignment || p > std::numeric_limits::max() - span.bytes) + throw std::invalid_argument("native PLE postops require nonnull aligned bounded spans"); +} +void launch_check() { + const auto error = cudaGetLastError(); + if (error != cudaSuccess) throw std::runtime_error(std::string("native PLE postops launch: ") + cudaGetErrorString(error)); +} +} // namespace + +void native_ple_postops(const float* projected_key, const float* hidden, + const float* value, const float* history, + const PleWeights& w, const NativePlePostopsBuffers& b, void* stream) { + if (!stream) throw std::invalid_argument("native PLE postops require an explicit stream"); + const Span inputs[] = {{projected_key,D*4,4}, {hidden,D*4,4}, {value,N*4,4}, + {history,HISTORY*D*4,4}, {w.norm_key,D*4,4}, {w.norm_query,D*4,4}, + {w.norm_conv,D*4,4}, {w.conv1d_f16,4*D*2,2}}; + const Span outputs[] = {{b.key,D*4,4}, {b.query,D*4,4}, {b.gate,H*4,4}, + {b.gated,D*4,4}, {b.normalized,D*4,4}, {b.conv,D*4,4}, {b.result,D*4,4}}; + for (const auto& span : inputs) validate(span); + for (const auto& span : outputs) validate(span); + for (size_t i = 0; i < 7; ++i) { + for (size_t j = 0; j < 8; ++j) + if (!(i == 6 && j == 1 && b.result == hidden) && overlaps(outputs[i], inputs[j])) + throw std::invalid_argument("native PLE postops output overlaps an input or weight"); + for (size_t j = i + 1; j < 7; ++j) + if (!(i == 1 && j == 4 && b.query == b.normalized) && overlaps(outputs[i], outputs[j])) + throw std::invalid_argument("native PLE postops writable spans overlap"); + } + native_gr_rms_norm_weighted(projected_key,w.norm_key,b.key,N,H,NG_RMS_EPS,stream); + native_gr_rms_norm_weighted(hidden,w.norm_query,b.query,N,H,NG_RMS_EPS,stream); + auto st = static_cast(stream); + gate_kernel<<>>(b.key,b.query,b.gate,1.0f / std::sqrt(float(N))); + broadcast_kernel<<>>(value,b.gate,b.gated); + launch_check(); + native_gr_rms_norm_weighted(b.gated,w.norm_conv,b.normalized,N,H,NG_RMS_EPS,stream); + conv_residual_kernel<<>>(history,b.normalized,w.conv1d_f16,hidden,b.gated,b.conv,b.result); + launch_check(); +} +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_qsa.cu b/src/kernels/cuda/native_qsa.cu new file mode 100644 index 00000000..32e2511c --- /dev/null +++ b/src/kernels/cuda/native_qsa.cu @@ -0,0 +1,127 @@ +// Adapted from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d: +// ggml/src/ggml-cuda/{norm.cu,common.cuh,unary.cu}. +// +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "strata/kernels/native_qsa.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +std::atomic enabled{false}; +__device__ __forceinline__ float warp_sum(float value) { +#pragma unroll + for (int offset = 16; offset; offset >>= 1) + value += __shfl_xor_sync(0xffffffffu, value, offset, 32); + return value; +} +template +__global__ void norm(const float* input, const float* __restrict__ gamma, float* output, + int n_cols, float epsilon) { + const int tid = threadIdx.x; + const std::size_t row_offset = std::size_t(blockIdx.x) * n_cols; + input += row_offset; output += row_offset; + float partial = 0.0f; + for (std::size_t col = tid; col < std::size_t(n_cols); col += BlockSize) { + const float x = input[col]; + partial += x * x; + } + __shared__ float sums[32]; + partial = warp_sum(partial); + const int lane = tid % 32; + if (lane == 0) sums[tid / 32] = partial; + // All reads of input for the reduction precede this barrier. Afterwards, + // each thread reads/writes only its own elements, allowing exact in-place use. + __syncthreads(); + partial = lane < BlockSize / 32 ? sums[lane] : 0.0f; + partial = warp_sum(partial); + const float mean = partial / n_cols; + const float scale = rsqrtf(mean + epsilon); + for (std::size_t col = tid; col < std::size_t(n_cols); col += BlockSize) + output[col] = scale * input[col] * gamma[col]; +} +__global__ void gate(const float* attn, const float* __restrict__ q_full, float* output, + int n_head, int head_dim) { + const std::size_t i = std::size_t(blockIdx.x) * blockDim.x + threadIdx.x; + if (i >= std::size_t(n_head) * head_dim) return; + const std::size_t head = i / head_dim, channel = i % head_dim; + const float raw = q_full[head * 2 * head_dim + head_dim + channel]; + const float sigmoid = 1.0f / (1.0f + expf(-raw)); + output[i] = attn[i] * sigmoid; +} +std::size_t elements(int cols, int rows) { + if (cols <= 0 || rows <= 0 || std::uint64_t(cols) * rows > std::uint64_t(std::numeric_limits::max())) + throw std::invalid_argument("native QSA requires positive bounded dimensions"); + return std::size_t(cols) * rows; +} +bool valid(const void* ptr, std::size_t bytes) { + const auto address = reinterpret_cast(ptr); + return ptr && address % 4 == 0 && bytes <= UINTPTR_MAX - address; +} +bool overlap(const void* a, std::size_t an, const void* b, std::size_t bn) { + const auto ap = reinterpret_cast(a), bp = reinterpret_cast(b); + return ap < bp + bn && bp < ap + an; +} +void buffers(const float* input, std::size_t in_bytes, const float* weight, std::size_t weight_bytes, + float* output, void* stream) { + if (!stream || !valid(input, in_bytes) || !valid(weight, weight_bytes) || !valid(output, in_bytes) || + overlap(input, in_bytes, weight, weight_bytes) || overlap(output, in_bytes, weight, weight_bytes) || + (input != output && overlap(input, in_bytes, output, in_bytes))) + throw std::invalid_argument("native QSA requires a stream, aligned spans, and disjoint buffers or exact input/output alias"); +} +void check_launch() { + const auto result = cudaGetLastError(); + if (result != cudaSuccess) + throw std::runtime_error(std::string("native QSA launch: ") + cudaGetErrorString(result)); +} +} // namespace + +void native_qsa_set_enabled(bool value) { enabled.store(value, std::memory_order_relaxed); } +bool native_qsa_enabled() { return enabled.load(std::memory_order_relaxed); } + +void native_qsa_rms_norm_weighted(const float* input, const float* gamma, float* output, + int n_cols, int n_rows, float epsilon, void* stream) { + const auto count = elements(n_cols, n_rows); + if (!std::isfinite(epsilon) || epsilon < 0.0f) + throw std::invalid_argument("native QSA requires finite nonnegative epsilon"); + buffers(input, count * 4, gamma, std::size_t(n_cols) * 4, output, stream); + if (n_cols < 1024) + norm<256><<(stream)>>>(input, gamma, output, n_cols, epsilon); + else + norm<1024><<(stream)>>>(input, gamma, output, n_cols, epsilon); + check_launch(); +} +void native_qsa_gate_apply(const float* attn, const float* q_full, float* output, + int n_head, int head_dim, void* stream) { + const auto count = elements(head_dim, n_head); + buffers(attn, count * 4, q_full, count * 8, output, stream); + gate<<(stream)>>>(attn, q_full, output, n_head, head_dim); + check_launch(); +} +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_qsa_indexer.cu b/src/kernels/cuda/native_qsa_indexer.cu new file mode 100644 index 00000000..b80009b5 --- /dev/null +++ b/src/kernels/cuda/native_qsa_indexer.cu @@ -0,0 +1,129 @@ +// Adapted from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d: +// src/models/qwen4exp.cpp; ggml/src/ggml-cuda/{set-rows.cu,norm.cu,rope.cu}. +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "strata/kernels/native_qsa_indexer.hpp" +#include "strata/kernels/mrope.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +std::atomic enabled{false}; +constexpr int D = 128, R = 4, ROT = 64, THREADS = 256; +__device__ float warp_sum(float x) { +#pragma unroll + for (int offset = 16; offset; offset >>= 1) + x += __shfl_xor_sync(0xffffffffu, x, offset); + return x; +} +__global__ void append(const float* __restrict__ raw, const int32_t* __restrict__ pos_dev, + int pos_base, const float* __restrict__ gamma, float epsilon, + float* __restrict__ tail, float* __restrict__ dead, + float* __restrict__ pooled, int32_t* __restrict__ block_pos, + int max_cells, float theta_scale, const int32_t* __restrict__ mtab) { + const int pos = *pos_dev, d = threadIdx.x; + if (pos < 0 || pos >= max_cells) return; + const int slot = pos % R; + float incoming = 0.0f; + if (d < D) { + // SET_ROWS stores F16; GET_ROWS expands those exact values to F32. + incoming = __half2float(__float2half_rn(raw[d])); + if (slot < R - 1) tail[slot * D + d] = incoming; + } + if (pos != 0 && slot != R - 1) return; + __shared__ float values[D]; + __shared__ float partials[32]; + float mean = 0.0f; + if (d < D) { + // The spare's four gather indices all name cell zero. Completed blocks + // use chronological slices; each graph ADD materializes an F32 sum. + float sum = pos == 0 ? incoming : tail[d]; +#pragma unroll + for (int j = 1; j < R; ++j) + sum = __fadd_rn(sum, pos == 0 || j == R - 1 ? incoming : tail[j * D + d]); + mean = __fmaf_rn(0.25f, sum, 0.0f); // SCALE includes a zero bias. + } + float square_sum = 0.0f; + if (d < D) square_sum += mean * mean; + square_sum = warp_sum(square_sum); + const int lane = d % 32; + if (lane == 0) partials[d / 32] = square_sum; + __syncthreads(); + square_sum = lane < THREADS / 32 ? partials[lane] : 0.0f; + square_sum = warp_sum(square_sum); + const float scale = rsqrtf(square_sum / D + epsilon); + if (d < D) values[d] = scale * mean * gamma[d]; + __syncthreads(); + if (d >= D) return; + const int b = pos / R; + const int rope_pos = pos == 0 ? 0 : pos_base + R * b; + float y = values[d]; + if (d < ROT) { + const int pair = d % (ROT / 2); + const float theta = (pos == 0 ? 0 : mrope_pos(mtab, rope_pos, pair)) * powf(theta_scale, float(pair)); + const float c = cosf(theta), s = sinf(theta); + const float a = values[pair], z = values[pair + ROT / 2]; + y = d < ROT / 2 ? a * c - z * s : a * s + z * c; + } + pooled[std::size_t(b) * D + d] = y; + if (pos == 0) dead[d] = y; + else pooled[std::size_t(b + 1) * D + d] = dead[d]; + if (d == 0 && pos != 0) *block_pos = rope_pos; +} +struct Span { const void* p; std::size_t n; }; +void validate(Span s) { + const auto p = reinterpret_cast(s.p); + if (!p || p % 4 || s.n > UINTPTR_MAX - p) + throw std::invalid_argument("native QSA indexer requires aligned bounded spans"); +} +bool overlaps(Span a, Span b) { + const auto x = reinterpret_cast(a.p), y = reinterpret_cast(b.p); + return x < y + b.n && y < x + a.n; +} +} // namespace + +void native_qsa_indexer_set_enabled(bool value) { enabled.store(value, std::memory_order_relaxed); } +bool native_qsa_indexer_enabled() { return enabled.load(std::memory_order_relaxed); } +void native_qsa_indexer_append(const float* raw, const int32_t* relative_pos_device, int32_t pos_base, + const float* gamma, float epsilon, const QsaIndexerBuffers& b, + const QsaShapes& s, int64_t max_cells, float freq_base, void* stream) { + if (!stream || s.idx_dim != D || s.idx_block != R || s.n_rot != ROT || + max_cells < 1 || max_cells > INT32_MAX || pos_base < 0 || pos_base % R || + int64_t(pos_base) + max_cells > INT32_MAX || !std::isfinite(epsilon) || epsilon <= 0.0f || + !std::isfinite(freq_base) || freq_base <= 1.0f) + throw std::invalid_argument("native QSA indexer requires fixed geometry, aligned position base, positive capacity/epsilon, valid frequency and explicit stream"); + const Span spans[] = {{raw,D*4},{relative_pos_device,4},{gamma,D*4},{b.tail,(R-1)*D*4}, + {b.dead,D*4},{b.pooled,std::size_t(max_cells/R+1)*D*4},{b.block_pos,4}}; + for (const auto& span : spans) validate(span); + for (int i = 0; i < 7; ++i) for (int j = i + 1; j < 7; ++j) + if (overlaps(spans[i], spans[j])) throw std::invalid_argument("native QSA indexer buffers overlap"); + const float theta_scale = powf(freq_base, -2.0f / ROT); + append<<<1,THREADS,0,static_cast(stream)>>>(raw,relative_pos_device,pos_base,gamma,epsilon, + b.tail,b.dead,b.pooled,b.block_pos,int(max_cells),theta_scale,mrope_table()); + const auto error = cudaGetLastError(); + if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error)); +} +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_qsa_score.cu b/src/kernels/cuda/native_qsa_score.cu new file mode 100644 index 00000000..9ae4d117 --- /dev/null +++ b/src/kernels/cuda/native_qsa_score.cu @@ -0,0 +1,151 @@ +// Adapted from llama.cpp 3cf03257f219afbe7334045ff7c6a06ac68c627d: +// ggml/src/ggml-cuda/{mmf.cuh,mma.cuh,unary.cu,binbcast.cu}. +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "strata/kernels/native_qsa_score.hpp" +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +std::atomic enabled{false}; +constexpr int D=128, HEADS=4, R=4, ROWS=32, WARPS=2, STRIDE=36, COMBINE=68; +struct TileA { uint32_t x[4]; }; +struct TileB { uint32_t x[2]; }; +struct TileC { float x[4]={0.0f,0.0f,0.0f,0.0f}; }; +__device__ __forceinline__ void load_a(TileA& a,const float* p) { + const float* src=p+(threadIdx.x%16)*STRIDE+(threadIdx.x/16)*4; + asm volatile("ldmatrix.sync.aligned.m8n8.x4.b16 {%0,%1,%2,%3}, [%4];" + : "=r"(a.x[0]),"=r"(a.x[1]),"=r"(a.x[2]),"=r"(a.x[3]):"l"(src)); +} +__device__ __forceinline__ void load_b(TileB& b,const float* p) { + const float* src=p+(threadIdx.x%8)*STRIDE+((threadIdx.x/8)*4)%8; + asm volatile("ldmatrix.sync.aligned.m8n8.x2.b16 {%0,%1}, [%2];" + : "=r"(b.x[0]),"=r"(b.x[1]):"l"(src)); +} +__device__ __forceinline__ void mma(TileC& c,const TileA& a,const TileB& b) { + // Deliberately no cvt.rn.tf32: pinned mma.cuh passes raw F32 bits directly. + asm("mma.sync.aligned.m16n8k8.row.col.f32.tf32.tf32.f32 {%0,%1,%2,%3}, {%4,%5,%6,%7}, {%8,%9}, {%0,%1,%2,%3};" + : "+f"(c.x[0]),"+f"(c.x[1]),"+f"(c.x[2]),"+f"(c.x[3]) + : "r"(a.x[0]),"r"(a.x[1]),"r"(a.x[2]),"r"(a.x[3]),"r"(b.x[0]),"r"(b.x[1])); +} +__global__ __launch_bounds__(64,1) void score_kernel( + const float* __restrict__ pooled,const float* __restrict__ query, + const float* __restrict__ bias,const int32_t* __restrict__ step, + int max_cells,float* __restrict__ cells) { + const int n=step[kStepNKv],full=step[kStepNBid]; + if(n<1||n>max_cells||step[kStepPos]!=n-1||full!=n/R|| + step[kStepWidth]!=(n<2051?n:2051))return; + const int row0=blockIdx.x*ROWS; + if(row0>full)return; + const int lane=threadIdx.x,warp=threadIdx.y; + __shared__ __align__(16) float shared[WARPS*16*STRIDE]; + float* tile=shared+warp*16*STRIDE; + TileC c[2]; + // mmf's launch heuristic picks2warps for K128. Each warp accumulates + // K[warp*32,warp*32+32), then K[warp*32+64,warp*32+96). + for(int col=warp*32+lane;colfull)return; + float h[HEADS]; +#pragma unroll + for(int j=0;j(s.p); + if(!p||p%4||s.n>UINTPTR_MAX-p)throw std::invalid_argument("native QSA score requires aligned bounded spans"); +} +bool overlaps(Span a,Span b){ + const auto x=reinterpret_cast(a.p),y=reinterpret_cast(b.p); + return xINT32_MAX-3||max_blocks!=max_cells/R+1) + throw std::invalid_argument("native QSA score requires128dim/4heads/4cells/2048budget, exact capacities and explicit stream"); + const Span spans[]={{pooled,size_t(max_blocks)*D*4},{query,HEADS*D*4}, + {step,kStepCount*4},{cells,size_t(max_cells)*4},{bias,bias?size_t(max_blocks)*4:0}}; + const int count=bias?5:4; + for(int i=0;i(stream)>>>( + pooled,query,bias,step,int(max_cells),cells); + const auto error=cudaGetLastError(); + if(error!=cudaSuccess)throw std::runtime_error(cudaGetErrorString(error)); +} +} // namespace strata::kernels diff --git a/src/kernels/cuda/native_rope.cu b/src/kernels/cuda/native_rope.cu new file mode 100644 index 00000000..d3e9225c --- /dev/null +++ b/src/kernels/cuda/native_rope.cu @@ -0,0 +1,88 @@ +// Numerical contract: pinned ggml/src/ggml-cuda/rope.cu, rope_multi/rope_yarn. +// Text positions are equal across the four IMRoPE sections, so section routing +// reduces to the one position associated with each contiguous row. +// MIT License +// +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +#include "strata/kernels/native_rope.hpp" +#include "strata/kernels/mrope.hpp" +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +std::atomic enabled{false}; +bool overlaps(const void* a, size_t an, const void* b, size_t bn) { + auto x = reinterpret_cast(a), y = reinterpret_cast(b); + return x <= y ? y - x < an : x - y < bn; +} +__global__ void apply(const float* x, float* out, int rows, int width, + int n_rot, float theta_scale, const int* positions, const int32_t* mtab) { + const int row = blockIdx.y; + const int pair = blockIdx.x * blockDim.x + threadIdx.x; + if (row >= rows || pair >= width / 2) return; + const size_t start = size_t(row) * width; + if (pair >= n_rot / 2) { + if (x != out) { + out[start + 2 * pair] = x[start + 2 * pair]; + out[start + 2 * pair + 1] = x[start + 2 * pair + 1]; + } + return; + } + const float theta = mrope_pos(mtab, positions[row], pair) * powf(theta_scale, float(pair)); + const float c = cosf(theta), s = sinf(theta); + const float a = x[start + pair], b = x[start + pair + n_rot / 2]; + out[start + pair] = a * c - b * s; + out[start + pair + n_rot / 2] = a * s + b * c; +} +} +namespace { std::atomic mrope_tab{nullptr}; } +void mrope_table_set(const int32_t* device_table) { mrope_tab.store(device_table, std::memory_order_relaxed); } +const int32_t* mrope_table() { return mrope_tab.load(std::memory_order_relaxed); } +void native_rope_set_enabled(bool value) { enabled.store(value, std::memory_order_relaxed); } +bool native_rope_enabled() { return enabled.load(std::memory_order_relaxed); } +void native_rope_apply(const float* x, float* out, int rows, int head_dim, + int n_rot, float freq_base, const int* positions, void* stream) { + if (!x || !out || !positions || !stream || rows < 1 || rows > 65535 || + (head_dim != 128 && head_dim != 256) || n_rot != 64 || + !std::isfinite(freq_base) || freq_base <= 1.0f || + reinterpret_cast(x) % 4 || reinterpret_cast(out) % 4 || + reinterpret_cast(positions) % 4) { + throw std::invalid_argument("native RoPE requires aligned F32 rows, width 128/256, rotation 64, valid base and explicit stream"); + } + const size_t bytes = size_t(rows) * head_dim * sizeof(float); + if ((x != out && overlaps(x, bytes, out, bytes)) || + overlaps(positions, size_t(rows) * sizeof(int), out, bytes) || + overlaps(positions, size_t(rows) * sizeof(int), x, bytes)) { + throw std::invalid_argument("native RoPE buffers partially overlap"); + } + // Match pinned host-side float powf before device fast powf/trigonometry. + const float theta_scale = powf(freq_base, -2.0f / n_rot); + apply<<(stream)>>>(x, out, rows, head_dim, n_rot, theta_scale, positions, mrope_table()); + const auto error = cudaGetLastError(); + if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error)); +} +} diff --git a/src/kernels/cuda/native_router.cu b/src/kernels/cuda/native_router.cu new file mode 100644 index 00000000..12456371 --- /dev/null +++ b/src/kernels/cuda/native_router.cu @@ -0,0 +1,117 @@ +// Adapted from topk-moe.cu/common.cuh in llama.cpp +// 3cf03257f219afbe7334045ff7c6a06ac68c627d; finite F32, 512-expert/10-output path. +// MIT License +// Copyright (c) 2023-2026 The ggml authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +#include "strata/kernels/native_router.hpp" +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { +std::atomic enabled{false}; +__device__ __forceinline__ float warp_sum(float value) { +#pragma unroll + for (int mask = 16; mask; mask >>= 1) value += __shfl_xor_sync(0xffffffffu, value, mask, 32); + return value; +} +__device__ __forceinline__ float warp_max(float value) { +#pragma unroll + for (int mask = 16; mask; mask >>= 1) value = fmaxf(value, __shfl_xor_sync(0xffffffffu, value, mask, 32)); + return value; +} +__launch_bounds__(256, 1) +__global__ void route(const float* __restrict__ logits, int32_t* __restrict__ ids, + float* __restrict__ weights) { + // Preserve the pinned 32x8 block geometry; only row zero is active here. + if (threadIdx.y != 0) return; + const int lane = threadIdx.x; + float values[16]; +#pragma unroll + for (int i = 0; i < 16; ++i) values[i] = logits[lane + i * 32]; + __syncthreads(); + float maximum = -INFINITY; +#pragma unroll + for (int i = 0; i < 16; ++i) maximum = max(maximum, values[i]); + maximum = warp_max(maximum); + float sum = 0.0f; +#pragma unroll + for (int i = 0; i < 16; ++i) { + values[i] = expf(values[i] - maximum); + sum += values[i]; + } + const float reciprocal = 1.0f / warp_sum(sum); +#pragma unroll + for (int i = 0; i < 16; ++i) { + values[i] *= reciprocal; + if (__isnanf(values[i])) values[i] = -FLT_MAX; + } + float selected = 0.0f, selected_sum = 0.0f; + for (int rank = 0; rank < 10; ++rank) { + float best = values[0]; + int expert = lane; +#pragma unroll + for (int i = 1; i < 16; ++i) { + if (values[i] > best) { best = values[i]; expert = lane + i * 32; } + } +#pragma unroll + for (int mask = 16; mask; mask >>= 1) { + const float other = __shfl_xor_sync(0xffffffffu, best, mask, 32); + const int other_id = __shfl_xor_sync(0xffffffffu, expert, mask, 32); + if (other > best || (other == best && other_id < expert)) { best = other; expert = other_id; } + } + if ((expert & 31) == lane) { + values[expert / 32] = -INFINITY; + ids[rank] = expert; + // Deliberately accumulate by WINNING EXPERT lane, not output rank. + // Multiple selected experts in one lane add in selection order. + selected_sum += best; + } + if (rank == lane) selected = best; + } + selected_sum = max(warp_sum(selected_sum), 6.103515625e-5f); + const float inverse_selected_sum = 1.0f / selected_sum; + if (lane < 10) weights[lane] = selected * inverse_selected_sum; +} +bool valid(const void* p, size_t bytes) { + const auto address = reinterpret_cast(p); + return p && address % 4 == 0 && bytes <= UINTPTR_MAX - address; +} +bool overlap(const void* a, size_t an, const void* b, size_t bn) { + const auto ap = reinterpret_cast(a), bp = reinterpret_cast(b); + return ap < bp + bn && bp < ap + an; +} +} +void native_router_set_enabled(bool value) { enabled.store(value, std::memory_order_relaxed); } +bool native_router_enabled() { return enabled.load(std::memory_order_relaxed); } +void native_router_top10(const float* logits, int32_t* ids, float* weights, void* stream) { + if (!stream || !valid(logits, 512 * 4) || !valid(ids, 10 * 4) || !valid(weights, 10 * 4) + || overlap(logits, 512 * 4, ids, 10 * 4) || overlap(logits, 512 * 4, weights, 10 * 4) + || overlap(ids, 10 * 4, weights, 10 * 4)) + throw std::invalid_argument("native router requires a stream, aligned spans, and disjoint outputs"); + route<<<1, dim3(32, 8), 0, static_cast(stream)>>>(logits, ids, weights); + const auto error = cudaGetLastError(); + if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error)); +} +} diff --git a/src/kernels/cuda/ple.cu b/src/kernels/cuda/ple.cu new file mode 100644 index 00000000..5a9174e0 --- /dev/null +++ b/src/kernels/cuda/ple.cu @@ -0,0 +1,366 @@ +// src/kernels/cuda/ple.cu - P2.S4: the PLE block's GPU half. +// +// See include/strata/kernels/ple.hpp for the structure and for the `normalized`-is-the-conv-input finding. +// +// The legacy arithmetic below follows the captured CPU ggml graph +// (`bench/micro/ple_in.bin` / `ple_out.bin`, produced by `ple_layer_xcheck.cpp`): +// +// * `rms_norm` accumulates `(double)(x*x)` with the PRODUCT rounded in f32 first - that is literally what +// `ggml_compute_forward_rms_norm_f32` does (`sum += (ggml_float)(x[i00]*x[i00])` with `ggml_float` = +// double), and it is not the same as accumulating in f32 or as widening before the multiply. +// * `silu` is `x / (1 + expf(-x))` in f32, which is `ggml_silu_f32`. +// * the gate's `sqrt` and `sigmoid` are f32. +// +// The opt-in native BF16 path replaces only ple_value with the pinned CUDA single-token BF16/F32 MMVF. +// A separate opt-in native postops path follows the pinned CUDA arithmetic after both projections. +#include "strata/kernels/ple.hpp" +#include "strata/kernels/bf16_gemv.hpp" +#include "strata/kernels/f16_bits.hpp" +#include "strata/kernels/ngram.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/s2_gemv_q8.hpp" +#include "strata/kernels/native_mmvq.hpp" +#include "strata/kernels/native_ple_postops.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int THREADS = 256; +bool native_bf16 = false; +bool native_postops = false; + +bool overlap(const void* a, size_t a_bytes, const void* b, size_t b_bytes) { + if (a == nullptr || b == nullptr || a_bytes == 0 || b_bytes == 0) return false; + const uintptr_t aa = reinterpret_cast(a), bb = reinterpret_cast(b); + return aa < bb ? bb - aa < a_bytes : aa - bb < b_bytes; +} + +__global__ void history_advance_kernel(float* __restrict__ history, const float* __restrict__ normalized) { + const int channel = blockIdx.x * blockDim.x + threadIdx.x; + if (channel >= NG_HC_DIM) return; + float* column = history + (size_t) channel * NG_HIST; + for (int row = 0; row + 1 < NG_HIST; ++row) column[row] = column[row + 1]; + column[NG_HIST - 1] = normalized[channel]; +} + +__device__ __forceinline__ uint16_t bf16_bits(float f) { + uint32_t i; + memcpy(&i, &f, 4); + i = (i + ((i >> 16) & 1u) + 0x7FFFu) & 0xFFFF0000u; + return (uint16_t) (i >> 16); +} + +__device__ __forceinline__ float bf16_float(uint16_t h) { + const uint32_t i = (uint32_t) h << 16; + float f; + memcpy(&f, &i, 4); + return f; +} + +__device__ __forceinline__ float silu_f(float x) { return x / (1.0f + expf(-x)); } + +/// Block-wide sum in double, broadcast to all threads. The leading `__syncthreads` matters: the result is +/// read out of `scratch[0]` and a later call reuses the array, so without a barrier a fast thread can +/// overwrite it before a slow one has read it - invisible in most runs and a slightly different norm when it +/// fires. +__device__ double block_sum(double v, double* scratch) { + __syncthreads(); + const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5; + for (int off = 16; off > 0; off >>= 1) v += __shfl_down_sync(0xFFFFFFFFu, v, off); + if (lane == 0) scratch[warp] = v; + __syncthreads(); + const int nw = ((int) blockDim.x + 31) >> 5; + v = (threadIdx.x < nw) ? scratch[threadIdx.x] : 0.0; + if (warp == 0) + for (int off = 16; off > 0; off >>= 1) v += __shfl_down_sync(0xFFFFFFFFu, v, off); + if (threadIdx.x == 0) scratch[0] = v; + __syncthreads(); + return scratch[0]; +} + +/// `grouped_norm`, ONE BLOCK PER STREAM. The ggml form is `reshape_3d(x, n_embd, hc, T)` then +/// `rms_norm(..., eps) * w`, so ne0 = n_embd is the reduction axis and each hc stream is normalised on its +/// own. In the flat ggml layout (ne0 fastest) stream c occupies [c*n_embd, (c+1)*n_embd). +__global__ void gnorm_kernel(const float* __restrict__ x, const float* __restrict__ w, + float* __restrict__ y, int n_embd, float eps) { + __shared__ double scratch[8]; + const int c = blockIdx.x; + const float* xc = x + (size_t) c * n_embd; + const float* wc = w + (size_t) c * n_embd; + float* yc = y + (size_t) c * n_embd; + + double acc = 0.0; + for (int d = threadIdx.x; d < n_embd; d += blockDim.x) { + // The PRODUCT is rounded in f32 before it is widened - `(ggml_float)(x[i00]*x[i00])`. + const float sq = xc[d] * xc[d]; + acc += (double) sq; + } + const float mean = (float) (block_sum(acc, scratch) / (double) n_embd); + const float scale = 1.0f / sqrtf(mean + eps); + for (int d = threadIdx.x; d < n_embd; d += blockDim.x) yc[d] = xc[d] * scale * wc[d]; +} + +/// `s[c] = sum_d key[c][d]*query[c][d] / sqrt(n_embd)`, then the signed square root and the sigmoid. +/// +/// `mag = sqrt(clamp(|s|, 1e-6, inf))` and `gate = sigmoid(sgn(s) * mag)`. The clamp is a floor on |s|, so +/// it only bites near zero; the SIGN is carried separately, which is what keeps the gate symmetric about 0.5. +__global__ void gate_kernel(const float* __restrict__ key, const float* __restrict__ query, + float* __restrict__ gate, int n_embd, float inv_sqrt_n) { + __shared__ double scratch[8]; + const int c = blockIdx.x; + const float* kc = key + (size_t) c * n_embd; + const float* qc = query + (size_t) c * n_embd; + double acc = 0.0; + for (int d = threadIdx.x; d < n_embd; d += blockDim.x) acc += (double) (kc[d] * qc[d]); + const float s = (float) (block_sum(acc, scratch) / (double) 1.0) * inv_sqrt_n; + const float mag = sqrtf(fmaxf(fabsf(s), 1e-6f)); + const float sgn = (s > 0.0f) ? 1.0f : ((s < 0.0f) ? -1.0f : 0.0f); + if (threadIdx.x == 0) gate[c] = 1.0f / (1.0f + expf(-(sgn * mag))); +} + +/// `gated[c][d] = value[d] * gate[c]` - the value broadcast across the hc streams. +__global__ void bcast_kernel(const float* __restrict__ value, const float* __restrict__ gate, + float* __restrict__ gated, int n_embd, int hc) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n_embd * hc) return; + gated[i] = value[i % n_embd] * gate[i / n_embd]; +} + +/// The depthwise causal dilated conv, then SiLU. One thread per channel. +/// +/// `out[c] = sum_k kW[k][c] * terms[t - (kern-1-k)*dil][c]` with `t = hist` (the first new position), so tap k +/// reads row `hist - (kern-1-k)*dil` of `[history | new]`. For the real geometry (kern 4, dil 3, hist 9) the +/// four taps read rows 0, 3, 6 and 9 - and row 9 is the NEW row, which is the only one beyond the history. +/// That is why no `terms` buffer is built: three taps come from the caller's history and one from `norm`. +/// +/// `kW` IS GGML-NATIVE: `kW[k + kern*c]`. The manifest's shape is [4, 10240] with ne0 = 4 fast, and +/// `ref/ngram.py`'s (4, 10240) numpy `kernel[k][c]` is the TRANSPOSE of that. The source's own view +/// (`ggml_view_2d(model.layers[il].ple_conv1d, 1, hc_dim, nb[1], k*nb[0])`) settles which one is meant. +__global__ void conv_kernel(const float* __restrict__ hist, const float* __restrict__ norm, + const uint16_t* __restrict__ kW, float* __restrict__ out, int hc_dim, int kern, + int dil, int nhist) { + const int c = blockIdx.x * blockDim.x + threadIdx.x; + if (c >= hc_dim) return; + float acc = 0.0f; + for (int k = 0; k < kern; ++k) { + const int row = nhist - (kern - 1 - k) * dil; // tap 0 reads the FURTHEST back + // ROW-FASTEST: `hist[row + nhist*c]`. The conv state is `ggml_reshape_3d(state, d_conv-1, + // conv_channels, n_seqs)`, so ne0 = hist varies fastest and the flat index is row + hist*channel. + // Channel-slowest (`row*hc_dim + c`) is the natural thing to write and would read a transposed state - + // `ple_layer_xcheck.cpp` L79-83 records getting this wrong once already. + const float v = (row == nhist) ? norm[c] : hist[(size_t) row + (size_t) nhist * c]; + acc += f32_from_f16(kW[k + kern * c]) * v; + } + out[c] = silu_f(acc); +} + +/// `result = hidden + gated + conv`, elementwise over hc_dim. +__global__ void add3_kernel(const float* __restrict__ hidden, const float* __restrict__ gated, + const float* __restrict__ conv, float* __restrict__ result, int n) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) result[i] = hidden[i] + gated[i] + conv[i]; +} + +/// `y[o] = sum_i bf16(x[i]) * bf16(w[o*n_in + i])`. The weight is BF16 and so is the ACTIVATION, which is +/// the contract for a BF16 tensor (`docs/activation-contract.md`); with both sides bf16 the products are +/// exact in f32 and only the summation order differs from ggml's. +__global__ void bf16_gemv_kernel(const uint16_t* __restrict__ x, const uint16_t* __restrict__ w, + float* __restrict__ y, int n_in, int n_out) { + const int o = blockIdx.x * blockDim.x + threadIdx.x; + if (o >= n_out) return; + const uint16_t* row = w + (size_t) o * n_in; + double acc = 0.0; + for (int i = 0; i < n_in; ++i) acc += (double) bf16_float(x[i]) * (double) bf16_float(row[i]); + y[o] = (float) acc; +} + +__global__ void to_bf16_kernel(const float* __restrict__ x, uint16_t* __restrict__ y, int n) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) y[i] = bf16_bits(x[i]); +} + +void ck(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "ple_block: %s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace + +void ple_set_native_bf16(bool enabled) { native_bf16 = enabled; } +void ple_set_native_postops(bool enabled) { native_postops = enabled; } +bool ple_native_postops_enabled() { return native_postops; } + +void ple_history_advance(float* hist, const float* normalized, void* stream) { + if (hist == nullptr || normalized == nullptr) + throw std::invalid_argument("ple_history_advance: null history or normalized input"); + if (overlap(hist, (size_t) NG_HIST * NG_HC_DIM * sizeof(float), + normalized, (size_t) NG_HC_DIM * sizeof(float))) + throw std::invalid_argument("ple_history_advance: history and normalized input overlap"); + history_advance_kernel<<<(NG_HC_DIM + THREADS - 1) / THREADS, THREADS, 0, (cudaStream_t) stream>>>(hist, normalized); + ck(cudaGetLastError(), "history advance launch"); +} + +bool ple_block_available() { + int n = 0; + return cudaGetDeviceCount(&n) == cudaSuccess && n > 0; +} + +uint64_t ple_block_scratch_bytes() { + // five hc_dim floats + n_embd + hc, then the Q8 activation image, then the BF16 embedding copy, each + // 16-byte aligned because `d_scratch` is cast to `float*` and `d_act` to `uint8_t*` at those offsets. + const size_t f = (size_t) (5 * NG_HC_DIM + NG_N_EMBD + NG_HC) * sizeof(float); + const size_t q = (size_t) (NG_N_EMBD / 32) * 34; + const size_t e = (size_t) NG_N_EMBD * sizeof(uint16_t); + return ((f + 15) & ~(size_t) 15) + ((q + 15) & ~(size_t) 15) + e + 256; +} + +void ple_block(const float* emb, const float* hidden, const float* hist_rows, const PleWeights& w, + PleOut& out, void* scratch, void* stream) { + const bool native_key = w.key_native_data != nullptr && w.key_bf16 == nullptr; + if (native_key && (!emb || !hidden || !hist_rows || !out.result || !scratch || !stream || + !w.key_native_q8_1 || w.key_native_type != 42)) + throw std::invalid_argument("ple_block: native key requires Q2_0 weights, input/output, private scratch and explicit stream"); + if (emb == nullptr || hidden == nullptr || hist_rows == nullptr || out.result == nullptr) return; + const int n_embd = NG_N_EMBD, hc = NG_HC, hc_dim = NG_HC_DIM; + static_assert(NG_N_EMBD == 2560 && NG_HC_DIM == 10240, "native PLE key geometry changed"); + const size_t float_bytes = (size_t) (5 * hc_dim + n_embd + hc) * sizeof(float); + cudaStream_t st = (cudaStream_t) stream; + + // One allocation for every intermediate. + // + // THE ENGINE WILL NOT DO THIS. P2.T10 requires zero token-path allocations, and the fix is the same one + // `gr_read` already uses: a caller-owned workspace carved once at startup. It is done this way here + // because this function's job in P2.S4 is to be CORRECT and comparable against the oracle, and matching + // `shared_expert.cu`'s existing shape keeps the diff small. Recorded rather than hidden. + // + // FIVE SEPARATE hc_dim BUFFERS, deliberately. `key` and `normalized` are both needed at the end for the + // oracle comparison, so aliasing one onto the other - which the first version did - silently returns the + // WRONG `key`. Two buffers of the same size look like an obvious saving and the only thing it saves is + // 40 KB. + // **THE WORKSPACE IS CARVED FROM THE CALLER'S, NOT ALLOCATED.** Three `cudaMalloc`s and a + // `cudaStreamSynchronize` used to live here, and inside a stream capture both are errors: the engine + // reported `ple_block: scratch: operation not permitted when stream is capturing`, and a token-path + // allocation is a P2.T10 violation besides. The comment this replaces already said the right shape was + // "a caller-owned workspace carved once at startup"; this is that. + const size_t q8_bytes = (size_t) (n_embd / 32) * 34; + if (scratch == nullptr) { + std::fprintf(stderr, "ple_block: scratch is null; the caller owns it (see ple_block_scratch_bytes)\n"); + std::exit(1); + } + const struct Export { const float* pointer; size_t count; const char* name; } exports[] = { + {out.key, (size_t) hc_dim, "key"}, {out.value, (size_t) n_embd, "value"}, + {out.gate, (size_t) hc, "gate"}, {out.gated, (size_t) hc_dim, "gated"}, + {out.normalized, (size_t) hc_dim, "normalized"}, {out.conv, (size_t) hc_dim, "conv"}, + {out.result, (size_t) hc_dim, "result"} + }; + for (const auto& item : exports) { + if (overlap(item.pointer, item.count * sizeof(float), scratch, (size_t) ple_block_scratch_bytes())) + throw std::invalid_argument(std::string("ple_block: output ") + item.name + " overlaps scratch"); + } + if (native_key) { + const size_t native_bytes = native_q8_1_bytes(n_embd); + const size_t weight_bytes = native_mmvq_weight_bytes(w.key_native_type, n_embd, hc_dim); + if ((reinterpret_cast(w.key_native_data) & 3u) || + (reinterpret_cast(w.key_native_q8_1) & 3u)) + throw std::invalid_argument("ple_block: native key buffers require four-byte alignment"); + const struct Region { const void* pointer; size_t bytes; } regions[] = { + {scratch, (size_t) ple_block_scratch_bytes()}, {emb, (size_t) n_embd * 4}, + {hidden, (size_t) hc_dim * 4}, {hist_rows, (size_t) NG_HIST * hc_dim * 4}, + {w.key_native_data, weight_bytes}, {w.value_bf16, (size_t) n_embd * n_embd * 2}, + {w.norm_key, (size_t) hc_dim * 4}, {w.norm_query, (size_t) hc_dim * 4}, + {w.norm_conv, (size_t) hc_dim * 4}, {w.conv1d_f16, (size_t) PLE_CONV_KERNEL * hc_dim * 2} + }; + for (const auto& region : regions) + if (overlap(w.key_native_q8_1, native_bytes, region.pointer, region.bytes)) + throw std::invalid_argument("ple_block: native key scratch overlaps workspace, input or weight"); + for (const auto& item : exports) + if (overlap(w.key_native_q8_1, native_bytes, item.pointer, item.count * sizeof(float))) + throw std::invalid_argument(std::string("ple_block: native key scratch overlaps output ") + item.name); + } + uint8_t* base = (uint8_t*) scratch; + float* d_scratch = (float*) base; + uint8_t* d_act = base + ((float_bytes + 15) & ~(size_t) 15); + uint16_t* d_emb16 = (uint16_t*) (d_act + ((q8_bytes + 15) & ~(size_t) 15)); + float* d_key = d_scratch; + float* d_query = d_key + hc_dim; + float* d_norm = d_query + hc_dim; + float* d_gated = d_norm + hc_dim; + float* d_conv = d_gated + hc_dim; + float* d_value = d_conv + hc_dim; + float* d_gate = d_value + n_embd; + + // ---- key = grouped_norm(ple_key @ emb). The optional native projection + // follows pinned CUDA Q8_1 MMVQ; the default retains its canonical Q8_0 path. + if (w.key_bf16 != nullptr) { + bf16_gemv_fp32_mmvf(emb, w.key_bf16, d_key, n_embd, hc_dim, stream); + } else if (native_key) { + native_quantize_q8_1(emb, w.key_native_q8_1, n_embd, 1, stream); + native_mmvq(w.key_native_type, w.key_native_data, w.key_native_q8_1, + d_key, n_embd, hc_dim, 1, stream); + } else { + quantize_q8_0(emb, d_act, n_embd, st); + s2_gemv_q8(d_act, w.key_codes, w.key_scales, d_key, n_embd, hc_dim, 8, st); + } + if (!native_postops) { + gnorm_kernel<<>>(d_key, w.norm_key, d_key, n_embd, NG_RMS_EPS); + gnorm_kernel<<>>(hidden, w.norm_query, d_query, n_embd, NG_RMS_EPS); + } + + // The value projection's independent option leaves the nonlinear PLE operations unchanged. + if (native_bf16) { + bf16_gemv_fp32_mmvf(emb, w.value_bf16, d_value, n_embd, n_embd, stream); + } else { + to_bf16_kernel<<<(n_embd + THREADS - 1) / THREADS, THREADS, 0, st>>>(emb, d_emb16, n_embd); + bf16_gemv_kernel<<<(n_embd + THREADS - 1) / THREADS, THREADS, 0, st>>>(d_emb16, w.value_bf16, d_value, + n_embd, n_embd); + } + + const float* normalized_key = d_key; + if (native_postops) { + // The key norm has a distinct destination; temporary query storage can + // be reused for normalized gated values after the gate consumes it. + NativePlePostopsBuffers buffers{d_query,d_norm,d_gate,d_gated,d_norm,d_conv,out.result}; + native_ple_postops(d_key,hidden,d_value,hist_rows,w,buffers,stream); + normalized_key = d_query; + } else { + gate_kernel<<>>(d_key, d_query, d_gate, n_embd, 1.0f / sqrtf((float) n_embd)); + bcast_kernel<<<(hc_dim + THREADS - 1) / THREADS, THREADS, 0, st>>>(d_value, d_gate, d_gated, n_embd, hc); + gnorm_kernel<<>>(d_gated, w.norm_conv, d_norm, n_embd, NG_RMS_EPS); + conv_kernel<<<(hc_dim + THREADS - 1) / THREADS, THREADS, 0, st>>>(hist_rows, d_norm, w.conv1d_f16, d_conv, + hc_dim, PLE_CONV_KERNEL, NGRAM_SIZE, + NG_HIST); + add3_kernel<<<(hc_dim + THREADS - 1) / THREADS, THREADS, 0, st>>>(hidden, d_gated, d_conv, out.result, + hc_dim); + } + + // ---- the intermediates the oracle comparison needs. `key` is the NORMALISED key, because the source's + // `cb(key, ...)` capture is after `gnorm`; `value` is the projection before the gate. Each stage the + // oracle records is reproduced here so a mismatch can be attributed instead of guessed at. + if (out.key) ck(cudaMemcpyAsync(out.key, normalized_key, hc_dim * sizeof(float), cudaMemcpyDeviceToDevice, st), "key"); + if (out.value) + ck(cudaMemcpyAsync(out.value, d_value, n_embd * sizeof(float), cudaMemcpyDeviceToDevice, st), "value"); + if (out.gate) ck(cudaMemcpyAsync(out.gate, d_gate, hc * sizeof(float), cudaMemcpyDeviceToDevice, st), "gate"); + if (out.gated) + ck(cudaMemcpyAsync(out.gated, d_gated, hc_dim * sizeof(float), cudaMemcpyDeviceToDevice, st), "gated"); + if (out.normalized) + ck(cudaMemcpyAsync(out.normalized, d_norm, hc_dim * sizeof(float), cudaMemcpyDeviceToDevice, st), "norm"); + if (out.conv) + ck(cudaMemcpyAsync(out.conv, d_conv, hc_dim * sizeof(float), cudaMemcpyDeviceToDevice, st), "conv"); + + ck(cudaGetLastError(), "launch"); + // **NO `cudaStreamSynchronize` HERE.** It was there to make the function self-contained for the parity + // test, and inside a capture it is an error - a caller that wants the result immediately synchronises + // itself, and the engine's caller does not want that at all. +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/qsa.cu b/src/kernels/cuda/qsa.cu new file mode 100644 index 00000000..aafb9ab7 --- /dev/null +++ b/src/kernels/cuda/qsa.cu @@ -0,0 +1,797 @@ +// src/kernels/cuda/qsa.cu - P2.S2: the QSA cache, indexer and attention (see include/strata/kernels/qsa.hpp). +// +// The header documents the layouts and the scope; this file documents the four decisions that are about the +// KERNEL rather than about the interface. +// +// 1. THE THRESHOLD SEARCH IN `topk_512` RATHER THAN A SORT. The selection has to reproduce `ggml_top_k` on the +// masked per-cell scores with the reference's tie rule (ascending index), and it has to be EXACT: a +// different selected set is a different attention input, and `docs/capture-format.md` L127 gives +// `indexer_ids` no tolerance. The kernel therefore computes the width-th largest score as a 32-bit +// "order key" by binary lifting (32 monotone passes with `count(key >= cand) >= width`), and then emits - +// in ONE ascending walk - every cell strictly above it and the first `width - c_gt` cells equal to it. That +// is provably the same set as sorting by (score desc, index asc) and taking the first width, it needs no +// scratch and no sort, it is deterministic, and it is the reason `kTopkMaxCells` exists: the whole selection +// runs in ONE block so the ascending walk is a chunked scan rather than a second global pass. Phase 3 +// replaces it with a radix select; this one is the thing that radix select has to agree with. +// +// 2. ONE BLOCK PER HEAD IN `qsa_attend`, SCORES IN SHARED MEMORY. n_ids reaches 2051, so a per-head score +// vector is 8 KB and 24 heads are 24 blocks: the whole attention is 24 blocks x 256 threads, which is a +// small grid, but it is also exactly the shape the source uses (one flash tile per query head) and it keeps +// the softmax's three passes over the scores in shared memory instead of in global. The score loop has one +// thread per CELL walking the 256 dims sequentially, which reads 16-byte-vectorisable half2 runs; the value +// loop has one thread per DIM walking the cells, whose warp-wide reads are contiguous within a cell. Both +// are coalesced for the layout the gather produces. +// +// 3. NO `__float2half`. `f16_from_f32` from f16_bits.hpp, for the reason round 193 recorded: `__float2half` +// produced 0x2600 for 0x26DB and 0x3C01 for exactly 1.0. The parity test checks that converter against +// vectors generated by numpy's float16, which is an oracle sharing no code with it. +// +// 4. THE POOLING ARITHMETIC IS IN DOUBLE AND ORDER-FIXED. `pooled` and the spare key are compared BIT-EXACT +// against the reference (the spare key's rotation is at position 0, where the f32 rope kernel is an exact +// identity, so the comparison is not weakened by the rope). That needs the same summation order on both +// sides: the `r` raw keys are added in CELL order, the sum of squares runs d = 0..idx_dim-1 sequentially, +// and `__dadd_rn`/`__dmul_rn` block FMA contraction, which would otherwise fuse `ss + m*m` into one +// operation with no intermediate rounding and land one ulp away from the host. +// +// THE `d` IN THOSE NAMES IS NOT COSMETIC. The first version wrote `__fadd_rn`/`__fmul_rn`, which are +// the SINGLE-precision intrinsics: the compiler converted each double operand to float, operated +// there, and converted back, so the whole pooling arithmetic ran in single precision - `ss` held only +// float precision (121.82236480712891 on the device against 121.82236342931094 in the reference). +// Every comparison still passed at any sane tolerance, the spare key landing 0.4 f32 ulp away; only a +// BIT-EXACT assertion plus a full-precision probe of the intermediate found it. That is the strongest +// argument in this file for keeping both. +#include "strata/kernels/qsa.hpp" + +#include "strata/kernels/f16_bits.hpp" +#include "strata/kernels/rope.hpp" +#include "strata/kernels/mrope.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int THREADS = 128; + +void fail(const char* what) { + std::fprintf(stderr, "qsa: %s\n", what); + std::exit(1); +} + +void check_launch(const char* what) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "qsa: %s launch: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +/// The SYNCHRONOUS entry points must check their own wait. Without this the sticky error from a kernel that +/// faulted becomes visible only at the NEXT call's `cudaGetLastError()`, which then names the wrong kernel - +/// exactly the mis-attribution that made round 198's first `kv_append` failure look like a later one. +void check_sync(const char* what) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "qsa: %s (synchronise): %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +/// Geometry validation. A silently wrong `head_dim % 4` would corrupt the gather's uint2 copy and a silently +/// wrong `n_head % n_head_kv` would produce a plausible attention with the wrong key, so both are refused. +void validate(const QsaShapes& s, const char* who) { + if (s.n_head <= 0 || s.n_head_kv <= 0 || s.head_dim <= 0 || s.idx_dim <= 0 || s.idx_n_head <= 0 || + s.idx_block < 2 || s.page_size < 1) { + std::fprintf(stderr, "qsa: %s: geometry is not set up\n", who); + std::exit(1); + } + if (s.n_head % s.n_head_kv != 0) { + std::fprintf(stderr, "qsa: %s: n_head %lld is not a multiple of n_head_kv %lld\n", who, + (long long) s.n_head, (long long) s.n_head_kv); + std::exit(1); + } + if (s.head_dim % 4 != 0) { + std::fprintf(stderr, "qsa: %s: head_dim %lld must be a multiple of 4 (the gather copies uint2)\n", who, + (long long) s.head_dim); + std::exit(1); + } + if (s.n_rot <= 0 || s.n_rot % 2 != 0 || s.n_rot > s.head_dim || s.n_rot > s.idx_dim) { + std::fprintf(stderr, "qsa: %s: n_rot %lld must be even and <= head_dim %lld and idx_dim %lld\n", who, + (long long) s.n_rot, (long long) s.head_dim, (long long) s.idx_dim); + std::exit(1); + } + if (s.idx_n_head > 32) { + std::fprintf(stderr, "qsa: %s: idx_n_head %lld > 32 (one warp per indexer head)\n", who, + (long long) s.idx_n_head); + std::exit(1); + } +} + +__device__ __forceinline__ float h2f(uint16_t bits) { return f32_from_f16(bits); } + +// ================= 1. kv_append ================= + +__global__ void kv_append_kernel(uint16_t* __restrict__ k_pool, uint16_t* __restrict__ v_pool, + const int32_t* __restrict__ table, const int32_t* __restrict__ step, + const float* __restrict__ kcur, const float* __restrict__ vcur, int kv_heads, + int head_dim, int page_size) { + const long long pos = (long long) __ldg(step + kStepPos); + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i >= kv_heads * head_dim) return; + const int h = i / head_dim, d = i - h * head_dim; + // `[page][kv_head][page_size][head_dim]`: one head's consecutive cells are contiguous inside a page + const long long page = (long long) table[pos / page_size]; + const long long row = (page * kv_heads + h) * page_size + (pos % page_size); + k_pool[row * head_dim + d] = f16_from_f32(kcur[i]); + v_pool[row * head_dim + d] = f16_from_f32(vcur[i]); +} + +// ================= 2. indexer_key_append ================= + +/// Appends the raw key, and on a block completion pools AND ROTATES the pooled row. One thread per indexer +/// dim, shared `mean` in double. +/// +/// **THE ROTATION IS INSIDE THIS KERNEL, AND THAT IS WHAT MAKES THE WHOLE FUNCTION CAPTURABLE.** It used to be +/// a second launch from the host wrapper, guarded by `if (pos % idx_block == idx_block - 1)` - a HOST branch on +/// `pos`, which forced `pos` to be a host scalar, which meant a captured graph would replay the first token's +/// position forever. Doing it here reads the position from `pos_dev` like every other kernel, so the grid and +/// the arguments are the same for every token and the graph replays correctly. +__global__ void indexer_key_append_kernel(const float* __restrict__ raw, const int32_t* __restrict__ pos_dev, + int pos_base, const float* __restrict__ w_k_norm, float eps, + float* __restrict__ tail, float* __restrict__ dead, + float* __restrict__ pooled, int32_t* __restrict__ block_pos, + int idx_dim, int r, int n_rot, const float* __restrict__ cos_tab, + const float* __restrict__ sin_tab, const int32_t* __restrict__ mtab) { + extern __shared__ double s_mean[]; // idx_dim doubles + const int d = threadIdx.x; + const int pos = (int) __ldg(pos_dev); + const int slot = pos % r; + + // The RAW tail: a cell that COMPLETES a block is not stored, because the pool below consumes it directly. + // That is what makes `idx_block - 1` rows enough. + if (slot < r - 1) tail[(size_t) slot * idx_dim + d] = raw[d]; + + // The spare slot's key is `rms_norm(raw[0])`, which is CONSTANT for the sequence and is the only reason + // cell 0's raw key has to be kept: the three-row tail no longer holds it after the first block. + if (pos == 0) { + double p = (double) raw[d]; + double ss = 0.0; + // Same order as the pooled path, so the two are comparable: sum of squares over the dims, sequential. + // Every thread computes the same `ss` and uses only its own output element. + // + // `__dadd_rn`/`__dmul_rn`, NOT `__fadd_rn`/`__fmul_rn`: the `f` names are the SINGLE-precision + // intrinsics, and using them on doubles silently rounds every operation to float. `ss` then carries + // only float precision (121.82236480712891 against the reference's 121.82236342931094) and the spare + // key comes out 0.4 f32 ulp away - close enough that only a BIT-EXACT assertion notices. `__ddiv_rn` + // and `__dsqrt_rn` pin the reciprocal square root for the same reason. + for (int i = 0; i < idx_dim; ++i) { + const double v = (double) raw[i]; + ss = __dadd_rn(ss, __dmul_rn(v, v)); + } + const double inv = __ddiv_rn(1.0, __dsqrt_rn(ss / (double) idx_dim + (double) eps)); + dead[d] = (float) (p * inv * (double) w_k_norm[d]); + // rope at position 0 is the identity here (cos = 1, sin = 0 exactly), so no rotation is applied and + // the parity test asserts the value is bit-exact - which is what makes skipping it legitimate. + pooled[d] = dead[d]; + } + + if (slot != r - 1) return; + __syncthreads(); // the tail rows this thread is about to read are written above + + // The mean of this block's r raw keys, in CELL order: the r-1 tail rows (slots 0..r-2) then the incoming + // cell (slot r-1). The sum of r f32 values is exact in double, so the mean's order cannot matter here - + // but the order of the SUM OF SQUARES below can, and it is fixed to d ascending on both sides. + double m = 0.0; + for (int j = 0; j < r - 1; ++j) m = __dadd_rn(m, (double) tail[(size_t) j * idx_dim + d]); + m = __dadd_rn(m, (double) raw[d]); + m = m / (double) r; + s_mean[d] = m; + __syncthreads(); + + double ss = 0.0; + for (int i = 0; i < idx_dim; ++i) ss = __dadd_rn(ss, __dmul_rn(s_mean[i], s_mean[i])); + const double inv = __ddiv_rn(1.0, __dsqrt_rn(ss / (double) idx_dim + (double) eps)); + + const int b = pos / r; + pooled[(size_t) b * idx_dim + d] = (float) (m * inv * (double) w_k_norm[d]); + // The spare slot MOVES to b+1 and is rewritten with the same constant value. + pooled[(size_t) (b + 1) * idx_dim + d] = dead[d]; + // The rotation position for this row, kept because a capture reader and the debug dumps want it. It is the + // block's first cell's POSITION, `pos_base + b*r`, and NOT the cell index `b*r`. + if (d == 0) *block_pos = (int32_t) (pos_base + b * r); + __syncthreads(); // the row is complete only now; the rotation below reads TWO elements of it + + // ---- the rotation of the row that just completed, IN PLACE. + // + // `i` pairs with `i + half`, so only the first `half` threads do any work - but EVERY thread must reach the + // barrier above, which is why the guard is here and not around the whole block's tail. + // + // The position is the BLOCK'S FIRST cell (`ref/qsa.py` PROPERTY 4: rotating at the block's last cell keeps + // every shape and every magnitude), and it comes from `pos_base`, which is constant for a sequence. + const int half = n_rot / 2; + if (d < half) { + float* row = pooled + (size_t) b * idx_dim; + const size_t toff = (size_t) mrope_pos(mtab, pos_base + b * r, d) * half; + // THE SAME `rope_neox_pair` THE STANDALONE ROPE KERNEL USES. A second transcription of the pairing is + // what this project keeps finding written down wrong. + rope_neox_pair(row[d], row[half + d], cos_tab[toff + d], sin_tab[toff + d], row[d], row[half + d]); + } +} + +// ================= 3. qsa_index ================= + +/// One warp per indexer head, one block per pooled row; thread 0 sums the relu'd dots IN HEAD ORDER and maps +/// the row's score onto its cells. The reference's Relu is PER HEAD, so the dots cannot be summed first. +__global__ void qsa_index_kernel(const float* __restrict__ pooled, + const float* __restrict__ q_idx, const float* __restrict__ bias, + int idx_n_head, int idx_dim, long long r, + const int32_t* __restrict__ step, float* __restrict__ cell_scores) { + const long long n_bid = (long long) __ldg(step + kStepNBid); + const long long n_kv = (long long) __ldg(step + kStepNKv); + __shared__ double s_dot[32]; + const long long b = blockIdx.x; + // A BLOCK PAST THE COMPLETED-BLOCK COUNT MUST DO NOTHING. This is what lets the LAYER launch a CONSTANT + // grid of the maximum block count instead of this token's `n_bid + 1`: a grid sized from a per-token value + // is a per-token LAUNCH CONFIGURATION, and a CUDA graph bakes those in exactly as it bakes in an argument. + // Without this guard, a capacity-sized grid would score pooled rows that have not been written yet - + // reading zeros and producing a plausible score for a block that does not exist. + if (b > n_bid) return; + const int wid = threadIdx.x >> 5, lane = threadIdx.x & 31; + if (wid == 0) s_dot[lane] = 0.0; // thread 0 reads all idx_n_head entries; zero the unused ones + __syncthreads(); + + double acc = 0.0; + for (int d = lane; d < idx_dim; d += 32) + acc = __dadd_rn(acc, __dmul_rn((double) pooled[(size_t) b * idx_dim + d], + (double) q_idx[(size_t) wid * idx_dim + d])); + for (int o = 16; o > 0; o >>= 1) acc = __dadd_rn(acc, __shfl_xor_sync(0xffffffffu, acc, o)); + if (lane == 0) s_dot[wid] = acc; + __syncthreads(); + + if (threadIdx.x != 0) return; + double score = 0.0; + for (int h = 0; h < idx_n_head; ++h) score += (s_dot[h] > 0.0) ? s_dot[h] : 0.0; + if (bias != nullptr) score += (double) bias[b]; + // cell_block: cells of block b for b < n_bid, and the WHOLE incomplete tail for b == n_bid + long long lo = b * r, hi = lo + r; + if (b == n_bid) hi = n_kv; + if (hi > n_kv) hi = n_kv; + // llama-memory-hybrid-idx.cpp::set_input_qsa gives incomplete-tail cells + // a finite +1e9 bias before top-k. Read the current step on every replay: + // the spare row becomes an ordinary block as soon as its fourth cell arrives. + float sc = (float) score; + if (b == n_bid && n_kv % r != 0) sc += 1e9f; + for (long long j = lo; j < hi; ++j) cell_scores[j] = sc; +} + +// ================= 4. topk_512 ================= + +/// Total order over f32 as an unsigned key, so one integer comparison orders scores with numpy's float +/// semantics. `+ 0.0f` maps -0.0 to +0.0 because numpy treats them as EQUAL (and would break the tie by +/// index), while the raw bit pattern orders them. A NaN is mapped to 0, below every real key, i.e. never +/// selected - numpy sorts NaN LAST, and silently selecting one would be worse than the disagreement. +__device__ __forceinline__ uint32_t order_key(float s) { + const float v = s + 0.0f; + if (!(v == v)) return 0u; + uint32_t b; + memcpy(&b, &v, 4); + return (b & 0x80000000u) ? ~b : (b | 0x80000000u); +} + +constexpr int TOPK_THREADS = 256; + +__global__ void topk_kernel(const float* __restrict__ scores, const int32_t* __restrict__ step, + int* __restrict__ out_ids) { + const long long n_kv = (long long) __ldg(step + kStepNKv); + const long long width = (long long) __ldg(step + kStepWidth); + __shared__ int s_a[TOPK_THREADS]; + __shared__ int s_b[TOPK_THREADS]; + __shared__ long long s_cgt; + const int t = threadIdx.x; + const long long chunk = (n_kv + TOPK_THREADS - 1) / TOPK_THREADS; + const long long lo = (long long) t * chunk; + long long hi = lo + chunk; + if (hi > n_kv) hi = n_kv; + + // ---- binary lifting: the largest key v with count(key >= v) >= width. Monotone in v, so setting the + // bits from the top down lands on exactly that value, and count(>= v) >= width > count(>= v+1). + uint32_t v = 0u; + for (int bit = 31; bit >= 0; --bit) { + const uint32_t cand = v | (1u << bit); + int c = 0; + for (long long j = lo; j < hi; ++j) if (order_key(scores[j]) >= cand) ++c; + s_a[t] = c; + __syncthreads(); + for (int s = TOPK_THREADS / 2; s > 0; s >>= 1) { + if (t < s) s_a[t] += s_a[t + s]; + __syncthreads(); + } + const int tot = s_a[0]; + __syncthreads(); // before the next round overwrites s_a + if (tot >= (int) width) v = cand; + } + const uint32_t thr = v; + + // ---- counts and their exclusive prefixes. The prefixes are computed by thread 0 in one walk, which is + // 256 iterations ONCE per kernel and removes 2 log-steps of scan machinery from a path that is already + // 32 passes deep. + int gt = 0, eq = 0; + for (long long j = lo; j < hi; ++j) { + const uint32_t k = order_key(scores[j]); + if (k > thr) ++gt; + else if (k == thr) ++eq; + } + s_a[t] = gt; + s_b[t] = eq; + __syncthreads(); + if (t == 0) { + long long ag = 0, ae = 0; + for (int i = 0; i < TOPK_THREADS; ++i) { + const int g = s_a[i], e = s_b[i]; + s_a[i] = (int) ag; + s_b[i] = (int) ae; + ag += g; + ae += e; + } + s_cgt = ag; // how many are strictly above the threshold, i.e. where the equals start + } + __syncthreads(); + // THE RANK VS THE COUNT. `off_eq` is how many equal-to-threshold cells come BEFORE this chunk, and it is + // compared against `eq_budget`, the number of them the selection has room for. The first version wrote + // `c_gt + prefix_eq` - where an equal cell would land in a "greater-than first, then equal" LAYOUT - and + // compared THAT against the budget. With distinct scores `c_gt == width` and `eq_budget == 0`, so nothing + // was ever selected at the threshold and the kernel emitted `width` ids with one missing and one stale. + // Every all-equal fixture passed, because there `c_gt == 0` and the two readings coincide. + const long long off_eq = s_b[t]; + const long long eq_budget = width - s_cgt; + + // ---- how many this chunk selects, so the ascending walk can be scattered into place + int sel = 0; + { + long long e = off_eq; + for (long long j = lo; j < hi; ++j) { + const uint32_t k = order_key(scores[j]); + if (k > thr) ++sel; + else if (k == thr && e < eq_budget) { ++sel; ++e; } + } + } + s_a[t] = sel; + __syncthreads(); + if (t == 0) { + long long a = 0; + for (int i = 0; i < TOPK_THREADS; ++i) { + const int c = s_a[i]; + s_a[i] = (int) a; + a += c; + } + } + __syncthreads(); + + // ---- emit, walking the cells ASCENDING so the tie rule's "first by index" is the walk order itself + long long w = s_a[t]; + long long e = off_eq; + for (long long j = lo; j < hi; ++j) { + const uint32_t k = order_key(scores[j]); + if (k > thr) out_ids[w++] = (int) j; + else if (k == thr && e < eq_budget) { ++e; out_ids[w++] = (int) j; } + } +} + +// ================= 5. kv_gather ================= + +__global__ void kv_gather_kernel(const uint16_t* __restrict__ k_pool, const uint16_t* __restrict__ v_pool, + const int32_t* __restrict__ table, const int32_t* __restrict__ ids, + const int32_t* __restrict__ step, int kv_heads, int head_dim, int page_size, + uint16_t* __restrict__ k_scratch, uint16_t* __restrict__ v_scratch) { + const long long n_ids = (long long) __ldg(step + kStepWidth); + const int per = head_dim / 4; // 4 halfs per uint2 + const long long total = n_ids * kv_heads * per; + const long long i = blockIdx.x * (long long) blockDim.x + threadIdx.x; + if (i >= total) return; + const long long id = i / (kv_heads * (long long) per); + const int rem = (int) (i % (kv_heads * (long long) per)); + const int h = rem / per, q = rem - h * per; + const int cell = ids[id]; + const long long page = (long long) table[cell / page_size]; + const long long src = ((page * kv_heads + h) * page_size + (cell % page_size)) * (long long) per + q; + const long long dst = (id * kv_heads + h) * (long long) per + q; + reinterpret_cast(k_scratch)[dst] = reinterpret_cast(k_pool)[src]; + reinterpret_cast(v_scratch)[dst] = reinterpret_cast(v_pool)[src]; +} + +// ================= 6. qsa_attend ================= + +__device__ __forceinline__ float warp_max(float v) { + for (int o = 16; o > 0; o >>= 1) v = fmaxf(v, __shfl_xor_sync(0xffffffffu, v, o)); + return v; +} + +__device__ __forceinline__ float warp_sum(float v) { + for (int o = 16; o > 0; o >>= 1) v += __shfl_xor_sync(0xffffffffu, v, o); + return v; +} + +/// One block per query head. `s` holds the scores, which are then overwritten with the softmax weights. +__global__ void qsa_attend_kernel(const float* __restrict__ q, const uint16_t* __restrict__ k_scratch, + const uint16_t* __restrict__ v_scratch, const int32_t* __restrict__ step, + int n_head, int n_head_kv, int head_dim, + float* __restrict__ attn, float* __restrict__ weights) { + const long long n_ids = (long long) __ldg(step + kStepWidth); + extern __shared__ float s_w[]; // CAPACITY floats (scores, then weights) + 32 for the reductions + float* red = s_w; // red[0..31] live past the scores/weights: see the launch + float* w = s_w + 32; + const int h = blockIdx.x; + const int d = threadIdx.x; + // AN EMPTY SELECTION IS HANDLED HERE, not by a host early return. The wrapper used to check `n_ids == 0` + // and `cudaMemsetAsync` the output, but `n_ids` is a device value now - so the branch has to be on the + // device, and this is also the only form a graph can replay: the launch is unconditional and the kernel + // decides. Below the selection bound the selection is the IDENTITY, so `n_ids` is never 0 there; it is + // reachable only for a genuinely empty cache, and the reference returns zeros for that. + if (n_ids == 0) { + for (int i = d; i < head_dim; i += blockDim.x) attn[(size_t) h * head_dim + i] = 0.0f; + return; + } + const int kv = h / (n_head / n_head_kv); // ops.cpp L8729: iv2 = iq2 / rv2, NOT h % n_head_kv + const float scale = 1.0f / sqrtf((float) head_dim); + const int lane = d & 31, wid = d >> 5, nwarp = (int) ((blockDim.x + 31) >> 5); + + for (long long j = d; j < n_ids; j += blockDim.x) { + const uint16_t* krow = k_scratch + (j * n_head_kv + kv) * head_dim; + float acc = 0.0f; + for (int i = 0; i < head_dim; ++i) acc += h2f(krow[i]) * q[(size_t) h * head_dim + i]; + w[j] = acc * scale; + } + __syncthreads(); + + float mx = -FLT_MAX; // not -inf, whose double -> float conversion nvcc warns about + for (long long j = d; j < n_ids; j += blockDim.x) mx = fmaxf(mx, w[j]); + mx = warp_max(mx); + if (lane == 0) red[wid] = mx; + __syncthreads(); + if (wid == 0) { + mx = (lane < nwarp) ? red[lane] : -FLT_MAX; + mx = warp_max(mx); + if (lane == 0) red[0] = mx; + } + __syncthreads(); + mx = red[0]; + __syncthreads(); + + float sum = 0.0f; + for (long long j = d; j < n_ids; j += blockDim.x) { + const float e = expf(w[j] - mx); + w[j] = e; + sum += e; + } + sum = warp_sum(sum); + if (lane == 0) red[wid] = sum; + __syncthreads(); + if (wid == 0) { + sum = (lane < nwarp) ? red[lane] : 0.0f; + sum = warp_sum(sum); + if (lane == 0) red[0] = 1.0f / sum; + } + __syncthreads(); + const float inv = red[0]; + __syncthreads(); + + float acc = 0.0f; + for (long long j = 0; j < n_ids; ++j) + acc += (w[j] * inv) * h2f(v_scratch[(j * n_head_kv + kv) * head_dim + d]); + attn[(size_t) h * head_dim + d] = acc; + if (weights != nullptr) { + for (long long j = d; j < n_ids; j += blockDim.x) weights[(size_t) h * n_ids + j] = w[j] * inv; + } +} + +// ================= 7. qsa_gate_apply ================= + +__global__ void qsa_gate_apply_kernel(const float* __restrict__ attn, const float* __restrict__ q_full, + int n_head, int head_dim, uint16_t* __restrict__ out) { + const long long i = blockIdx.x * (long long) blockDim.x + threadIdx.x; + if (i >= (long long) n_head * head_dim) return; + const int h = (int) (i / head_dim), d = (int) (i % head_dim); + const double g = (double) q_full[((size_t) h * 2 * head_dim) + head_dim + d]; // the SECOND half + const double sig = 1.0 / (1.0 + exp(-g)); + out[i] = f16_from_f32((float) ((double) attn[i] * sig)); +} + +inline unsigned grid_for(long long n, int threads) { + return (unsigned) ((n + threads - 1) / threads); +} + +} // namespace + +/// THE STEP STATE, UPLOADED. Every kernel below reads its per-token counts from here rather than from a kernel +/// argument, because a CUDA graph bakes arguments in at capture time - see the long note in `qsa.hpp`. This +/// module-level buffer is what lets the OLD host-scalar signatures keep working unchanged while the kernels +/// already read the device values; the layer will pass its own buffer once it captures. +/// +/// It is per-PROCESS and reused, which is safe because the upload and the kernel that reads it are on the same +/// stream and the host is the only writer. `cudaMemcpyAsync` on the null stream is ordered against the null +/// stream's kernels, which is what every direct-launch caller here uses. +namespace { + +int32_t* step_scratch() { + static int32_t* d_step = nullptr; + if (d_step == nullptr) { + if (cudaMalloc(&d_step, qsa_step_bytes()) != cudaSuccess) { + std::fprintf(stderr, "qsa: step upload: cudaMalloc failed\n"); + std::exit(1); + } + } + return d_step; +} + +void step_upload_raw(const int32_t* h_step) { + int32_t* d = step_scratch(); + if (cudaMemcpy(d, h_step, qsa_step_bytes(), cudaMemcpyHostToDevice) != cudaSuccess) { + std::fprintf(stderr, "qsa: step upload: cudaMemcpy failed\n"); + std::exit(1); + } +} + +/// From a POSITION: fills all four entries. This is the form the layer will use, and the only form that cannot +/// produce an inconsistent `n_kv`/`n_bid`/`width`. +const int32_t* step_upload(int64_t pos, int64_t n_kv_hint, const QsaShapes& s) { + int32_t h[kStepCount]; + qsa_step_fill(h, pos, s); + // A caller that also knows `n_kv` is CHECKED against it rather than believed: a disagreement is exactly the + // inconsistency `qsa_step_fill` exists to make impossible, so it is worth an exit rather than a silent + // preference for one of the two. + if (n_kv_hint >= 0 && n_kv_hint != (int64_t) h[kStepNKv]) { + std::fprintf(stderr, "qsa: step upload: n_kv %lld disagrees with pos+1 = %d\n", (long long) n_kv_hint, + h[kStepNKv]); + std::exit(1); + } + step_upload_raw(h); + return step_scratch(); +} + +/// From a WIDTH. `kv_gather` and `qsa_attend` are handed the selection width directly and never see `pos`, so +/// only that entry is meaningful for them; the others are set consistently anyway rather than left stale, +/// because a stale entry is a value the NEXT kernel might read. +const int32_t* step_upload_width(int64_t width, const QsaShapes& s) { + int32_t h[kStepCount]; + for (int i = 0; i < kStepCount; ++i) h[i] = 0; + h[kStepWidth] = (int32_t) width; + (void) s; + step_upload_raw(h); + return step_scratch(); +} + +} // namespace + +// ================= THE CAPTURABLE ENTRY POINTS ================= +// +// These are the REAL implementations: every launch below is sized from a constant capacity and every per-token +// count comes from `step`. The host-scalar entry points further down are wrappers that fill a module-level step +// buffer and forward this token's counts as the capacities. + +void kv_append_step(uint16_t* k_pool, uint16_t* v_pool, const int32_t* page_table, const int32_t* step, + const float* kcur, const float* vcur, const QsaShapes& s, void* stream) { + validate(s, "kv_append"); + if (step == nullptr) fail("kv_append: step is null"); + // The grid is the head x dim count, which is a CONSTANT - `kv_append` writes one cell. + const long long n = s.n_head_kv * s.head_dim; + kv_append_kernel<<>>( + k_pool, v_pool, page_table, step, kcur, vcur, (int) s.n_head_kv, (int) s.head_dim, (int) s.page_size); + check_launch("kv_append"); + if (stream == nullptr) check_sync("kv_append"); +} + +void qsa_index_step(const float* pooled, const float* q_idx, const float* bias, const QsaShapes& s, + const int32_t* step, int64_t max_blocks, float* cell_scores, void* stream) { + validate(s, "qsa_index"); + if (step == nullptr) fail("qsa_index: step is null"); + if (max_blocks <= 0) fail("qsa_index: max_blocks must be positive"); + // THE GRID IS `max_blocks`, A CONSTANT - not this token's `n_bid + 1`. The kernel returns for + // `b > n_bid`, so the surplus blocks cost a launch and nothing else. + const int threads = 32 * (int) s.idx_n_head; + qsa_index_kernel<<<(unsigned) max_blocks, threads, 0, (cudaStream_t) stream>>>( + pooled, q_idx, bias, (int) s.idx_n_head, (int) s.idx_dim, s.idx_block, step, cell_scores); + check_launch("qsa_index"); + if (stream == nullptr) check_sync("qsa_index"); +} + +void topk_512_step(const float* cell_scores, const QsaShapes& s, int64_t cap, const int32_t* step, + int32_t* ids, void* stream) { + validate(s, "topk_512"); + if (step == nullptr) fail("topk_512: step is null"); + // `cap` is checked against the GEOMETRY'S largest possible width rather than this token's, because this + // token's is a device value. The two are the same guarantee: `width = min(n_kv, top_k + r - 1)` and + // `n_kv <= kTopkMaxCells`, so this is the most the kernel can ever write. + const int64_t width_max = qsa_selection_width(kTopkMaxCells, s); + if (cap < width_max) { + std::fprintf(stderr, "qsa: topk_512: cap %lld < the largest possible selection width %lld\n", + (long long) cap, (long long) width_max); + std::exit(1); + } + // One block, so the launch is already constant; the kernel reads `n_kv` and `width` from `step`. + topk_kernel<<<1, TOPK_THREADS, 0, (cudaStream_t) stream>>>(cell_scores, step, ids); + check_launch("topk_512"); + if (stream == nullptr) check_sync("topk_512"); +} + +void kv_gather_step(const uint16_t* k_pool, const uint16_t* v_pool, const int32_t* page_table, + const int32_t* ids, const int32_t* step, int64_t max_ids, const QsaShapes& s, + uint16_t* k_scratch, uint16_t* v_scratch, void* stream) { + validate(s, "kv_gather"); + if (step == nullptr) fail("kv_gather: step is null"); + if (max_ids <= 0) return; + // THE GRID IS THE CAPACITY. The kernel reads the real `n_ids` from `step` and its `if (i >= total) return;` + // guard leaves the surplus threads idle. + const long long total = max_ids * s.n_head_kv * (s.head_dim / 4); + kv_gather_kernel<<>>( + k_pool, v_pool, page_table, ids, step, (int) s.n_head_kv, (int) s.head_dim, (int) s.page_size, + k_scratch, v_scratch); + check_launch("kv_gather"); + if (stream == nullptr) check_sync("kv_gather"); +} + +void qsa_attend_step(const float* q, const uint16_t* k_scratch, const uint16_t* v_scratch, + const int32_t* step, int64_t max_ids, const QsaShapes& s, float* attn, float* weights, + void* stream) { + validate(s, "qsa_attend"); + if (step == nullptr) fail("qsa_attend: step is null"); + if (max_ids <= 0) fail("qsa_attend: max_ids must be positive"); + // THE SHARED SIZE IS THE CAPACITY. Shared memory is part of the LAUNCH CONFIGURATION, so sizing it from + // this token's `n_ids` bakes the sequence length in: a captured layer would allocate room for token 1 and + // then index past it at token 500. The kernel reads the real count from `step`, so a larger allocation is + // exactly what it wants. The opt-in therefore happens once, for the capacity. + const size_t smem = (size_t) (max_ids + 32) * sizeof(float); + static size_t s_configured = 0; + if (smem > s_configured) { + int dev = 0, max_shared = 0; + cudaGetDevice(&dev); + cudaDeviceGetAttribute(&max_shared, cudaDevAttrMaxSharedMemoryPerBlockOptin, dev); + if ((int) smem > max_shared) { + std::fprintf(stderr, "qsa: qsa_attend: max_ids %lld needs %zu B of shared, over the %d B limit\n", + (long long) max_ids, smem, max_shared); + std::exit(1); + } + const cudaError_t e = + cudaFuncSetAttribute(qsa_attend_kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, (int) smem); + if (e != cudaSuccess) { + std::fprintf(stderr, "qsa: qsa_attend: shared opt-in: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + s_configured = smem; + } + qsa_attend_kernel<<<(unsigned) s.n_head, (unsigned) s.head_dim, smem, (cudaStream_t) stream>>>( + q, k_scratch, v_scratch, step, (int) s.n_head, (int) s.n_head_kv, (int) s.head_dim, attn, weights); + check_launch("qsa_attend"); + if (stream == nullptr) check_sync("qsa_attend"); +} + +// ================= host entry points ================= + +void qsa_step_fill(int32_t* host_step, int64_t pos, const QsaShapes& s) { + if (host_step == nullptr) return; + if (pos < 0) fail("qsa_step_fill: pos < 0"); + // ONE PLACE COMPUTES ALL FOUR, which is the whole reason the step state is a BUFFER rather than four + // separate parameters. `n_kv`, `n_bid` and `width` are DERIVED from `pos`, so a caller filling them in + // itself could fill them inconsistently - and `qsa_index`'s host guard against exactly that inconsistency + // now has a single writer to check against instead of two independent arguments. + // + // This is the form the LAYER will use: every value a QSA kernel needs derives from one position, so one + // H2D per token updates all of them and no entry point takes a per-token scalar. + const int64_t n_kv = pos + 1; + host_step[kStepPos] = (int32_t) pos; + host_step[kStepNKv] = (int32_t) n_kv; + host_step[kStepNBid] = (int32_t) (n_kv / s.idx_block); + host_step[kStepWidth] = (int32_t) qsa_selection_width(n_kv, s); +} + +void kv_append(uint16_t* k_pool, uint16_t* v_pool, const int32_t* page_table, int64_t pos, + const float* kcur, const float* vcur, const QsaShapes& s, void* stream) { + if (pos < 0) { validate(s, "kv_append"); fail("kv_append: pos < 0"); } + kv_append_step(k_pool, v_pool, page_table, step_upload(pos, -1, s), kcur, vcur, s, stream); +} + +void indexer_key_append(const float* raw, const int32_t* pos_dev, int32_t pos_base, const float* w_k_norm, + float eps, const QsaIndexerBuffers& b, const QsaShapes& s, const float* cos_tab, + const float* sin_tab, void* stream) { + validate(s, "indexer_key_append"); + if (pos_dev == nullptr) fail("indexer_key_append: pos_dev is null"); + if (b.tail == nullptr || b.dead == nullptr || b.pooled == nullptr || b.block_pos == nullptr) + fail("indexer_key_append: the indexer buffers are not all set (tail/dead/pooled/block_pos)"); + const int threads = (int) s.idx_dim; + const size_t smem = (size_t) s.idx_dim * sizeof(double); + // **ONE LAUNCH, NO HOST BRANCH ON THE POSITION.** The completion rotation is inside the kernel now. The + // arguments here are identical for every token, which is the property a CUDA graph needs: this function can + // be captured and replayed, and the position arrives through `pos_dev` like every other kernel's data. + indexer_key_append_kernel<<<1, threads, smem, (cudaStream_t) stream>>>( + raw, pos_dev, (int) pos_base, w_k_norm, eps, b.tail, b.dead, b.pooled, b.block_pos, (int) s.idx_dim, + (int) s.idx_block, (int) s.n_rot, cos_tab, sin_tab, mrope_table()); + check_launch("indexer_key_append"); + if (stream == nullptr) check_sync("indexer_key_append"); +} + +void qsa_index(const float* pooled, int64_t n_bid, const float* q_idx, const float* bias, const QsaShapes& s, + int64_t n_kv, float* cell_scores, void* stream) { + validate(s, "qsa_index"); + if (n_bid < 0 || n_kv <= 0) fail("qsa_index: n_bid < 0 or n_kv <= 0"); + // `n_kv`, `n_bid` and `width` are DERIVED from one position, so this refuses a caller whose two arguments + // disagree rather than launching a kernel that would score the wrong number of blocks. + if ((n_kv / s.idx_block) != n_bid) { + std::fprintf(stderr, "qsa: qsa_index: n_bid %lld is not n_kv %lld / r %lld\n", (long long) n_bid, + (long long) n_kv, (long long) s.idx_block); + std::exit(1); + } + // THIS TOKEN'S counts as the CAPACITIES: correct for a direct launch, WRONG in a graph. The layer passes a + // constant `max_blocks` from its state instead - see the note in `qsa.hpp`. + qsa_index_step(pooled, q_idx, bias, s, step_upload(n_kv - 1, n_kv, s), n_bid + 1, cell_scores, stream); +} + +void topk_512(const float* cell_scores, int64_t n_kv, const QsaShapes& s, int64_t cap, int32_t* ids, + void* stream) { + validate(s, "topk_512"); + if (n_kv <= 0) return; + if (n_kv > kTopkMaxCells) { + std::fprintf(stderr, "qsa: topk_512: n_kv %lld > kTopkMaxCells %lld (one block; phase 3 replaces this)\n", + (long long) n_kv, (long long) kTopkMaxCells); + std::exit(1); + } + topk_512_step(cell_scores, s, cap, step_upload(n_kv - 1, n_kv, s), ids, stream); +} + +void kv_gather(const uint16_t* k_pool, const uint16_t* v_pool, const int32_t* page_table, const int32_t* ids, + int64_t n_ids, const QsaShapes& s, uint16_t* k_scratch, uint16_t* v_scratch, void* stream) { + validate(s, "kv_gather"); + if (n_ids <= 0) return; + kv_gather_step(k_pool, v_pool, page_table, ids, step_upload_width(n_ids, s), n_ids, s, k_scratch, + v_scratch, stream); +} + +void qsa_attend(const float* q, const uint16_t* k_scratch, const uint16_t* v_scratch, int64_t n_ids, + const QsaShapes& s, float* attn, float* weights, void* stream) { + validate(s, "qsa_attend"); + if (n_ids < 0) fail("qsa_attend: n_ids < 0"); + if (n_ids == 0) { + // The empty selection goes through the kernel's own zero path; `max_ids` must still be positive, so the + // wrapper gives it the geometry's capacity rather than 0. + qsa_attend_step(q, k_scratch, v_scratch, step_upload_width(0, s), + qsa_selection_width(kTopkMaxCells, s), s, attn, weights, stream); + return; + } + qsa_attend_step(q, k_scratch, v_scratch, step_upload_width(n_ids, s), n_ids, s, attn, weights, stream); +} + +/// The same multiply as `qsa_gate_apply_kernel` WITHOUT the fp16 round - see the header for why the caller +/// whose weight is a K-quant needs the f32 form. Identical arithmetic up to the last step. +__global__ void qsa_gate_apply_f32_kernel(const float* __restrict__ attn, const float* __restrict__ q_full, + int n_head, int head_dim, float* __restrict__ out) { + const long long i = blockIdx.x * (long long) blockDim.x + threadIdx.x; + if (i >= (long long) n_head * head_dim) return; + const int h = (int) (i / head_dim), d = (int) (i % head_dim); + const double g = (double) q_full[((size_t) h * 2 * head_dim) + head_dim + d]; // the SECOND half + const double sig = 1.0 / (1.0 + exp(-g)); + out[i] = (float) ((double) attn[i] * sig); +} + +void qsa_gate_apply_f32(const float* attn, const float* q_full, const QsaShapes& s, float* out, void* stream) { + validate(s, "qsa_gate_apply_f32"); + const long long n = s.n_head * s.head_dim; + qsa_gate_apply_f32_kernel<<>>(attn, q_full, (int) s.n_head, + (int) s.head_dim, out); + check_launch("qsa_gate_apply_f32"); + if (stream == nullptr) check_sync("qsa_gate_apply_f32"); +} + +void qsa_gate_apply(const float* attn, const float* q_full, const QsaShapes& s, uint16_t* out, void* stream) { + validate(s, "qsa_gate_apply"); + const long long n = s.n_head * s.head_dim; + qsa_gate_apply_kernel<<>>(attn, q_full, (int) s.n_head, + (int) s.head_dim, out); + check_launch("qsa_gate_apply"); + if (stream == nullptr) check_sync("qsa_gate_apply"); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/qsa_decode_attn.cu b/src/kernels/cuda/qsa_decode_attn.cu new file mode 100644 index 00000000..9fe92b6a --- /dev/null +++ b/src/kernels/cuda/qsa_decode_attn.cu @@ -0,0 +1,242 @@ +// src/kernels/cuda/qsa_decode_attn.cu - see include/strata/kernels/qsa_decode_attn.hpp. +#include "strata/kernels/qsa_decode_attn.hpp" +#include "strata/kernels/kv_q8.hpp" + +#include +#include + +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int HD = 256; // head_dim +constexpr int G = 12; // query heads per KV head (24 / 2) +constexpr int CHUNK = 64; // cells per block +constexpr int THREADS = 256; +constexpr int WARPS = THREADS / 32; + +__device__ __forceinline__ float warp_sum(float v) { +#pragma unroll + for (int o = 16; o > 0; o >>= 1) v += __shfl_xor_sync(0xffffffffu, v, o); + return v; +} +__device__ __forceinline__ float warp_max(float v) { +#pragma unroll + for (int o = 16; o > 0; o >>= 1) v = fmaxf(v, __shfl_xor_sync(0xffffffffu, v, o)); + return v; +} + +// 8 consecutive values of one cell's key or value row for KV head `kvh`, dimensions [d0, d0+8). +template +__device__ __forceinline__ void load8(const QsaAttnPools& p, bool value, long long row, int d0, float* out) { + if constexpr (!INT8) { + const uint16_t* base = (value ? p.v_pool : p.k_pool) + row * HD + d0; + const uint4 raw = *reinterpret_cast(base); + const __half2* h2 = reinterpret_cast(&raw); +#pragma unroll + for (int j = 0; j < 4; ++j) { + const float2 f = __half22float2(h2[j]); + out[2 * j] = f.x; + out[2 * j + 1] = f.y; + } + } else { + const int8_t* codes = (value ? p.v_q : p.k_q) + row * HD + d0; + const uint16_t sbits = (value ? p.v_scale : p.k_scale)[row * (HD / KV_Q8_GROUP) + d0 / KV_Q8_GROUP]; + const float sc = __half2float(__ushort_as_half(sbits)); + const uint2 raw = *reinterpret_cast(codes); + const int8_t* c = reinterpret_cast(&raw); +#pragma unroll + for (int j = 0; j < 8; ++j) out[j] = (float) c[j] * sc; + } +} + +template +__global__ void __launch_bounds__(THREADS) attn_chunk_kernel(const float* __restrict__ q, QsaAttnPools p, + const int32_t* __restrict__ ids, + const int32_t* __restrict__ step, int n_kv_heads, + int page_size, float scale, float* __restrict__ part_acc, + float* __restrict__ part_m, float* __restrict__ part_l, + int n_chunks, int cap = 0, long long scratch_stride = 0) { + // batched form: query blockIdx.z, with its own q row, selection, step and scratch + q += (size_t) blockIdx.z * (size_t) (n_kv_heads * G) * HD; + ids += (size_t) blockIdx.z * (size_t) cap; + step += (size_t) blockIdx.z * kStepCount; + part_acc += (size_t) blockIdx.z * (size_t) scratch_stride; + part_m += (size_t) blockIdx.z * (size_t) scratch_stride; + part_l += (size_t) blockIdx.z * (size_t) scratch_stride; + __shared__ __align__(16) float sq[G][HD]; // 12 KB: this KV head's query heads + __shared__ float sp[G][CHUNK]; // scores, then probabilities + __shared__ long long srow[CHUNK]; // pool row of each cell (page, kv head, slot) + const int n_ids = __ldg(step + kStepWidth); + const int chunk = blockIdx.x, kvh = blockIdx.y; + const int t = threadIdx.x, lane = t & 31, warp = t >> 5; + const int c0 = chunk * CHUNK; + const int n_here = min(CHUNK, n_ids - c0); + const int slot = kvh * n_chunks + chunk; + if (n_here <= 0) { + if (t < G) { part_m[slot * G + t] = -FLT_MAX; part_l[slot * G + t] = 0.0f; } + return; + } + for (int i = t; i < G * HD; i += THREADS) sq[i / HD][i % HD] = q[(size_t) (kvh * G) * HD + i]; + if (t < CHUNK) { + long long r = -1; + if (t < n_here) { + const int cell = ids[c0 + t]; + const long long page = (long long) p.page_table[cell / page_size]; + r = (page * n_kv_heads + kvh) * page_size + (cell % page_size); + } + srow[t] = r; + } + __syncthreads(); + // scores: each warp takes cells warp, warp+8, ...; each lane holds 8 of the 256 dimensions. + for (int c = warp; c < CHUNK; c += WARPS) { + if (c >= n_here) { + if (lane < G) sp[lane][c] = -FLT_MAX; + continue; + } + float k8[8]; + load8(p, false, srow[c], lane * 8, k8); +#pragma unroll + for (int h = 0; h < G; ++h) { + const float4 qa = *reinterpret_cast(&sq[h][lane * 8]); + const float4 qb = *reinterpret_cast(&sq[h][lane * 8 + 4]); + float s = k8[0] * qa.x + k8[1] * qa.y + k8[2] * qa.z + k8[3] * qa.w + + k8[4] * qb.x + k8[5] * qb.y + k8[6] * qb.z + k8[7] * qb.w; + s = warp_sum(s); + if (lane == 0) sp[h][c] = s * scale; + } + } + __syncthreads(); + // per-head chunk max and exp-sum: warp w handles heads w and w+8. + for (int h = warp; h < G; h += WARPS) { + const float a = sp[h][lane], b = sp[h][lane + 32]; + const float m = warp_max(fmaxf(a, b)); + const float ea = (lane < n_here) ? __expf(a - m) : 0.0f; + const float eb = (lane + 32 < n_here) ? __expf(b - m) : 0.0f; + sp[h][lane] = ea; + sp[h][lane + 32] = eb; + const float l = warp_sum(ea + eb); + if (lane == 0) { part_m[slot * G + h] = m; part_l[slot * G + h] = l; } + } + __syncthreads(); + // values: thread t owns dimension t for all 12 heads. + float acc[G]; +#pragma unroll + for (int h = 0; h < G; ++h) acc[h] = 0.0f; + for (int c = 0; c < n_here; ++c) { + float v; + if constexpr (!INT8) { + v = __half2float(__ushort_as_half(p.v_pool[srow[c] * HD + t])); + } else { + const float sc = __half2float(__ushort_as_half(p.v_scale[srow[c] * (HD / KV_Q8_GROUP) + t / KV_Q8_GROUP])); + v = (float) p.v_q[srow[c] * HD + t] * sc; + } +#pragma unroll + for (int h = 0; h < G; ++h) acc[h] = fmaf(sp[h][c], v, acc[h]); + } +#pragma unroll + for (int h = 0; h < G; ++h) part_acc[((size_t) slot * G + h) * HD + t] = acc[h]; +} + +__global__ void __launch_bounds__(HD) attn_merge_kernel(const float* __restrict__ part_acc, + const float* __restrict__ part_m, + const float* __restrict__ part_l, int n_chunks, + float* __restrict__ attn, long long scratch_stride = 0) { + part_acc += (size_t) blockIdx.y * (size_t) scratch_stride; + part_m += (size_t) blockIdx.y * (size_t) scratch_stride; + part_l += (size_t) blockIdx.y * (size_t) scratch_stride; + attn += (size_t) blockIdx.y * (size_t) gridDim.x * HD; + const int h = blockIdx.x; // global query head + const int kvh = h / G, hl = h % G; + const int d = threadIdx.x; + float M = -FLT_MAX; + for (int c = 0; c < n_chunks; ++c) M = fmaxf(M, part_m[(kvh * n_chunks + c) * G + hl]); + float L = 0.0f, acc = 0.0f; + for (int c = 0; c < n_chunks; ++c) { + const int slot = kvh * n_chunks + c; + const float m = part_m[slot * G + hl]; + if (m == -FLT_MAX) continue; + const float w = __expf(m - M); + L = fmaf(part_l[slot * G + hl], w, L); + acc = fmaf(part_acc[((size_t) slot * G + hl) * HD + d], w, acc); + } + attn[(size_t) h * HD + d] = L > 0.0f ? acc / L : 0.0f; +} + +} // namespace + +void qsa_decode_attn_batch(const float* q, const QsaAttnPools& pools, const int32_t* ids, const int32_t* steps, + int64_t cap, const QsaShapes& s, float* scratch, float* attn, int64_t n_q, void* stream) { + if (n_q <= 0) return; + if (s.head_dim != HD || s.n_head != (int64_t) G * s.n_head_kv || cap <= 0 || !scratch || !ids || !steps || + !pools.page_table || n_q > 65535) { + std::fprintf(stderr, "qsa_decode_attn_batch: unsupported geometry or missing buffers\n"); + std::exit(1); + } + const bool int8 = pools.k_q != nullptr; + const int n_chunks = (int) ((cap + CHUNK - 1) / CHUNK); + // per query: [acc: n_chunks*n_head*HD][m: n_chunks*n_head][l: n_chunks*n_head], all offsets from one stride + const long long stride = (long long) qsa_decode_attn_scratch_floats(cap, s); + float* part_acc = scratch; + float* part_m = scratch + (size_t) n_chunks * s.n_head * HD; + float* part_l = part_m + (size_t) n_chunks * s.n_head; + const float scale = 1.0f / sqrtf((float) HD); + const dim3 grid((unsigned) n_chunks, (unsigned) s.n_head_kv, (unsigned) n_q); + cudaStream_t st = (cudaStream_t) stream; + if (int8) + attn_chunk_kernel<<>>(q, pools, ids, steps, (int) s.n_head_kv, (int) s.page_size, + scale, part_acc, part_m, part_l, n_chunks, (int) cap, stride); + else + attn_chunk_kernel<<>>(q, pools, ids, steps, (int) s.n_head_kv, (int) s.page_size, + scale, part_acc, part_m, part_l, n_chunks, (int) cap, stride); + attn_merge_kernel<<>>(part_acc, part_m, part_l, n_chunks, + attn, stride); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "qsa_decode_attn_batch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +uint64_t qsa_decode_attn_scratch_floats(int64_t cap, const QsaShapes& s) { + const int64_t chunks = (cap + CHUNK - 1) / CHUNK; + return (uint64_t) chunks * (uint64_t) s.n_head * (HD + 2) + 64; +} + +void qsa_decode_attn_step(const float* q, const QsaAttnPools& pools, const int32_t* ids, const int32_t* step, + int64_t cap, const QsaShapes& s, float* scratch, float* attn, void* stream) { + if (s.head_dim != HD || s.n_head != (int64_t) G * s.n_head_kv || cap <= 0 || !scratch || !ids || !step || + !pools.page_table) { + std::fprintf(stderr, "qsa_decode_attn: unsupported geometry or missing buffers\n"); + std::exit(1); + } + const bool int8 = pools.k_q != nullptr; + if (int8 ? (!pools.v_q || !pools.k_scale || !pools.v_scale) : (!pools.k_pool || !pools.v_pool)) { + std::fprintf(stderr, "qsa_decode_attn: incomplete KV pools\n"); + std::exit(1); + } + const int n_chunks = (int) ((cap + CHUNK - 1) / CHUNK); + float* part_acc = scratch; + float* part_m = scratch + (size_t) n_chunks * s.n_head * HD; + float* part_l = part_m + (size_t) n_chunks * s.n_head; + const float scale = 1.0f / sqrtf((float) HD); + const dim3 grid((unsigned) n_chunks, (unsigned) s.n_head_kv); + cudaStream_t st = (cudaStream_t) stream; + if (int8) + attn_chunk_kernel<<>>(q, pools, ids, step, (int) s.n_head_kv, (int) s.page_size, + scale, part_acc, part_m, part_l, n_chunks); + else + attn_chunk_kernel<<>>(q, pools, ids, step, (int) s.n_head_kv, (int) s.page_size, + scale, part_acc, part_m, part_l, n_chunks); + attn_merge_kernel<<<(unsigned) s.n_head, HD, 0, st>>>(part_acc, part_m, part_l, n_chunks, attn); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "qsa_decode_attn: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/qsa_select.cu b/src/kernels/cuda/qsa_select.cu new file mode 100644 index 00000000..6b13b2ff --- /dev/null +++ b/src/kernels/cuda/qsa_select.cu @@ -0,0 +1,179 @@ +// src/kernels/cuda/qsa_select.cu - see include/strata/kernels/qsa_select.hpp. +#include "strata/kernels/qsa_select.hpp" + +#include + +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int IDX_DIM = 128, IDX_HEADS = 4, R = 4; +constexpr int SCORE_WARPS = 8; +constexpr int TOPK_T = 256; + +__device__ __forceinline__ uint32_t order_key(float s) { + const float v = s + 0.0f; + if (!(v == v)) return 0u; + const uint32_t b = __float_as_uint(v); + return (b & 0x80000000u) ? ~b : (b | 0x80000000u); +} + +__global__ void __launch_bounds__(SCORE_WARPS * 32) block_scores_kernel(const float* __restrict__ pooled, + const float* __restrict__ dead, + const float* __restrict__ q_idx, + const int32_t* __restrict__ steps, + int64_t max_blocks, float* __restrict__ out) { + const int64_t qi = blockIdx.y; + const int32_t* st = steps + qi * kStepCount; + const int64_t n_kv = st[kStepNKv], n_bid = st[kStepNBid]; + const int64_t b = (int64_t) blockIdx.x * SCORE_WARPS + (threadIdx.x >> 5); + if (b > n_bid || b >= max_blocks) return; + const int lane = threadIdx.x & 31; + const float* key = (b == n_bid) ? dead : pooled + b * IDX_DIM; + const float4 k4 = *reinterpret_cast(key + lane * 4); + const float* q = q_idx + qi * IDX_HEADS * IDX_DIM + lane * 4; + float score = 0.0f; +#pragma unroll + for (int h = 0; h < IDX_HEADS; ++h) { + const float4 q4 = *reinterpret_cast(q + h * IDX_DIM); + float d = k4.x * q4.x + k4.y * q4.y + k4.z * q4.z + k4.w * q4.w; +#pragma unroll + for (int o = 16; o > 0; o >>= 1) d += __shfl_xor_sync(0xffffffffu, d, o); + score += d > 0.0f ? d : 0.0f; + } + if (lane == 0) { + if (b == n_bid && n_kv % R != 0) score += 1e9f; + out[qi * max_blocks + b] = score; + } +} + +__global__ void __launch_bounds__(TOPK_T) block_topk_kernel(const float* __restrict__ scores, + const int32_t* __restrict__ steps, int64_t max_blocks, + int64_t cap, int32_t* __restrict__ ids) { + __shared__ int hist[256]; + __shared__ int s_a[TOPK_T], s_b[TOPK_T]; + __shared__ int s_digit, s_above; + const int64_t qi = blockIdx.x; + const int32_t* st = steps + qi * kStepCount; + const int64_t n_kv = st[kStepNKv], n_bid = st[kStepNBid], width = st[kStepWidth]; + int32_t* out = ids + qi * cap; + const int t = threadIdx.x; + if (n_kv <= width) { // everything is selected: the identity, ascending + for (int64_t j = t; j < n_kv; j += TOPK_T) out[j] = (int32_t) j; + return; + } + const float* sc = scores + qi * max_blocks; + const int64_t nb = n_bid + 1; // blocks 0..n_bid, the last possibly empty + const int64_t per = (nb + TOPK_T - 1) / TOPK_T; + const int64_t b0 = (int64_t) t * per, b1 = (b0 + per < nb) ? b0 + per : nb; + auto weight = [&](int64_t b) -> int { return b < n_bid ? R : (int) (n_kv - n_bid * R); }; + // ---- radix select: the largest key thr with (cells with key >= thr) >= width, 8 bits at a time + uint32_t prefix = 0; + int above = 0; // cells strictly above the digits fixed so far + for (int shift = 24; shift >= 0; shift -= 8) { + for (int i = t; i < 256; i += TOPK_T) hist[i] = 0; + __syncthreads(); + const uint32_t hi_mask = shift == 24 ? 0u : (0xffffffffu << (shift + 8)); + for (int64_t b = b0; b < b1; ++b) { + const int w = weight(b); + if (w == 0) continue; + const uint32_t k = order_key(sc[b]); + if ((k & hi_mask) == (prefix & hi_mask)) atomicAdd(&hist[(k >> shift) & 255], w); + } + __syncthreads(); + if (t == 0) { + int cum = above, d = 255; + for (; d > 0; --d) { + if (cum + hist[d] >= width) break; + cum += hist[d]; + } + s_digit = d; + s_above = cum; + } + __syncthreads(); + prefix |= (uint32_t) s_digit << shift; + above = s_above; + __syncthreads(); + } + const uint32_t thr = prefix; + const int64_t eq_budget = width - above; // cells equal to thr that fit, lowest index first + // ---- per-thread counts of cells above and at the threshold, then their exclusive prefixes + int gt = 0, eq = 0; + for (int64_t b = b0; b < b1; ++b) { + const int w = weight(b); + if (w == 0) continue; + const uint32_t k = order_key(sc[b]); + if (k > thr) gt += w; + else if (k == thr) eq += w; + } + s_a[t] = gt; + s_b[t] = eq; + __syncthreads(); + if (t == 0) { + int ag = 0, ae = 0; + for (int i = 0; i < TOPK_T; ++i) { + const int g = s_a[i], e = s_b[i]; + s_a[i] = ag; s_b[i] = ae; + ag += g; ae += e; + } + } + __syncthreads(); + const int64_t eq_before = s_b[t]; + int64_t my_eq = eq_budget - eq_before; + if (my_eq < 0) my_eq = 0; + if (my_eq > eq) my_eq = eq; + const int sel = gt + (int) my_eq; + __syncthreads(); + s_a[t] = sel; + __syncthreads(); + if (t == 0) { + int a = 0; + for (int i = 0; i < TOPK_T; ++i) { const int c = s_a[i]; s_a[i] = a; a += c; } + } + __syncthreads(); + int64_t wpos = s_a[t]; + int64_t eq_left = my_eq; + for (int64_t b = b0; b < b1; ++b) { + const int w = weight(b); + if (w == 0) continue; + const uint32_t k = order_key(sc[b]); + if (k > thr) { + for (int c = 0; c < w; ++c) out[wpos++] = (int32_t) (b * R + c); + } else if (k == thr) { + for (int c = 0; c < w && eq_left > 0; ++c, --eq_left) out[wpos++] = (int32_t) (b * R + c); + } + } +} + +} // namespace + +void qsa_block_scores(const float* pooled, const float* dead, const float* q_idx, const int32_t* steps, int64_t nq, + int64_t max_blocks, const QsaShapes& s, float* scores, void* stream) { + if (nq <= 0) return; + if (s.idx_dim != IDX_DIM || s.idx_n_head != IDX_HEADS || s.idx_block != R || nq > 65535) { + std::fprintf(stderr, "qsa_block_scores: unsupported indexer geometry\n"); + std::exit(1); + } + const dim3 grid((unsigned) ((max_blocks + SCORE_WARPS - 1) / SCORE_WARPS), (unsigned) nq); + block_scores_kernel<<>>(pooled, dead, q_idx, steps, max_blocks, + scores); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "qsa_block_scores: %s\n", cudaGetErrorString(e)); std::exit(1); } +} + +void qsa_block_topk(const float* scores, const int32_t* steps, int64_t nq, int64_t max_blocks, int64_t cap, + const QsaShapes& s, int32_t* ids, void* stream) { + if (nq <= 0) return; + if (s.idx_block != R || cap < qsa_selection_width(kTopkMaxCells, s)) { + std::fprintf(stderr, "qsa_block_topk: unsupported geometry or cap\n"); + std::exit(1); + } + block_topk_kernel<<<(unsigned) nq, TOPK_T, 0, (cudaStream_t) stream>>>(scores, steps, max_blocks, cap, ids); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "qsa_block_topk: %s\n", cudaGetErrorString(e)); std::exit(1); } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/quantize_act.cu b/src/kernels/cuda/quantize_act.cu new file mode 100644 index 00000000..ddd14dfb --- /dev/null +++ b/src/kernels/cuda/quantize_act.cu @@ -0,0 +1,313 @@ +// src/kernels/cuda/quantize_act.cu - P2.S2: quantize an activation the way ggml converts src1. +// +// WHY THIS EXISTS AT ALL. `ggml_mul_mat` converts the ACTIVATION (src1) to the weight's `vec_dot_type` +// before the dot product - the rule established over rounds 119-136 and written into `ref/quant.py` as +// `apply_src1`. The CPU expert path already honours it (`bench/micro/cpu_s2.cpp` quantizes to int8 and uses +// `vpdpbusd`), and its P0.T2 parity passes at 1.461e-06. The GPU kernels added in rounds 166-177 take FP16 +// activations instead. **Left that way, the two paths would compute different numbers for different experts +// of the SAME token** - not a tolerance question, an inconsistency inside one forward pass. +// +// Q2_0 IS THE CASE THAT MATTERS: the routed experts are all Q2_0, `Q2_0 -> Q8_0`, and that is 31.64 GiB of the +// 38 GiB pack. Q8_K (for the K-quants and IQ4_XS) is the other half of the table and is not here yet. +// +// THE THREE SUBTLETIES, each of which was paid for once already and is transcribed rather than recalled: +// +// 1. `d32 = amax / 127.0f` is computed in FP32, and the QUANTIZED INTEGERS divide by **d32**, not by the +// fp16-rounded value that gets stored. Conflating the two shifts every quant near a rounding boundary - +// measured against ggml's own bytes, 18 of 80 blocks differed, and because Q2_0 experts convert to Q8_0 +// the error landed in all 48 MoE blocks and made the end-to-end KL WORSE. +// 2. The reference divides in FLOAT64 (`blk.astype(np.float64) / float(np.float32(d32))`) and rounds with +// `rint` - half to even. This kernel therefore divides in double too: an FP32 division followed by +// `rintf` differs from the reference wherever the f32 quotient rounds across a .5 boundary. +// 3. The DEQUANTIZED value is `q * d16`, the fp16 scale, not `q * d32`. The block stores fp16 and that is +// what a reader multiplies by. +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/f16_bits.hpp" + +#include + +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int QK8_0 = 32; + +// THE fp16 CONVERSION LIVES IN `strata/kernels/f16_bits.hpp`, and this file used to carry its own copy. +// +// Round 198 found the private copy wrong in a way no fixture here could see: it tested `if (exp >= 31)` to +// detect an out-of-range exponent, which conflates an f32 INF/NAN (raw exponent 255) with a FINITE value too +// large for fp16. Every finite overflow - 1e30, 65536, 1e45 - came back as a NaN instead of saturating to +// inf, and this file's fixture is O(1) throughout, so it passed. A numpy-generated oracle caught it on its +// first run. Three copies of a converter whose failure mode is silent wrong bits was two copies too many. + + +__global__ void quantize_q8_0_kernel(const float* __restrict__ x, uint8_t* __restrict__ blocks, + long long n_blocks) { + const long long b = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (b >= n_blocks) return; + const float* xb = x + b * QK8_0; + uint8_t* out = blocks + b * 34; // { fp16 d ; int8 qs[32] } + + float amax = 0.0f; + for (int i = 0; i < QK8_0; ++i) amax = fmaxf(amax, fabsf(xb[i])); + if (amax == 0.0f) { + // ggml leaves the block zeroed: d = 0 and every q = 0. Writing the fp16 zero explicitly rather than + // skipping keeps the block layout deterministic for the byte comparison. + const uint16_t zb = f16_from_f32(0.0f); + out[0] = (uint8_t) (zb & 0xFF); + out[1] = (uint8_t) (zb >> 8); + for (int i = 0; i < QK8_0; ++i) out[2 + i] = 0; + return; + } + const float d32 = amax / 127.0f; + const uint16_t d16bits = f16_from_f32(d32); + out[0] = (uint8_t) (d16bits & 0xFF); + out[1] = (uint8_t) (d16bits >> 8); + + // double division, matching the reference exactly (subtlety 2) + for (int i = 0; i < QK8_0; ++i) { + double q = rint((double) xb[i] / (double) d32); + if (q > 127.0) q = 127.0; + if (q < -128.0) q = -128.0; + out[2 + i] = (uint8_t) (int8_t) q; + } +} + +/// **THE HIT PATH'S QUANTIZER, AND IT EXISTS TO REPRODUCE `act_quant_q8_1` EXACTLY (R4.2h, round 331).** +/// +/// The engine computes every routed expert twice. MISSES go through `cpu/expert.cpp:138 act_quant_q8_1` +/// into the VNNI kernel; HITS went through `quantize_q8_0` above. **Those are not the same quantization**, +/// and round 330 measured the difference with both real implementations linked +/// (`bench/micro/act_quant_parity.cu`): +/// +/// int8 activations differing : 0 of 2560 <- this rule's ties are measure-zero +/// chunks whose SCALE differs : 80 of 80, max rel 4.761e-04 <- 2^-11: fp32 vs the block's fp16 +/// +/// The scale is the difference that matters: `ActQ::scale` is `float` and the CPU kernel multiplies by it +/// (`expert.cpp:92`), while `row_dot_s2_q8` read `f16_at(xb)` out of the `block_q8_0`. So this variant +/// writes the SAME 34-byte blocks (the kernel's indexing is unchanged) **and** a parallel fp32 scale array +/// carrying `d32` unrounded. +/// +/// It also adopts the CPU's ROUNDING RULE - multiply by the reciprocal, round half AWAY FROM ZERO - rather +/// than this file's `rint`. That is deliberate and it is the opposite of the direction the comment above +/// argues for. `quantize_q8_0` was tuned to match **ggml's Q8_0 bytes**, which is right for the pack and for +/// `moe_hit_parity`. This function's job is different: it must match **this engine's own CPU reference**, +/// because a hit and a miss for the same expert on the same layer have to produce the same number. The CPU +/// path is the reference - C1 passes on it - so the hit path is brought to it, not the reverse. +__global__ void quantize_q8_0_scaled_kernel(const float* __restrict__ x, uint8_t* __restrict__ blocks, + float* __restrict__ scales, long long n_blocks) { + const long long b = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (b >= n_blocks) return; + const float* xb = x + b * QK8_0; + uint8_t* out = blocks + b * 34; + + float amax = 0.0f; + for (int i = 0; i < QK8_0; ++i) amax = fmaxf(amax, fabsf(xb[i])); + // VERBATIM from `cpu/expert.cpp:144-145`, including the `amax > 0` guard, so the fp32 value written here + // is bit-identical to the `s` the CPU path used. + const float s = amax > 0.f ? amax / 127.f : 0.f; + const float inv = s > 0.f ? 1.f / s : 0.f; + scales[b] = s; + + const uint16_t d16bits = f16_from_f32(s); + out[0] = (uint8_t) (d16bits & 0xFF); + out[1] = (uint8_t) (d16bits >> 8); + for (int i = 0; i < QK8_0; ++i) { + // VERBATIM from `cpu/expert.cpp:159-162`: reciprocal multiply, then `t + copysign(0.5, t)` truncated + // toward zero, which is `lround`'s rule - round half away from zero. + const float t = xb[i] * inv; + const float r = t + (t >= 0.f ? 0.5f : -0.5f); + int v = (int) r; + v = v < -127 ? -127 : (v > 127 ? 127 : v); + out[2 + i] = (uint8_t) (int8_t) v; + } +} + +__global__ void dequant_q8_0_kernel(const uint8_t* __restrict__ blocks, float* __restrict__ x, + long long n_blocks) { + const long long b = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (b >= n_blocks) return; + const uint8_t* blk = blocks + b * 34; + const uint16_t dbits = (uint16_t) (blk[0] | (blk[1] << 8)); + const float d = f32_from_f16(dbits); + float* out = x + b * QK8_0; + for (int i = 0; i < QK8_0; ++i) out[i] = (float) (int8_t) blk[2 + i] * d; +} + +// ===================== Q8_K ===================== +// +// `block_q8_K` = { float d ; int8_t qs[256] ; int16_t bsums[16] } = 292 bytes, no padding +// (`static_assert` in ggml-common.h). QK_K = 256. + +constexpr int QK_K = 256; +constexpr int Q8K_BYTES = 292; + +/// ggml's `nearest_int` (ggml-quants.c L621), transcribed rather than replaced. +/// +/// The magic number is 1.5 * 2^23: adding it forces the mantissa's integer part into the low bits, and the +/// mask/subtract recover it. The reason it is transcribed and not written as `rintf` is that the two differ +/// on exact ties - and that is the whole point of the constant, so a "cleaner" rewrite would silently change +/// which way ties go. +__device__ __forceinline__ int nearest_int_dev(float fval) { + const float val = fval + 12582912.0f; + int i; + memcpy(&i, &val, 4); + return (i & 0x007fffff) - 0x00400000; +} + +__global__ void quantize_q8_K_kernel(const float* __restrict__ x, uint8_t* __restrict__ blocks, + long long n_blocks) { + const long long b = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (b >= n_blocks) return; + const float* xb = x + b * QK_K; + uint8_t* out = blocks + b * Q8K_BYTES; + float* d = (float*) out; + int8_t* qs = (int8_t*) (out + 4); + int16_t* bsums = (int16_t*) (out + 4 + QK_K); + + // `max` is the SIGNED value at the largest magnitude position, and the comparison is STRICTLY greater, + // so a tie keeps the FIRST maximum - which is what `np.argmax` does in the reference transcription too. + float max = 0.0f, amax = 0.0f; + for (int j = 0; j < QK_K; ++j) { + const float ax = fabsf(xb[j]); + if (ax > amax) { + amax = ax; + max = xb[j]; + } + } + if (amax == 0.0f) { + // ggml writes d = 0 and zeroes qs, then `continue`s - which LEAVES bsums UNWRITTEN. A zeroed block + // and an untouched one are indistinguishable to the dot product, but not to a byte comparison, so + // this zeroes bsums as well and the parity test's reference does the same. Recorded because it is a + // deliberate divergence from the letter of the source. + *d = 0.0f; + for (int j = 0; j < QK_K; ++j) qs[j] = 0; + for (int j = 0; j < QK_K / 16; ++j) bsums[j] = 0; + return; + } + const float iscale = -127.0f / max; // -127, NOT -128; see the header + for (int j = 0; j < QK_K; ++j) { + // `__fmul_rn`, NOT `iscale * xb[j]`. ggml computes the product, ROUNDS IT TO F32, and then adds + // 12582912.0f inside `nearest_int`. Written as a plain expression, nvcc CONTRACTS the multiply into + // the add as an FMA - which is a more accurate product but not the same one, and it flips the result + // wherever the true product sits just off a .5 boundary. The first version of this kernel differed + // from the reference in 1 element of 524,288 for exactly this reason, and `__fmul_rn` pins the + // rounding step the source actually performs. + const int v = nearest_int_dev(__fmul_rn(iscale, xb[j])); + qs[j] = (int8_t) min(127, v); // MIN only - the source has no lower clamp + } + for (int j = 0; j < QK_K / 16; ++j) { + int sum = 0; + for (int ii = 0; ii < 16; ++ii) sum += qs[j * 16 + ii]; + bsums[j] = (int16_t) sum; + } + *d = 1.0f / iscale; +} + +__global__ void dequant_q8_K_kernel(const uint8_t* __restrict__ blocks, float* __restrict__ x, + long long n_blocks) { + const long long b = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (b >= n_blocks) return; + const uint8_t* blk = blocks + b * Q8K_BYTES; + float d; + memcpy(&d, blk, 4); + const int8_t* qs = (const int8_t*) (blk + 4); + float* out = x + b * QK_K; + for (int i = 0; i < QK_K; ++i) out[i] = (float) qs[i] * d; +} + +} // namespace + +void quantize_q8_0(const float* x, uint8_t* blocks, int64_t n, void* stream) { + if (n <= 0) return; + if (n % QK8_0 != 0) { + std::fprintf(stderr, "quantize_q8_0: n %lld is not a multiple of %d\n", (long long) n, QK8_0); + std::exit(1); + } + const long long nb = n / QK8_0; + const int threads = 128; + const unsigned grid = (unsigned) ((nb + threads - 1) / threads); + quantize_q8_0_kernel<<>>(x, blocks, nb); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "quantize_q8_0 launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) cudaDeviceSynchronize(); +} + +/// See `quantize_q8_0_scaled_kernel`. Writes the same 34-byte `block_q8_0` layout as `quantize_q8_0`, plus +/// `scales[n/32]` carrying the fp32 `s` the CPU path used - so a hit can be computed with the CPU's +/// multiplier instead of the block's fp16 `d`. `scales` must not be null. +void quantize_q8_0_scaled(const float* x, uint8_t* blocks, float* scales, int64_t n, void* stream) { + if (n <= 0) return; + if (n % QK8_0 != 0) { + std::fprintf(stderr, "quantize_q8_0_scaled: n %lld is not a multiple of %d\n", (long long) n, QK8_0); + std::exit(1); + } + if (scales == nullptr) { + std::fprintf(stderr, "quantize_q8_0_scaled: scales is null\n"); + std::exit(1); + } + const long long nb = n / QK8_0; + const int threads = 128; + const unsigned grid = (unsigned) ((nb + threads - 1) / threads); + quantize_q8_0_scaled_kernel<<>>(x, blocks, scales, nb); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "quantize_q8_0_scaled launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) cudaDeviceSynchronize(); +} + +void dequant_q8_0(const uint8_t* blocks, float* x, int64_t n, void* stream) { + if (n <= 0) return; + const long long nb = n / QK8_0; + const int threads = 128; + const unsigned grid = (unsigned) ((nb + threads - 1) / threads); + dequant_q8_0_kernel<<>>(blocks, x, nb); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "dequant_q8_0 launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) cudaDeviceSynchronize(); +} + +void quantize_q8_K(const float* x, uint8_t* blocks, int64_t n, void* stream) { + if (n <= 0) return; + if (n % QK_K != 0) { + std::fprintf(stderr, "quantize_q8_K: n %lld is not a multiple of %d\n", (long long) n, QK_K); + std::exit(1); + } + const long long nb = n / QK_K; + const int threads = 64; // one block per thread, and a block is 256 elements + const unsigned grid = (unsigned) ((nb + threads - 1) / threads); + quantize_q8_K_kernel<<>>(x, blocks, nb); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "quantize_q8_K launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) cudaDeviceSynchronize(); +} + +void dequant_q8_K(const uint8_t* blocks, float* x, int64_t n, void* stream) { + if (n <= 0) return; + const long long nb = n / QK_K; + const int threads = 64; + const unsigned grid = (unsigned) ((nb + threads - 1) / threads); + dequant_q8_K_kernel<<>>(blocks, x, nb); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "dequant_q8_K launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) cudaDeviceSynchronize(); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/rope.cu b/src/kernels/cuda/rope.cu new file mode 100644 index 00000000..2bb1735a --- /dev/null +++ b/src/kernels/cuda/rope.cu @@ -0,0 +1,95 @@ +// src/kernels/cuda/rope.cu - P2.S2: NEOX partial RoPE. +// +// Semantics from `ref/qsa.py` (`rope_freqs` + `rope_neox`), which `bench/micro/rope_xcheck` validates against +// the oracle: +// +// inv[i] = theta ** (-2i / n_rot) i in [0, n_rot/2) +// ang = pos * inv +// cos, sin = cos(ang), sin(ang) +// out[:half] = a*cos - b*sin a = x[:half], b = x[half:n_rot] +// out[half:n_rot] = a*sin + b*cos +// +// THE PAIRING IS NEOX - (i, i + n_rot/2), NOT the adjacent pair (2i, 2i+1). `ggml.h` writes NEOX +// n_dims=8 as [ccccssss]; the adjacent-pair ("NORMAL") ordering is the other common convention and choosing +// wrongly rotates the wrong dimensions together, which produces correctly-shaped output with scrambled +// content. It is also PARTIAL: only the first n_rot of head_dim are touched, here 64 of 256. +// +// WHY THE TABLE IS BUILT ON THE HOST. The reference computes the frequencies in FLOAT64 (`theta ** (...)` +// on a float64 arange, cos/sin in float64). Reproducing that on device means double-precision pow and cos, +// which are slow on a consumer GPU and - more to the point - not guaranteed to agree with the host's libm. +// Building the table once on the host and passing it in makes the TABLE exactly the reference's values and +// leaves the kernel a pure rotation, so each half can be checked tightly instead of both halves sharing one +// loose tolerance. The engine wants a cached table anyway: it is per (n_rot, theta, position), not per token. +#include "strata/kernels/rope.hpp" +#include "strata/kernels/mrope.hpp" + +#include + +#include +#include +#include + +namespace strata::kernels { + +void build_rope_table(int n_rot, double theta, int max_pos, float* cos_tab, float* sin_tab) { + const int half = n_rot / 2; + for (int p = 0; p < max_pos; ++p) { + for (int i = 0; i < half; ++i) { + // float64 throughout, in the reference's order: inv, then ang, then cos/sin + const double inv = std::pow(theta, -2.0 * (double) i / (double) n_rot); + const double ang = (double) p * inv; + cos_tab[(size_t) p * half + i] = (float) std::cos(ang); + sin_tab[(size_t) p * half + i] = (float) std::sin(ang); + } + } +} + +namespace { + +// ONE THREAD PER ROW, not per (row, pair). The first version had one thread per pair and did the tail copy +// with hreadIdx.x strided loops - so up to 32 threads wrote into the SAME row, with no barrier between the +// copy and the rotation, and a thread's copy of orow[i] could land AFTER another thread had rotated it. A +// race that silently writes the unrotated value is worse than a wrong answer: it is intermittent. +// +// A row is head_dim floats (256 here) and there are batch x heads of them, so one thread per row is a +// handful of threads for a token and removes the ordering question entirely rather than synchronising it. +__global__ void rope_neox_kernel(const float* __restrict__ x, float* __restrict__ out, long long rows, + int head_dim, int n_rot, const float* __restrict__ cos_tab, + const float* __restrict__ sin_tab, const int* __restrict__ pos, + const int32_t* __restrict__ mtab) { + const long long r = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (r >= rows) return; + const int half = n_rot / 2; + const float* xr = x + r * head_dim; + float* orow = out + r * head_dim; + + for (int d = n_rot; d < head_dim; ++d) orow[d] = xr[d]; // the PARTIAL rotation's untouched tail + for (int i = 0; i < half; ++i) { + const size_t toff = (size_t) mrope_pos(mtab, pos[r], i) * half; // the image path's per-pair position + // THE PAIRING AND THE SIGNS COME FROM `rope_neox_pair`, shared with the indexer's pooling kernel. + rope_neox_pair(xr[i], xr[half + i], cos_tab[toff + i], sin_tab[toff + i], orow[i], orow[half + i]); + } +} + +} // namespace + +void rope_neox_apply(const float* x, float* out, int64_t rows, int head_dim, int n_rot, const float* cos_tab, + const float* sin_tab, const int* pos, void* stream) { + if (rows <= 0 || n_rot <= 0) return; + if (n_rot % 2 != 0 || n_rot > head_dim) { + std::fprintf(stderr, "rope_neox_apply: n_rot %d must be even and <= head_dim %d\n", n_rot, head_dim); + std::exit(1); + } + const int threads = 128; + const unsigned grid = (unsigned) ((rows + threads - 1) / threads); + rope_neox_kernel<<>>(x, out, rows, head_dim, n_rot, cos_tab, + sin_tab, pos, mrope_table()); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "rope_neox_apply launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) cudaDeviceSynchronize(); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/router_top10.cu b/src/kernels/cuda/router_top10.cu new file mode 100644 index 00000000..1f137e59 --- /dev/null +++ b/src/kernels/cuda/router_top10.cu @@ -0,0 +1,226 @@ +// src/kernels/cuda/router_top10.cu - P2.S2: the MoE router. +// +// P2.S2's spec: "BF16 GEMV, softmax / top-k / renormalize per docs/semantics.md; emits (expert_id, weight) x 10 +// per token". This is the softmax/top-k/renormalize half; the BF16 GEMV that produces the logits is a +// separate kernel and is NOT here. +// +// The semantics are transcribed from `ref/moe.py::router`, which is itself transcribed from llama-graph.cpp: +// +// p = softmax(logits) over ALL experts, not over the selected subset +// ids = stable argsort(-p)[:k] ties break by INDEX, ascending +// w = gather(p, ids) +// s = max(sum(w), 2**-14) ggml_clamp(..., 2**-14, INF) +// return ids, w / s +// +// The two-step structure matters and is not cosmetic: computing softmax over the selected subset instead is +// mathematically identical (softmax is shift-invariant) but it moves the renormalisation, and the CLAMP is +// part of the renormalisation. Reproducing the gather form is what makes the clamp land in the same place. +// +// WHY THIS IS NO LONGER "NAIVE BY DESIGN", AND WHAT THE OLD REASONING GOT WRONG. +// +// The first version was ONE THREAD PER TOKEN: k passes over the experts, each pass recomputing +// `exp((double) l[e] - mx)` for every expert. Its comment justified that with +// +// "5,120 operations per token against 2.36e9 weights of expert matvec - three orders of magnitude smaller +// than the thing it feeds, so the simplest correct version is also fast enough" +// +// **That is a statement about OPERATION COUNT and it says nothing about TIME, because all 5,120 operations were +// on ONE THREAD while the matvec has thousands.** Measured (round 218, stage-by-stage inside one block): +// +// moe_layer 4.077 ms router_top10 3.387 ms bf16 gemv 0.270 ms +// +// 3.39 ms per layer x 48 layers = 163 ms of a 289 ms token - **56% of the whole forward pass, in a kernel that +// reads 2.6 MB.** It was invisible in `router_top10_parity` because that test runs the kernel ONCE and checks +// the right ids, and invisible in every per-layer test for the same reason. +// +// WHAT IS PARALLELISED AND WHAT IS DELIBERATELY NOT: +// +// * the MAX is a tree reduction of `fmaxf`, which is exact and order-independent - bit-identical to the +// serial scan it replaces; +// * the 512 EXPONENTIALS are computed ONCE each, in parallel. The old kernel computed them ELEVEN TIMES +// (once for the softmax, then again inside every one of the k passes). This is where the 3.4 ms was; +// * the DOUBLE SUM is still accumulated on ONE thread in ASCENDING order. 512 double adds is about a +// microsecond and it keeps the accumulation order - and therefore the last bits - identical to the +// reference-faithful serial version. Parallelising it would be a free speedup and a silent change to the +// values, and the exp was the cost, not this; +// * the SELECTION is by RANK rather than by k repeated maxima: +// +// rank(e) = #{ f : p[f] > p[e] } + #{ f < e : p[f] == p[e] } +// +// which IS "stable descending argsort, ties by ascending index" - the same order the repeated-maximum loop +// produced - computed in ONE parallel pass over the experts instead of k serial ones. It is O(n^2) +// comparisons and that is the right trade here: n = 512, every comparison is independent, and the old +// version was O(k*n) with a serial `exp` inside. +#include "strata/kernels/router_top10.hpp" + +#include + +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int RT_MAX_THREADS = 512; + +/// One BLOCK per token, so the reductions have somewhere to happen. `n_tokens` is 1 in decode; the grid keeps +/// the batch case working without a second code path. +__global__ void router_top10_kernel(const float* __restrict__ logits, int n_tokens, int n_expert, int k, + int* __restrict__ ids, float* __restrict__ weights) { + // **`s_taken` IS FIRST SO THE OTHER TWO KEEP THEIR ALIGNMENT WITHOUT AN OFFSET PARAMETER**, and `s_p`'s + // existing `s_ex + n_expert` stays correct because it is relative to `s_ex`. One byte per expert. + extern __shared__ unsigned char s_raw[]; + const size_t taken_bytes = ((size_t) n_expert + 15u) & ~(size_t) 15u; + unsigned char* s_taken = s_raw; + double* s_ex = (double*) (s_raw + taken_bytes); + __shared__ float s_red[RT_MAX_THREADS / 32]; + __shared__ int s_rid[RT_MAX_THREADS / 32]; + __shared__ double s_sum; + + const int t = blockIdx.x; + if (t >= n_tokens) return; + const int tid = threadIdx.x; + const int nt = blockDim.x; + const float* l = logits + (size_t) t * n_expert; + + // ---- softmax over ALL experts, for stability: the max. A tree of `fmaxf` is EXACT and order-independent, + // so this is bit-identical to the serial scan. + float mx = -INFINITY; + for (int e = tid; e < n_expert; e += nt) mx = fmaxf(mx, l[e]); + for (int off = 16; off > 0; off >>= 1) mx = fmaxf(mx, __shfl_down_sync(0xffffffffu, mx, off)); + if ((tid & 31) == 0) s_red[tid >> 5] = mx; + __syncthreads(); + if (tid < 32) { + const int nw = (nt + 31) >> 5; + float v = (tid < nw) ? s_red[tid] : -INFINITY; + for (int off = 16; off > 0; off >>= 1) v = fmaxf(v, __shfl_down_sync(0xffffffffu, v, off)); + if (tid == 0) s_red[0] = v; + } + __syncthreads(); + mx = s_red[0]; + + // ---- THE 512 EXPONENTIALS, ONCE EACH AND IN PARALLEL. `exp` in double is software-emulated on this die + // and was 5,632 serial calls before; it is 512 parallel ones now. + for (int e = tid; e < n_expert; e += nt) s_ex[e] = exp((double) l[e] - (double) mx); + __syncthreads(); + + // ---- the sum, ascending, on one thread: see the note above on why this is NOT parallelised. + if (tid == 0) { + double sum = 0.0; + for (int e = 0; e < n_expert; ++e) sum += s_ex[e]; + s_sum = sum; + } + __syncthreads(); + const float inv = (float) (1.0 / s_sum); + + // ---- the selection, by rank. `p[e] = (float)(s_ex[e] * inv)` reproduces the old expression exactly: + // `(float)(exp((double) l[e] - (double) mx) * inv)` with `inv` a FLOAT. + // ---- p[] ONCE. The rank loop below compares every expert against every other, so computing + // `(float) (s_ex[f] * inv)` INSIDE it did **512 x 512 = 262,144** double multiplies and float converts per + // token per layer instead of 512 - and a consumer die runs FP64 at a small fraction of FP32, which is why + // this was worth more than the branch. The shared load stays either way; the ARITHMETIC is what goes. + // + // THIS IS BIT-IDENTICAL, not merely equivalent: `p[e]` is the same expression it was, computed once instead + // of 513 times, and the comparisons then see exactly the same floats. + float* s_p = (float*) (s_ex + n_expert); + for (int e = tid; e < n_expert; e += nt) s_p[e] = (float) (s_ex[e] * inv); + __syncthreads(); + + // ================================ THE SELECTION, IN k PASSES ================================ + // + // **THE RANK-BY-COUNTING LOOP COMPARED EVERY EXPERT AGAINST EVERY OTHER**: 512 x 512 = 262,144 shared + // loads and compares per token per layer, to choose ten things out of 512 - measured at 34.35 us, 63% of + // `moe_route`'s accounted cost and the largest single item in the R3 plan. The comment above records that + // the FP64 ARITHMETIC inside that loop was hoisted out once already ("262,144 double multiplies per token + // per layer instead of 512"); **the O(n^2) STRUCTURE was left, and it is the structure that costs.** + // + // `k` passes of a block-wide argmax is 10 x 512 = 5,120 compares - **51x less work** - and it produces the + // SAME ORDER. That is the whole correctness argument: scanning `e` ascending with a strict `>` keeps the + // LOWEST index on a tie, which is exactly the rule the rank loop spelled out as + // `else if (f < e && pf == pe) ++rank`. A stable descending top-k, ties by index. + for (int e = tid; e < n_expert; e += nt) s_taken[e] = 0; + __syncthreads(); + + for (int i = 0; i < k; ++i) { + float bv = -INFINITY; + int bi = n_expert; // a sentinel that loses to every real index + for (int e = tid; e < n_expert; e += nt) { + if (s_taken[e]) continue; + const float pe = s_p[e]; + if (pe > bv) { bv = pe; bi = e; } + } + for (int off = 16; off > 0; off >>= 1) { + const float ov = __shfl_down_sync(0xffffffffu, bv, off); + const int oi = __shfl_down_sync(0xffffffffu, bi, off); + if (ov > bv || (ov == bv && oi < bi)) { bv = ov; bi = oi; } + } + if ((tid & 31) == 0) { s_red[tid >> 5] = bv; s_rid[tid >> 5] = bi; } + __syncthreads(); + if (tid < 32) { + const int nw = (nt + 31) >> 5; + float v = (tid < nw) ? s_red[tid] : -INFINITY; + int ix = (tid < nw) ? s_rid[tid] : n_expert; + for (int off = 16; off > 0; off >>= 1) { + const float ov = __shfl_down_sync(0xffffffffu, v, off); + const int oi = __shfl_down_sync(0xffffffffu, ix, off); + if (ov > v || (ov == v && oi < ix)) { v = ov; ix = oi; } + } + if (tid == 0 && ix < n_expert) { + ids[(size_t) t * k + i] = ix; + weights[(size_t) t * k + i] = v; + s_taken[ix] = 1; + } + } + __syncthreads(); + } + __syncthreads(); + + // ---- renormalise, with ggml's lower clamp. Order preserved. + if (tid == 0) { + double s = 0.0; + for (int i = 0; i < k; ++i) s += (double) weights[(size_t) t * k + i]; + const double sc = fmax(s, 6.103515625e-05); // 2**-14 + for (int i = 0; i < k; ++i) + weights[(size_t) t * k + i] = (float) ((double) weights[(size_t) t * k + i] / sc); + } +} + +} // namespace + +void router_top10(const float* logits, int n_tokens, int n_expert, int k, int* ids, float* weights, + void* stream) { + if (n_tokens <= 0 || n_expert <= 0 || k <= 0) return; + if (k > 64) { + std::fprintf(stderr, "router_top10: k %d exceeds the kernel's 64\n", k); + std::exit(1); + } + if (n_expert > RT_MAX_THREADS * 64) { + std::fprintf(stderr, "router_top10: n_expert %d is past the kernel's %d\n", n_expert, + RT_MAX_THREADS * 64); + std::exit(1); + } + int threads = n_expert < RT_MAX_THREADS ? n_expert : RT_MAX_THREADS; + threads = (threads + 31) & ~31; // at least one full warp, for the reductions + // the selection's taken-mask, then n_expert doubles for the exponentials, then n_expert floats for the + // probabilities. The mask is first so the two aligned arrays need no offset parameter. + const size_t taken_bytes = ((size_t) n_expert + 15u) & ~(size_t) 15u; + const size_t smem = + taken_bytes + (size_t) n_expert * sizeof(double) + (size_t) n_expert * sizeof(float); + router_top10_kernel<<<(unsigned) n_tokens, threads, smem, (cudaStream_t) stream>>>( + logits, n_tokens, n_expert, k, ids, weights); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "router_top10 launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) { + const cudaError_t s = cudaDeviceSynchronize(); + if (s != cudaSuccess) { + std::fprintf(stderr, "router_top10: %s\n", cudaGetErrorString(s)); + std::exit(1); + } + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/s2_expert_grouped.cu b/src/kernels/cuda/s2_expert_grouped.cu new file mode 100644 index 00000000..90761ae6 --- /dev/null +++ b/src/kernels/cuda/s2_expert_grouped.cu @@ -0,0 +1,785 @@ +// src/kernels/cuda/s2_expert_grouped.cu - R4's grouped GPU expert. Read the header first. +// +// THE ARITHMETIC IS `src/kernels/cpu/expert.cpp`'s, over the same bytes, one warp per output row. +// +// sum_j (c_j - 1) * d_w * xhat_j * d_x == d_w * d_x * ( sum_j c_j*xhat_j - sum_j xhat_j ) +// +// Both sums are INT8 x INT8, so both are `__dp4a` - four multiply-accumulates per instruction, exact in +// integer and with no dequantization inside the loop. The weight scale `d_w` is fp16 (one per 64 elements) +// and the activation scale `d_x` is fp16 (one per 32 elements), so the float work is one multiply-add per +// 32-element chunk rather than one per element. +// +// **THE SUMMATION ORDER IS NOT THE CPU's AND CANNOT BE.** Lane `L` takes chunks `L, L+32, ...` and the +// partials are reduced through shuffle; the CPU walks every chunk in order with its own accumulator shape. +// The two agree to float rounding and not to the bit, which is the same contract `s_gemv_parity` carries for +// the same reason. `bench/micro/moe_hit_parity.cu` is the check. +#include "strata/kernels/s2_expert_grouped.hpp" + +#include "strata/kernels/quantize_act.hpp" + +#include +#include + +#include +#include +#include + +namespace strata::kernels { +namespace { + +// THE BLOB'S OWN GEOMETRY, from `include/strata/kernels/cpu/expert.hpp`. Restated as literals because that +// header is the CPU path's and this file must not silently follow it if the two ever disagree: the sizes below +// are what the CPU kernel's indexing computes, and `moe_hit_parity` compares the two end to end. +constexpr int H = 2560; +constexpr int FF = 640; +constexpr int QK = 64; // Q2_0's group: one fp16 scale per 64 weights +constexpr int ROW_GU = H / 4; // 640 B of codes per gate/up row (2 bits per element) +constexpr int ROW_D = FF / 4; // 160 B per down row +constexpr int SC_GU = H / QK; // 40 fp16 scales per gate/up row +constexpr int SC_D = FF / QK; // 10 per down row +constexpr size_t O_D_CODES = (size_t) 2 * FF * ROW_GU; +constexpr size_t O_GU_SCALES = O_D_CODES + (size_t) H * ROW_D; +constexpr size_t O_D_SCALES = O_GU_SCALES + (size_t) 2 * FF * SC_GU * 2; + +__device__ __forceinline__ float f16_at(const uint8_t* p) { + return __half2float(__ushort_as_half((uint16_t) (p[0] | (p[1] << 8)))); +} + +/// **ONE S2 ROW AGAINST A Q8_0 ACTIVATION, WARP-WIDE.** Every lane accumulates its own float partial over a +/// strided set of 32-element chunks and the caller reduces; `chunk` indices are absolute so the caller can +/// start the lane at any offset. +/// +/// Returns the lane's partial. `codes` is `n_in/4` bytes and `scales` `n_in/QK` fp16, both for THIS row. +/// +/// **`x_scales` IS R4.2h AND IT IS OPTIONAL ON PURPOSE.** When it is non-null the activation's multiplier +/// comes from an fp32 array instead of the block's fp16 `d`. The CPU pool multiplies by the fp32 +/// `ActQ::scale` (`cpu/expert.cpp:92`), and the two disagreed by **4.761e-04 relative on 80 of 80 chunks** +/// (`bench/micro/act_quant_parity.cu`), which is what made a cache hit compute a different expert from a +/// cache miss. Null keeps the previous fp16 behaviour **exactly**, so `moe_hit_parity` - which passes no +/// scales - still measures the kernel it always measured. +__device__ __forceinline__ float row_dot_s2_q8(const uint8_t* __restrict__ codes, + const uint8_t* __restrict__ scales, + const uint8_t* __restrict__ x_q8_0, int n_chunks, int lane, + const float* __restrict__ x_scales = nullptr) { + float acc = 0.0f; + for (int c = lane; c < n_chunks; c += 32) { + const uint8_t* cb = codes + (size_t) c * 8; // 8 code bytes = 32 elements + const uint8_t* xb = x_q8_0 + (size_t) c * 34; // one block_q8_0 + const float dx = x_scales ? x_scales[c] : f16_at(xb); + const int8_t* xq = (const int8_t*) (xb + 2); + + // ---- THE CODES, EXPANDED TO ONE BYTE PER ELEMENT, THEN FOUR AT A TIME INTO `dp4a`. + // + // The packed form is LSB-first: element 4j+k is bits [2k, 2k+2) of code byte j. `dp4a` needs both + // operands as packed int8, so each code byte becomes a word whose four bytes are its four 2-bit + // fields - which is exactly `(c & 3) | ((c>>2)&3)<<8 | ((c>>4)&3)<<16 | ((c>>6)&3)<<24`. + int s = 0; // sum of code * x + int hx = 0; // sum of x - the weight-independent term, as ones * x + const int ones = 0x01010101; +#pragma unroll + for (int j = 0; j < 8; ++j) { + const unsigned cbyte = cb[j]; + const int cw = (int) ((cbyte & 3u) | (((cbyte >> 2) & 3u) << 8) | (((cbyte >> 4) & 3u) << 16) | + (((cbyte >> 6) & 3u) << 24)); + // **`memcpy`, NOT A CAST.** A `block_q8_0` is 34 BYTES - two of header then 32 of int8 - so the + // activation data at offset 2 is never 4-byte aligned, and `*(const int*)(xq + 4*j)` faults with + // "misaligned address". It faulted exactly that way on this kernel's first run. `memcpy` of a + // known 4 bytes compiles to whatever load is legal for the alignment, which is the point of using + // it rather than reasoning about which cast happens to work. + int xw; + memcpy(&xw, xq + 4 * j, 4); + s = __dp4a(cw, xw, s); + hx = __dp4a(ones, xw, hx); + } + // One weight scale per 64 elements, so per TWO 32-element chunks. + const float dw = f16_at(scales + (size_t) (c >> 1) * 2); + acc += dw * dx * (float) (s - hx); + } + return acc; +} + +__device__ __forceinline__ float warp_sum(float v) { +#pragma unroll + for (int off = 16; off > 0; off >>= 1) v += __shfl_down_sync(0xFFFFFFFFu, v, off); + return v; +} + +/// GATE AND UP, ONE WARP PER ROW. +/// +/// **THE ROWS ARE INTERLEAVED AND THE FIRST VERSION OF THIS GOT IT WRONG.** In the blob, row-SLOT `i` of the +/// `2*FF` gate/up rows is gate row `i/2` when `i` is even and up row `(i-1)/2` when it is odd - that is what +/// `O_GU_CODES + (2r)*ROW_GU` and `+ (2r+1)*ROW_GU` mean in `expert.cpp`. This kernel decoded the correct +/// code row for slot `i` and then wrote it to output slot `i` of a layout whose first `FF` entries are gate and +/// whose last `FF` are up, which pairs `silu(gate[r]) * up[r]` with the WRONG `up` for every `r`. It produced +/// finite, plausible numbers. `moe_hit_parity` caught it on the first run, at worst relative error 2.2e+03. +/// +/// So slot `i` is DECODED from row-slot `i` and WRITTEN to the output slot its parity says it belongs to. +__global__ void gu_kernel(const uint8_t* __restrict__ blob_base, const int32_t* __restrict__ slot_index, + long long blob_bytes, const uint8_t* __restrict__ x_q8_0, + const float* __restrict__ x_scales, float* __restrict__ gate_up, int n_hits, + const int32_t* __restrict__ d_count = nullptr, + const int32_t* __restrict__ dst_index = nullptr, int tok_div = 0) { + const int warps_per_block = (int) (blockDim.x >> 5); + const long long slot = (long long) blockIdx.x * warps_per_block + (threadIdx.x >> 5); + const long long rows_per_hit = 2LL * FF; + const long long total = (long long) n_hits * rows_per_hit; + if (slot >= total) return; + const int h = (int) (slot / rows_per_hit); + if (d_count != nullptr && h >= *d_count) return; // token graph: capacity layout, device count + const int i = (int) (slot % rows_per_hit); + const int lane = threadIdx.x & 31; + + const uint8_t* blob = blob_base + (size_t) slot_index[h] * (size_t) blob_bytes; + if (tok_div > 0) { // plan v0.3 P6 verify window: each hit reads its own token's activation + const int tok = dst_index[h] / tok_div; + x_q8_0 += (size_t) tok * (size_t) (H / 32) * 34; + if (x_scales != nullptr) x_scales += (size_t) tok * (size_t) (H / 32); + } + const float acc = row_dot_s2_q8(blob + (size_t) i * ROW_GU, + blob + O_GU_SCALES + (size_t) i * SC_GU * 2, x_q8_0, H / 32, lane, x_scales); + const float s = warp_sum(acc); + if (lane != 0) return; + // ---- THE OUTPUT LAYOUT IS GATE-MAJOR, AND THAT IS NOT COSMETIC. + // + // The first version wrote `gate_up[h*2FF + {0..FF-1}] = gate` and `[h*2FF + FF..] = up`, i.e. a per-hit + // [gate | up] pair, and then called the shared `swiglu_kernel` and `quantize_q8_0` over the whole buffer. + // Both of those walk a CONTIGUOUS range, so with more than one hit they read hit 0's UP rows where they + // wanted hit 1's GATE rows - finite numbers, wrong expert. With one hit it would have passed. + // + // Gate-major removes the mismatch instead of adding a stride to two other kernels: every hit's gate rows + // are contiguous from 0, every hit's up rows are contiguous from `n_hits * FF`, and the swiglu's output + // lands in the first `n_hits * FF` floats exactly where `quantize_q8_0` reads it. + const int r = i >> 1; + const size_t base = (i & 1) ? ((size_t) n_hits * FF + (size_t) h * FF) : ((size_t) h * FF); + gate_up[base + (size_t) r] = s; +} + +/// `silu(gate) * up`, in place, over a GATE-MAJOR buffer: `[0, n_pairs)` is every hit's gate and +/// `[n_pairs, 2*n_pairs)` is every hit's up, so hit `h`'s row `r` meets itself at `h*FF + r`. +/// +/// SiLU on the GATE and multiplied by up - the reading `docs/semantics.md` records, and the one that is wrong +/// the other way round in a way that still produces a finite number. +__global__ void swiglu_kernel(float* __restrict__ gate_up, long long n_pairs) { + const long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n_pairs) return; + const float g = gate_up[i]; + const float u = gate_up[n_pairs + i]; + gate_up[i] = (g / (1.0f + __expf(-g))) * u; +} + +/// DOWN, ONE WARP PER ROW, reading the quantized intermediate the caller produced. +/// +/// `dst_index[h]` is which row of the shared output buffer hit `h` fills - see the header. It is the router's +/// slot, not `h`, and the two differ on every layer where some experts are resident and some are not. +__global__ void down_kernel(const uint8_t* __restrict__ blob_base, const int32_t* __restrict__ slot_index, + const int32_t* __restrict__ dst_index, long long blob_bytes, + const uint8_t* __restrict__ h_q8_0, const float* __restrict__ h_scales, + float* __restrict__ out, int n_hits, const int32_t* __restrict__ d_count = nullptr) { + const int warps_per_block = (int) (blockDim.x >> 5); + const long long row = (long long) blockIdx.x * warps_per_block + (threadIdx.x >> 5); + const long long total = (long long) n_hits * H; + if (row >= total) return; + const int h = (int) (row / H); + if (d_count != nullptr && h >= *d_count) return; + const int r = (int) (row % H); + const int lane = threadIdx.x & 31; + + const uint8_t* blob = blob_base + (size_t) slot_index[h] * (size_t) blob_bytes; + const uint8_t* xb = h_q8_0 + (size_t) h * (size_t) (FF / 32) * 34; + const float acc = row_dot_s2_q8(blob + O_D_CODES + (size_t) r * ROW_D, + blob + O_D_SCALES + (size_t) r * SC_D * 2, xb, FF / 32, lane, + h_scales ? h_scales + (size_t) h * (size_t) (FF / 32) : nullptr); + const float s = warp_sum(acc); + if (lane == 0) out[(size_t) dst_index[h] * H + r] = s; +} + +// The CPU subtracts the weight bias after its eight FMA accumulators have been reduced. Moving the +// subtraction into each integer dot, as the legacy kernel does, changes rounding even with equal scales. +__global__ void activation_correction_kernel(const uint8_t* q8, const float* scales, float* hx, int chunks) { + const int c = blockIdx.x * blockDim.x + threadIdx.x; + if (c >= chunks) return; + const int8_t* q = (const int8_t*) (q8 + (size_t) c * 34 + 2); + int sum = 0; + for (int j = 0; j < 32; ++j) sum += q[j]; + hx[c] = __fmul_rn(scales[c], (float) sum); +} + +__device__ __forceinline__ int dot4(const uint8_t* codes, const int8_t* q) { + const unsigned c = *codes; + const int cw = (int) ((c & 3u) | (((c >> 2) & 3u) << 8) | + (((c >> 4) & 3u) << 16) | (((c >> 6) & 3u) << 24)); + int xw; + memcpy(&xw, q, sizeof xw); + return __dp4a(cw, xw, 0); +} + +__device__ __forceinline__ float row_dot_cpu_order(const uint8_t* codes, const uint8_t* scales, + const uint8_t* xq, const float* xs, + const float* hx, int blocks, int lane) { + float acc = 0.0f; + float corr = 0.0f; + for (int b = 0; b < blocks; ++b) { + const float d = f16_at(scales + 2 * b); + const int lo = dot4(codes + b * 16 + lane, + (const int8_t*) (xq + (size_t) (2 * b) * 34 + 2) + lane * 4); + const int hi = dot4(codes + b * 16 + 8 + lane, + (const int8_t*) (xq + (size_t) (2 * b + 1) * 34 + 2) + lane * 4); + acc = __fmaf_rn(__fmul_rn(d, xs[2 * b]), (float) lo, acc); + acc = __fmaf_rn(__fmul_rn(d, xs[2 * b + 1]), (float) hi, acc); + if (lane == 0) + corr = __fadd_rn(corr, __fmul_rn(d, __fadd_rn(hx[2 * b], hx[2 * b + 1]))); + } + // _mm_add_ps(low128, high128), then two _mm_hadd_ps. The pair order is 4, 1, 2; + // a standard shuffle tree in the order 4, 2, 1 is a different floating-point expression. + constexpr unsigned mask = 0xffffffffu; + acc = __fadd_rn(acc, __shfl_down_sync(mask, acc, 4, 8)); + acc = __fadd_rn(acc, __shfl_down_sync(mask, acc, 1, 8)); + acc = __fadd_rn(acc, __shfl_down_sync(mask, acc, 2, 8)); + return __fsub_rn(acc, corr); // Only lane zero is consumed. +} + +template +__global__ void cpu_order_projection_kernel(const uint8_t* blob_base, const int32_t* slots, + const int32_t* destinations, long long blob_bytes, + const uint8_t* xq, const float* xs, const float* hx, + float* out, int n_hits) { + constexpr int rows_per_hit = DOWN ? H : 2 * FF; + const int row = blockIdx.x * (blockDim.x / 8) + threadIdx.x / 8; + if (row >= n_hits * rows_per_hit) return; + const int h = row / rows_per_hit; + const int r = row % rows_per_hit; + const int lane = threadIdx.x & 7; + const uint8_t* blob = blob_base + (size_t) slots[h] * (size_t) blob_bytes; + const int chunks_offset = DOWN ? h * (FF / 32) : 0; + const uint8_t* codes = DOWN ? blob + O_D_CODES + (size_t) r * ROW_D : blob + (size_t) r * ROW_GU; + const uint8_t* scales = DOWN ? blob + O_D_SCALES + (size_t) r * SC_D * 2 + : blob + O_GU_SCALES + (size_t) r * SC_GU * 2; + const float value = row_dot_cpu_order(codes, scales, xq + (size_t) chunks_offset * 34, + xs + chunks_offset, hx + chunks_offset, + DOWN ? SC_D : SC_GU, lane); + if (lane != 0) return; + if (DOWN) out[(size_t) destinations[h] * H + r] = value; + else out[((r & 1) ? (size_t) n_hits * FF : 0) + (size_t) h * FF + (r >> 1)] = value; +} + +__global__ void cpu_order_swiglu_kernel(float* gu, int pairs) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i >= pairs) return; + const float g = gu[i]; + // Accurate fp32 exponential; __expf's approximation would introduce an additional source of error. + // CPU/GPU libc last-bit differences are diagnosed separately by the micro, not hidden with FP64 here. + const float eg = expf(-g); + gu[i] = __fmul_rn(__fdiv_rn(g, __fadd_rn(1.0f, eg)), gu[pairs + i]); +} + +__global__ void cpu_order_quantize_kernel(const float* x, uint8_t* blocks, float* scales, + float* hx, int chunks) { + const int c = blockIdx.x * blockDim.x + threadIdx.x; + if (c >= chunks) return; + const float* xb = x + c * 32; + uint8_t* out = blocks + (size_t) c * 34; + float amax = 0.0f; + for (int j = 0; j < 32; ++j) amax = fmaxf(amax, fabsf(xb[j])); + const float s = amax > 0.0f ? __fdiv_rn(amax, 127.0f) : 0.0f; + const float inv = s > 0.0f ? __fdiv_rn(1.0f, s) : 0.0f; + scales[c] = s; + const uint16_t bits = __half_as_ushort(__float2half_rn(s)); + out[0] = (uint8_t) bits; + out[1] = (uint8_t) (bits >> 8); + int sum = 0; + for (int j = 0; j < 32; ++j) { + const float t = __fmul_rn(xb[j], inv); + int v = (int) __fadd_rn(t, t >= 0.0f ? 0.5f : -0.5f); + v = v < -127 ? -127 : (v > 127 ? 127 : v); + out[2 + j] = (uint8_t) (int8_t) v; + sum += v; + } + hx[c] = __fmul_rn(s, (float) sum); +} + +constexpr int THREADS = 256; + +void check(const char* who, void* stream) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "%s launch: %s\n", who, cudaGetErrorString(e)); + std::exit(1); + } + // Deliberately NOT synchronising for a non-null stream: this is called once per layer from a captured + // graph's worth of work, and `finish()`'s null-stream sync in `s_gemv.cu` is the pattern that made a whole + // round of measurements the driver's cost instead of the kernel's (Memory/ERRORS.md RC-7). + (void) stream; +} + +} // namespace + +uint64_t moe_hit_grouped_scratch_bytes(int64_t n_hits, int64_t n_embd, int64_t n_ff) { + if (n_hits <= 0) return 0; + const uint64_t gu = (uint64_t) n_hits * (uint64_t) (2 * n_ff) * 4; + const uint64_t q8 = (uint64_t) n_hits * (uint64_t) (n_ff / 32) * 34; + // R4.2h: the fp32 scales for the INTERMEDIATE's own quantization, one per 32-element chunk per hit. + const uint64_t hs = (uint64_t) n_hits * (uint64_t) (n_ff / 32) * 4; + const uint64_t xh = (uint64_t) (n_embd / 32) * 4; + return ((gu + 15) & ~15ull) + ((q8 + 15) & ~15ull) + 2 * ((hs + 15) & ~15ull) + + ((xh + 15) & ~15ull); +} + +void moe_hit_grouped_s2(const uint8_t* blob_base, const int32_t* slot_index, const int32_t* dst_index, + int64_t n_hits, int64_t blob_bytes, const uint8_t* x_q8_0, void* scratch, float* out, + void* stream, const float* x_scales) { + if (n_hits <= 0) return; + cudaStream_t cs = (cudaStream_t) stream; + const int warps = THREADS / 32; + + const uint64_t gu_bytes = ((uint64_t) n_hits * (uint64_t) (2 * FF) * 4 + 15) & ~15ull; + const uint64_t q8_bytes = ((uint64_t) n_hits * (uint64_t) (FF / 32) * 34 + 15) & ~15ull; + float* gate_up = (float*) scratch; + uint8_t* h_q8_0 = (uint8_t*) scratch + gu_bytes; + float* h_scales = (float*) ((uint8_t*) scratch + gu_bytes + q8_bytes); + + // 1. gate + up, one launch for every row of every hit. + { + const long long rows = n_hits * 2LL * FF; + const unsigned blocks = (unsigned) ((rows + warps - 1) / warps); + gu_kernel<<>>(blob_base, slot_index, blob_bytes, x_q8_0, x_scales, gate_up, + (int) n_hits); + check("moe_hit_grouped_s2/gu", stream); + } + // 2. silu(gate) * up. + { + const long long pairs = n_hits * (long long) FF; + const unsigned blocks = (unsigned) ((pairs + THREADS - 1) / THREADS); + swiglu_kernel<<>>(gate_up, pairs); + check("moe_hit_grouped_s2/swiglu", stream); + } + // 3. the intermediate's own contract, which is `ggml_mul_mat`'s rule and NOT a choice: the down weight is + // Q2_0, whose `vec_dot_type` is Q8_0. `gate_up` holds the pairs; the product went into the first half. + // **R4.2h: the CPU quantizes this intermediate into `a2` with fp32 scales too** (`expert.cpp:232`), so + // when the caller supplies `x_scales` the intermediate gets the CPU's contract as well - otherwise the + // down projection would keep the very disagreement the gate/up projection just had removed. + if (x_scales != nullptr) quantize_q8_0_scaled(gate_up, h_q8_0, h_scales, n_hits * (int64_t) FF, stream); + else quantize_q8_0(gate_up, h_q8_0, n_hits * (int64_t) FF, stream); + // 4. down. + { + const long long rows = n_hits * (long long) H; + const unsigned blocks = (unsigned) ((rows + warps - 1) / warps); + down_kernel<<>>(blob_base, slot_index, dst_index, blob_bytes, h_q8_0, + x_scales != nullptr ? h_scales : nullptr, out, (int) n_hits); + check("moe_hit_grouped_s2/down", stream); + } +} + +namespace { +// Plan v0.3 P4 token graph: which of this layer's routed experts are resident, decided ON THE DEVICE from the +// static residency row, so no host step sits between the ring and the hit kernels. One warp; k <= 32. +__global__ void hit_select_kernel(const int32_t* __restrict__ ids, const int32_t* __restrict__ res_row, int k, + int n_expert, int32_t* __restrict__ slot, int32_t* __restrict__ dst, + int32_t* __restrict__ count) { + const int lane = threadIdx.x; + int s = -1; + if (lane < k) { + const int e = ids[lane]; + if (e >= 0 && e < n_expert) s = res_row[e]; + } + const unsigned hit = __ballot_sync(0xffffffffu, s >= 0); + if (s >= 0) { + const int at = __popc(hit & ((1u << lane) - 1u)); + slot[at] = s; + dst[at] = lane; + } + if (lane == 0) *count = __popc(hit); +} + +// Plan v0.3 P6: the same for up to 128 routed entries (a verify window of T tokens x k): four warps, ballots +// compacted in entry order. +__global__ void hit_select_multi_kernel(const int32_t* __restrict__ ids, const int32_t* __restrict__ res_row, int n, + int n_expert, int32_t* __restrict__ slot, int32_t* __restrict__ dst, + int32_t* __restrict__ count) { + __shared__ int warp_count[4]; + const int i = threadIdx.x, lane = i & 31, warp = i >> 5; + int s = -1; + if (i < n) { + const int e = ids[i]; + if (e >= 0 && e < n_expert) s = res_row[e]; + } + const unsigned hit = __ballot_sync(0xffffffffu, s >= 0); + if (lane == 0) warp_count[warp] = __popc(hit); + __syncthreads(); + int before = 0; + for (int w = 0; w < warp; ++w) before += warp_count[w]; + if (s >= 0) { + const int at = before + __popc(hit & ((1u << lane) - 1u)); + slot[at] = s; + dst[at] = i; + } + if (i == 0) *count = warp_count[0] + warp_count[1] + warp_count[2] + warp_count[3]; +} + +__global__ void add_hits_kernel(float* __restrict__ parts, const float* __restrict__ hit_out, + const int32_t* __restrict__ dst, const int32_t* __restrict__ count, int n_embd) { + const int h = blockIdx.y; + if (h >= *count) return; + const size_t row = (size_t) dst[h] * (size_t) n_embd; + for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n_embd; i += gridDim.x * blockDim.x) + parts[row + i] += hit_out[row + i]; +} +} // namespace + +void moe_hit_select(const int32_t* ids, const int32_t* res_row, int k, int n_expert, int32_t* slot, int32_t* dst, + int32_t* count, void* stream) { + if (k < 1 || k > 32) { std::fprintf(stderr, "moe_hit_select: k must be 1..32\n"); std::exit(1); } + hit_select_kernel<<<1, 32, 0, (cudaStream_t) stream>>>(ids, res_row, k, n_expert, slot, dst, count); + check("moe_hit_select", stream); +} + +void moe_hit_grouped_s2_dev(const uint8_t* blob_base, const int32_t* slot_index, const int32_t* dst_index, + const int32_t* d_count, int64_t cap, int64_t blob_bytes, const uint8_t* x_q8_0, + void* scratch, float* out, void* stream, const float* x_scales) { + if (cap <= 0) return; + cudaStream_t cs = (cudaStream_t) stream; + const int warps = THREADS / 32; + const uint64_t gu_bytes = ((uint64_t) cap * (uint64_t) (2 * FF) * 4 + 15) & ~15ull; + const uint64_t q8_bytes = ((uint64_t) cap * (uint64_t) (FF / 32) * 34 + 15) & ~15ull; + float* gate_up = (float*) scratch; + uint8_t* h_q8_0 = (uint8_t*) scratch + gu_bytes; + float* h_scales = (float*) ((uint8_t*) scratch + gu_bytes + q8_bytes); + { + const long long rows = cap * 2LL * FF; + gu_kernel<<<(unsigned) ((rows + warps - 1) / warps), THREADS, 0, cs>>>( + blob_base, slot_index, blob_bytes, x_q8_0, x_scales, gate_up, (int) cap, d_count); + check("moe_hit_grouped_s2_dev/gu", stream); + } + { + const long long pairs = cap * (long long) FF; + swiglu_kernel<<<(unsigned) ((pairs + THREADS - 1) / THREADS), THREADS, 0, cs>>>(gate_up, pairs); + check("moe_hit_grouped_s2_dev/swiglu", stream); + } + if (x_scales != nullptr) quantize_q8_0_scaled(gate_up, h_q8_0, h_scales, cap * (int64_t) FF, stream); + else quantize_q8_0(gate_up, h_q8_0, cap * (int64_t) FF, stream); + { + const long long rows = cap * (long long) H; + down_kernel<<<(unsigned) ((rows + warps - 1) / warps), THREADS, 0, cs>>>( + blob_base, slot_index, dst_index, blob_bytes, h_q8_0, x_scales != nullptr ? h_scales : nullptr, out, + (int) cap, d_count); + check("moe_hit_grouped_s2_dev/down", stream); + } +} + +void moe_hit_select_multi(const int32_t* ids, const int32_t* res_row, int n, int n_expert, int32_t* slot, int32_t* dst, + int32_t* count, void* stream) { + if (n < 1 || n > 128) { std::fprintf(stderr, "moe_hit_select_multi: n must be 1..128\n"); std::exit(1); } + hit_select_multi_kernel<<<1, 128, 0, (cudaStream_t) stream>>>(ids, res_row, n, n_expert, slot, dst, count); + check("moe_hit_select_multi", stream); +} + +void moe_hit_grouped_s2_multi(const uint8_t* blob_base, const int32_t* slot_index, const int32_t* dst_index, + const int32_t* d_count, int64_t cap, int64_t blob_bytes, const uint8_t* x_q8_0, + const float* x_scales, int k_per_token, void* scratch, float* out, void* stream) { + if (cap <= 0) return; + cudaStream_t cs = (cudaStream_t) stream; + const int warps = THREADS / 32; + const uint64_t gu_bytes = ((uint64_t) cap * (uint64_t) (2 * FF) * 4 + 15) & ~15ull; + const uint64_t q8_bytes = ((uint64_t) cap * (uint64_t) (FF / 32) * 34 + 15) & ~15ull; + float* gate_up = (float*) scratch; + uint8_t* h_q8_0 = (uint8_t*) scratch + gu_bytes; + float* h_scales = (float*) ((uint8_t*) scratch + gu_bytes + q8_bytes); + { + const long long rows = cap * 2LL * FF; + gu_kernel<<<(unsigned) ((rows + warps - 1) / warps), THREADS, 0, cs>>>( + blob_base, slot_index, blob_bytes, x_q8_0, x_scales, gate_up, (int) cap, d_count, dst_index, k_per_token); + check("moe_hit_grouped_s2_multi/gu", stream); + } + { + const long long pairs = cap * (long long) FF; + swiglu_kernel<<<(unsigned) ((pairs + THREADS - 1) / THREADS), THREADS, 0, cs>>>(gate_up, pairs); + check("moe_hit_grouped_s2_multi/swiglu", stream); + } + if (x_scales != nullptr) quantize_q8_0_scaled(gate_up, h_q8_0, h_scales, cap * (int64_t) FF, stream); + else quantize_q8_0(gate_up, h_q8_0, cap * (int64_t) FF, stream); + { + const long long rows = cap * (long long) H; + down_kernel<<<(unsigned) ((rows + warps - 1) / warps), THREADS, 0, cs>>>( + blob_base, slot_index, dst_index, blob_bytes, h_q8_0, x_scales != nullptr ? h_scales : nullptr, out, + (int) cap, d_count); + check("moe_hit_grouped_s2_multi/down", stream); + } +} + +namespace { +// Plan v0.3 P6: experts GROUPED - one blob pointer per group (a VRAM slot or a mapped host blob read over PCIe), +// every entry of the group (a token routed to that expert) computed from ONE read of each row. Per entry the +// arithmetic is `row_dot_s2_q8`'s, chunk by chunk in the same lane order, so every entry is bitwise the per-entry +// hit kernel's. +constexpr int GU_CHUNKS = (H / 32 + 31) / 32; // 3: chunks of a gate/up row per lane (80 chunks / 32 lanes) +constexpr int GMAX = 8; // entries per group (tokens routed to one expert in a window) +constexpr int GU_ROWS = 32; // gate/up rows per block: 4 per warp +constexpr int D_ROWS = 64; // down rows per block: 8 per warp + +// One activation chunk's contribution, `row_dot_s2_q8`'s inner body with the 32 int8 of the chunk already in +// aligned words: same dp4a sequence, same float expression, so the result is bitwise the per-entry kernel's. +__device__ __forceinline__ float chunk_dot(uint2 cb, const int* xw, float dw, float dx) { + const uint8_t* cbytes = (const uint8_t*) &cb; + int s = 0, hx = 0; + const int ones = 0x01010101; +#pragma unroll + for (int j = 0; j < 8; ++j) { + const unsigned cbyte = cbytes[j]; + const int cw = (int) ((cbyte & 3u) | (((cbyte >> 2) & 3u) << 8) | (((cbyte >> 4) & 3u) << 16) | + (((cbyte >> 6) & 3u) << 24)); + s = __dp4a(cw, xw[j], s); + hx = __dp4a(ones, xw[j], hx); + } + return dw * dx * (float) (s - hx); +} + +// Gate/up: a block = GU_ROWS rows of ONE group. The group's activations (each entry's token row of x_q8_0 and +// its fp32 scales) are staged once into shared memory as aligned words; each warp then walks its rows, loading +// each lane's code chunks once and dotting them with every entry. +__global__ void __launch_bounds__(256) gu_grouped_kernel(const unsigned long long* __restrict__ grp_ptr, + const int32_t* __restrict__ grp_start, + const int32_t* __restrict__ n_groups, + const int32_t* __restrict__ ent_tok, + const uint8_t* __restrict__ x_q8_0, + const float* __restrict__ x_scales, + float* __restrict__ gate_up, int cap_entries) { + __shared__ int xs_q[GMAX][H / 4]; // the entries' int8 activations as words (2560 B each) + __shared__ float xs_d[GMAX][H / 32]; + const int g = blockIdx.y; + if (g >= *n_groups) return; + const int e0 = grp_start[g], ne = min(grp_start[g + 1] - e0, GMAX); + const int t = threadIdx.x, lane = t & 31, warp = t >> 5; + for (int i = t; i < ne * (H / 32); i += blockDim.x) { + const int k = i / (H / 32), c = i - k * (H / 32); + const uint8_t* xb = x_q8_0 + (size_t) ent_tok[e0 + k] * (size_t) (H / 32) * 34 + (size_t) c * 34; + xs_d[k][c] = x_scales ? x_scales[(size_t) ent_tok[e0 + k] * (H / 32) + c] : f16_at(xb); + const int8_t* q = (const int8_t*) (xb + 2); +#pragma unroll + for (int w = 0; w < 8; ++w) { + int v; + memcpy(&v, q + 4 * w, 4); + xs_q[k][c * 8 + w] = v; + } + } + __syncthreads(); + const uint8_t* blob = (const uint8_t*) grp_ptr[g]; + const int row0 = blockIdx.x * GU_ROWS; + for (int rr = warp; rr < GU_ROWS; rr += 8) { + const int i = row0 + rr; + const uint8_t* codes = blob + (size_t) i * ROW_GU; + const uint8_t* scales = blob + O_GU_SCALES + (size_t) i * SC_GU * 2; + uint2 cb[GU_CHUNKS]; + float dw[GU_CHUNKS]; +#pragma unroll + for (int q = 0; q < GU_CHUNKS; ++q) { + const int c = lane + 32 * q; + if (c < H / 32) { + cb[q] = *(const uint2*) (codes + (size_t) c * 8); + dw[q] = f16_at(scales + (size_t) (c >> 1) * 2); + } + } + for (int k = 0; k < ne; ++k) { + float acc = 0.0f; +#pragma unroll + for (int q = 0; q < GU_CHUNKS; ++q) { + const int c = lane + 32 * q; + if (c >= H / 32) break; + acc += chunk_dot(cb[q], &xs_q[k][c * 8], dw[q], xs_d[k][c]); + } + const float sum = warp_sum(acc); + if (lane == 0) { + const int e = e0 + k, r = i >> 1; + const size_t base = (i & 1) ? ((size_t) cap_entries * FF + (size_t) e * FF) : ((size_t) e * FF); + gate_up[base + (size_t) r] = sum; + } + } + } +} + +// Down: a block = D_ROWS rows of ONE group; the entries' quantized intermediates staged once. A down row is 20 +// chunks, so lanes 0..19 each hold one chunk, as in the per-entry kernel. +__global__ void __launch_bounds__(256) down_grouped_kernel(const unsigned long long* __restrict__ grp_ptr, + const int32_t* __restrict__ grp_start, + const int32_t* __restrict__ n_groups, + const int32_t* __restrict__ ent_dst, + const uint8_t* __restrict__ h_q8_0, + const float* __restrict__ h_scales, + float* __restrict__ out) { + __shared__ int hs_q[GMAX][FF / 4]; + __shared__ float hs_d[GMAX][FF / 32]; + const int g = blockIdx.y; + if (g >= *n_groups) return; + const int e0 = grp_start[g], ne = min(grp_start[g + 1] - e0, GMAX); + const int t = threadIdx.x, lane = t & 31, warp = t >> 5; + for (int i = t; i < ne * (FF / 32); i += blockDim.x) { + const int k = i / (FF / 32), c = i - k * (FF / 32); + const uint8_t* xb = h_q8_0 + (size_t) (e0 + k) * (size_t) (FF / 32) * 34 + (size_t) c * 34; + hs_d[k][c] = h_scales ? h_scales[(size_t) (e0 + k) * (FF / 32) + c] : f16_at(xb); + const int8_t* q = (const int8_t*) (xb + 2); +#pragma unroll + for (int w = 0; w < 8; ++w) { + int v; + memcpy(&v, q + 4 * w, 4); + hs_q[k][c * 8 + w] = v; + } + } + __syncthreads(); + const uint8_t* blob = (const uint8_t*) grp_ptr[g]; + const int row0 = blockIdx.x * D_ROWS; + for (int rr = warp; rr < D_ROWS; rr += 8) { + const int r = row0 + rr; + const uint8_t* codes = blob + O_D_CODES + (size_t) r * ROW_D; + const uint8_t* scales = blob + O_D_SCALES + (size_t) r * SC_D * 2; + const int c = lane; + uint2 cb = make_uint2(0, 0); + float dw = 0.0f; + if (c < FF / 32) { + cb = *(const uint2*) (codes + (size_t) c * 8); + dw = f16_at(scales + (size_t) (c >> 1) * 2); + } + for (int k = 0; k < ne; ++k) { + float acc = 0.0f; + if (c < FF / 32) acc += chunk_dot(cb, &hs_q[k][c * 8], dw, hs_d[k][c]); + const float sum = warp_sum(acc); + if (lane == 0) out[(size_t) ent_dst[e0 + k] * H + r] = sum; + } + } +} +} // namespace + +namespace { +// Plan v0.3 P6: groups built on the device when every expert is resident at `base + id * blob` (the MTP layer): +// one block of 128 threads, groups in first-appearance order, entries of a group in routing order. +__global__ void group_resident_kernel(const int32_t* __restrict__ ids, int n, int k_per_tok, const uint8_t* base, + long long blob, unsigned long long* __restrict__ grp_ptr, + int32_t* __restrict__ grp_start, int32_t* __restrict__ counts, + int32_t* __restrict__ ent_dst, int32_t* __restrict__ ent_tok) { + __shared__ int e_s[128], first_s[128], size_s[128], gidx_s[128], gstart_s[129]; + const int i = threadIdx.x; + const int e = i < n ? ids[i] : -1; + e_s[i] = e; + __syncthreads(); + int first = i, rank = 0, size = 0; + if (i < n) { + for (int j = 0; j < i; ++j) + if (e_s[j] == e) { if (first == i) first = j; ++rank; } + if (first == i) + for (int j = i; j < n; ++j) size += e_s[j] == e; + } + first_s[i] = first; + size_s[i] = (i < n && first == i) ? size : 0; + __syncthreads(); + if (i == 0) { + int gi = 0, acc = 0; + for (int j = 0; j < n; ++j) + if (first_s[j] == j) { + gidx_s[j] = gi; + gstart_s[gi] = acc; + grp_ptr[gi] = (unsigned long long) (base + (size_t) e_s[j] * (size_t) blob); + grp_start[gi] = acc; + acc += size_s[j]; + ++gi; + } + grp_start[gi] = acc; + counts[0] = gi; + counts[1] = acc; + } + __syncthreads(); + if (i < n) { + const int at = gstart_s[gidx_s[first]] + rank; + ent_dst[at] = i; + ent_tok[at] = i / k_per_tok; + } +} +} // namespace + +void moe_group_resident(const int32_t* ids, int n, int k_per_tok, const uint8_t* base, int64_t blob, + unsigned long long* grp_ptr, int32_t* grp_start, int32_t* counts, int32_t* ent_dst, + int32_t* ent_tok, void* stream) { + if (n < 1 || n > 128) { std::fprintf(stderr, "moe_group_resident: n must be 1..128\n"); std::exit(1); } + group_resident_kernel<<<1, 128, 0, (cudaStream_t) stream>>>(ids, n, k_per_tok, base, (long long) blob, grp_ptr, + grp_start, counts, ent_dst, ent_tok); + check("moe_group_resident", stream); +} + +void moe_grouped_s2(const unsigned long long* grp_ptr, const int32_t* grp_start, const int32_t* n_groups, + const int32_t* ent_dst, const int32_t* ent_tok, int64_t cap_groups, int64_t cap_entries, + const uint8_t* x_q8_0, const float* x_scales, void* scratch, float* out, void* stream) { + if (cap_groups <= 0 || cap_entries <= 0) return; + cudaStream_t cs = (cudaStream_t) stream; + const uint64_t gu_bytes = ((uint64_t) cap_entries * (uint64_t) (2 * FF) * 4 + 15) & ~15ull; + const uint64_t q8_bytes = ((uint64_t) cap_entries * (uint64_t) (FF / 32) * 34 + 15) & ~15ull; + float* gate_up = (float*) scratch; + uint8_t* h_q8_0 = (uint8_t*) scratch + gu_bytes; + float* h_scales = (float*) ((uint8_t*) scratch + gu_bytes + q8_bytes); + { + gu_grouped_kernel<<>>( + grp_ptr, grp_start, n_groups, ent_tok, x_q8_0, x_scales, gate_up, (int) cap_entries); + check("moe_grouped_s2/gu", stream); + } + { + const long long pairs = cap_entries * (long long) FF; + swiglu_kernel<<<(unsigned) ((pairs + THREADS - 1) / THREADS), THREADS, 0, cs>>>(gate_up, pairs); + check("moe_grouped_s2/swiglu", stream); + } + if (x_scales != nullptr) quantize_q8_0_scaled(gate_up, h_q8_0, h_scales, cap_entries * (int64_t) FF, stream); + else quantize_q8_0(gate_up, h_q8_0, cap_entries * (int64_t) FF, stream); + { + down_grouped_kernel<<>>( + grp_ptr, grp_start, n_groups, ent_dst, h_q8_0, x_scales != nullptr ? h_scales : nullptr, out); + check("moe_grouped_s2/down", stream); + } +} + +void moe_hit_add(float* parts, const float* hit_out, const int32_t* dst, const int32_t* count, int64_t cap, + int64_t n_embd, void* stream) { + if (cap <= 0) return; + const dim3 grid((unsigned) ((n_embd + 255) / 256 < 8 ? (n_embd + 255) / 256 : 8), (unsigned) cap); + add_hits_kernel<<>>(parts, hit_out, dst, count, (int) n_embd); + check("moe_hit_add", stream); +} + +void moe_hit_grouped_s2_cpu_order(const uint8_t* blob_base, const int32_t* slot_index, + const int32_t* dst_index, int64_t n_hits, int64_t blob_bytes, + const uint8_t* x_q8_0, void* scratch, float* out, void* stream, + const float* x_scales, float* gate_up_trace) { + if (n_hits <= 0) return; + if (x_scales == nullptr) { + std::fprintf(stderr, "moe_hit_grouped_s2_cpu_order requires fp32 activation scales\n"); + std::exit(1); + } + cudaStream_t cs = (cudaStream_t) stream; + const uint64_t gu_bytes = ((uint64_t) n_hits * 2 * FF * 4 + 15) & ~15ull; + const uint64_t q8_bytes = ((uint64_t) n_hits * (FF / 32) * 34 + 15) & ~15ull; + const uint64_t scale_bytes = ((uint64_t) n_hits * (FF / 32) * 4 + 15) & ~15ull; + float* gu = (float*) scratch; + uint8_t* hq = (uint8_t*) scratch + gu_bytes; + float* hs = (float*) (hq + q8_bytes); + float* hh = (float*) ((uint8_t*) hs + scale_bytes); + float* xh = (float*) ((uint8_t*) hh + scale_bytes); + activation_correction_kernel<<<(H / 32 + THREADS - 1) / THREADS, THREADS, 0, cs>>>( + x_q8_0, x_scales, xh, H / 32); + check("cpu_order/input_correction", stream); + const int rows_per_block = THREADS / 8; + cpu_order_projection_kernel<<<(unsigned) ((n_hits * 2 * FF + rows_per_block - 1) / rows_per_block), + THREADS, 0, cs>>>( + blob_base, slot_index, dst_index, blob_bytes, x_q8_0, x_scales, xh, gu, (int) n_hits); + check("cpu_order/gate_up", stream); + if (gate_up_trace != nullptr && + cudaMemcpyAsync(gate_up_trace, gu, (size_t) n_hits * 2 * FF * sizeof(float), + cudaMemcpyDeviceToDevice, cs) != cudaSuccess) { + std::fprintf(stderr, "cpu_order/gate_up_trace copy failed\n"); + std::exit(1); + } + cpu_order_swiglu_kernel<<<(unsigned) ((n_hits * FF + THREADS - 1) / THREADS), THREADS, 0, cs>>>( + gu, (int) n_hits * FF); + check("cpu_order/swiglu", stream); + cpu_order_quantize_kernel<<<(unsigned) ((n_hits * (FF / 32) + THREADS - 1) / THREADS), THREADS, 0, cs>>>( + gu, hq, hs, hh, (int) n_hits * (FF / 32)); + check("cpu_order/intermediate_quantize", stream); + cpu_order_projection_kernel<<<(unsigned) ((n_hits * H + rows_per_block - 1) / rows_per_block), + THREADS, 0, cs>>>( + blob_base, slot_index, dst_index, blob_bytes, hq, hs, hh, out, (int) n_hits); + check("cpu_order/down", stream); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/s2_gemv.cu b/src/kernels/cuda/s2_gemv.cu new file mode 100644 index 00000000..c1ff5cf2 --- /dev/null +++ b/src/kernels/cuda/s2_gemv.cu @@ -0,0 +1,63 @@ +// src/kernels/cuda/s2_gemv.cu - P2.S2: the S2 GEMV, one thread per output row. +// +// Naive per the phase rule: dequantize on the fly, FP32 accumulation inside the row, no shared memory, no +// vector loads, no __ldg hints. Phase 3 changes this file; the parity test is what says whether a change is +// still right. +#include "strata/kernels/s2_gemv.hpp" + +#include +#include + +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int QK = 64; + +__global__ void s2_gemv_kernel(const uint16_t* __restrict__ x, const uint8_t* __restrict__ codes, + const float* __restrict__ scales, float* __restrict__ y, long long n_in, + long long n_out) { + const long long o = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (o >= n_out) return; + + const long long nb = n_in / QK; + const uint8_t* c = codes + o * nb * (QK / 4); + const float* s = scales + o * nb; + + float acc = 0.0f; + for (long long b = 0; b < nb; ++b) { + const float d = s[b]; + const uint8_t* cb = c + b * (QK / 4); + const uint16_t* xb = x + b * QK; + for (int j = 0; j < QK; ++j) { + // (code - 1) in the INTEGER domain, then the group scale, then the activation; the CPU reference + // below is written in the same order so the two differ only by floating-point contraction. + const int code = (cb[j >> 2] >> ((j & 3) * 2)) & 0x03; + acc += (float) (code - 1) * d * __half2float(__ushort_as_half(xb[j])); + } + } + y[o] = acc; +} + +} // namespace + +void s2_gemv(const uint16_t* x, const uint8_t* codes, const float* scales, float* y, int64_t n_in, + int64_t n_out) { + if (n_in <= 0 || n_out <= 0) return; + if (n_in % QK != 0) { + std::fprintf(stderr, "s2_gemv: n_in %lld is not a multiple of %d\n", (long long) n_in, QK); + std::exit(1); + } + const int threads = 128; + const long long blocks = (n_out + threads - 1) / threads; + s2_gemv_kernel<<<(unsigned) blocks, threads>>>(x, codes, scales, y, n_in, n_out); + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "s2_gemv: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/s2_gemv_fast.cu b/src/kernels/cuda/s2_gemv_fast.cu new file mode 100644 index 00000000..a295fd6d --- /dev/null +++ b/src/kernels/cuda/s2_gemv_fast.cu @@ -0,0 +1,150 @@ +// src/kernels/cuda/s2_gemv_fast.cu - two levers on the hottest kernel, BOTH MEASURED NEGATIVE OR NEUTRAL. +// +// *** THIS FILE IS NAMED "fast" AND IS NOT FASTER. DO NOT ADOPT IT AS THE FAST PATH. *** +// It is kept because the negative result is worth more than the file costs: it closes two plausible +// optimisations and it is the experiment that established the measurement floor. +// +// L5 got the S2 GEMV from 10.5 to 86.2 G weights/s by amortising the loads, and left the instruction-count +// argument confirmed but unfinished. Two further reductions were obvious, so both were built and measured: +// +// LEVER 1 - STAGE `x` IN SHARED MEMORY. `x` is n_in halves (5 KB at n_embd 2560) and every output row reads +// all of it, so the generic kernel issues n_out * n_in activation loads per GEMV. Staging it once per block +// measured WORSE in every single paired comparison (59-76 G w/s staged against 64-87 G w/s unstaged, across +// four sweep passes and three thread counts). The __syncthreads and the copy cost more than the L1/L2 hits +// they replace - the same 5 KB is already resident and already broadcast. +// +// LEVER 2 - A CONSTANT-MEMORY CODE TABLE. Turn the per-element shifts, masks and int-to-float convert into +// one broadcast `float4` load per four elements. Measured NEUTRAL: the two orderings swap between passes +// with no consistent winner. +// +// WHY THE NEGATIVE RESULT IS THE REAL FINDING: run-to-run variance on this machine is now ~20% within a +// single configuration (quads tpr=32 measured 64.3 to 79.5 G w/s across four passes), because `verify` holds +// ~97% of six CPU cores. **The noise floor now EXCEEDS the effect sizes being chased**, so further +// micro-optimisation of this kernel cannot be evaluated here and should wait for a quiet machine rather than +// produce more numbers that cannot be distinguished from noise. +// +// Both changes alter the summation order or the expression, so this kernel is checked against the naive +// reference like every other one - being faster is never a reason to be trusted, and being neutral is not +// either. +#include "strata/kernels/s_gemv.hpp" + +#include +#include + +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int QK_S2 = 64; +constexpr int MAX_SHARED_HALVES = 4096; // 8 KB of shared for x; n_embd 2560 fits with room + +// byte -> the four code values with the -1 bias already applied, in element order (bits 0,2,4,6). +__constant__ float c_codes[256][4]; + +bool g_lut_ready = false; + +void ensure_lut() { + if (g_lut_ready) return; + float host[256][4]; + for (int b = 0; b < 256; ++b) { + for (int k = 0; k < 4; ++k) { + host[b][k] = (float) (((b >> (2 * k)) & 3) - 1); + } + } + const cudaError_t e = cudaMemcpyToSymbol(c_codes, host, sizeof(host)); + if (e != cudaSuccess) { + std::fprintf(stderr, "s2_gemv_fast: cudaMemcpyToSymbol failed: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + g_lut_ready = true; +} + +// One block per output row. `staged` says whether x was copied to shared, so the SAME kernel covers both +// configurations and the bench can measure them against each other with nothing else changed. +template +__global__ void s2_gemv_fast_kernel(const uint16_t* __restrict__ x, const uint8_t* __restrict__ codes, + const float* __restrict__ scales, float* __restrict__ y, long long n_in, + long long n_out, int threads_per_row) { + extern __shared__ float smem[]; + __half* sx = reinterpret_cast<__half*>(smem); // [threads_per_row, ...] partials live after + float* partial = smem + (STAGE_X ? (MAX_SHARED_HALVES / 2) : 0); + + const long long o = blockIdx.x; + if (o >= n_out) return; + const int tid = threadIdx.x; + + if (STAGE_X) { + const __half* xh = reinterpret_cast(x); + for (long long i = tid; i < n_in; i += threads_per_row) sx[i] = xh[i]; + __syncthreads(); + } + + const long long n_quads = n_in / 4; + const uint8_t* c = codes + o * n_quads; + const float* s = scales + o * (n_in / QK_S2); + + float a0 = 0.0f, a1 = 0.0f, a2 = 0.0f, a3 = 0.0f; + for (long long q = tid; q < n_quads; q += threads_per_row) { + const uint8_t byte = c[q]; // ONE load for four codes + const float d = s[q >> 4]; // (q*4) >> 6 + const float4 cv = *reinterpret_cast(&c_codes[byte][0]); // one broadcast load + if (STAGE_X) { + const __half2 h01 = *reinterpret_cast(&sx[q * 4]); + const __half2 h23 = *reinterpret_cast(&sx[q * 4 + 2]); + a0 += cv.x * d * __low2float(h01); + a1 += cv.y * d * __high2float(h01); + a2 += cv.z * d * __low2float(h23); + a3 += cv.w * d * __high2float(h23); + } else { + const uint2 xw2 = *reinterpret_cast(x + q * 4); + const __half2 h01 = *reinterpret_cast(&xw2.x); + const __half2 h23 = *reinterpret_cast(&xw2.y); + a0 += cv.x * d * __low2float(h01); + a1 += cv.y * d * __high2float(h01); + a2 += cv.z * d * __low2float(h23); + a3 += cv.w * d * __high2float(h23); + } + } + partial[tid] = (a0 + a1) + (a2 + a3); + __syncthreads(); + for (int step = threads_per_row / 2; step > 0; step >>= 1) { + if (tid < step) partial[tid] += partial[tid + step]; + __syncthreads(); + } + if (tid == 0) y[o] = partial[0]; +} + +} // namespace + +void s2_gemv_fast(const uint16_t* x, const uint8_t* codes, const float* scales, float* y, int64_t n_in, + int64_t n_out, int threads_per_row, bool stage_x) { + if (n_in <= 0 || n_out <= 0) return; + if (n_in % 4 != 0 || n_in % QK_S2 != 0) { + std::fprintf(stderr, "s2_gemv_fast: n_in %lld must be a multiple of %d\n", (long long) n_in, QK_S2); + std::exit(1); + } + ensure_lut(); + if (stage_x && n_in > MAX_SHARED_HALVES) { + std::fprintf(stderr, "s2_gemv_fast: n_in %lld exceeds the %d-half shared staging limit\n", + (long long) n_in, MAX_SHARED_HALVES); + std::exit(1); + } + const size_t smem = (stage_x ? MAX_SHARED_HALVES * sizeof(__half) : 0) + + (size_t) threads_per_row * sizeof(float); + if (stage_x) { + s2_gemv_fast_kernel<<<(unsigned) n_out, threads_per_row, smem>>>(x, codes, scales, y, n_in, n_out, + threads_per_row); + } else { + s2_gemv_fast_kernel<<<(unsigned) n_out, threads_per_row, smem>>>(x, codes, scales, y, n_in, n_out, + threads_per_row); + } + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "s2_gemv_fast: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/s2_gemv_q8.cu b/src/kernels/cuda/s2_gemv_q8.cu new file mode 100644 index 00000000..386f8827 --- /dev/null +++ b/src/kernels/cuda/s2_gemv_q8.cu @@ -0,0 +1,96 @@ +// src/kernels/cuda/s2_gemv_q8.cu - the S2 GEMV that consumes Q8_0 ACTIVATIONS, as ggml does. +// +// THE GAP THIS CLOSES (round 186). `ggml_mul_mat` converts src1 - the ACTIVATION - to the weight's +// `vec_dot_type` before the dot product, and for Q2_0 that is Q8_0. The CUDA kernels built in rounds 166-177 +// take FP16 activations instead, while the CPU expert path (`bench/micro/cpu_s2.cpp`) already quantizes to int8 +// and uses `vpdpbusd`. Left that way the two paths compute different numbers for different experts of the SAME +// token, and the measured size of the difference is the activation contract itself: 0.9498% relative, against +// P2.S2's 1e-3 tolerance for FP16 paths. +// +// This is the CORRECTNESS version, not the fast one. It dequantizes each activation (`xq * dx`) inside the +// loop and does the same float arithmetic as `s2_gemv_quads`; the speed win available here is `__dp4a`, which +// turns four int8 MACs into one instruction, and that belongs to Phase 3 once the numerics are settled. The +// phase's rule is the simplest correct kernel first, and a dp4a version whose numerics are wrong would be +// indistinguishable from a fast one. +// +// Layout: activations are (n_in/32) ggml Q8_0 blocks, 34 bytes each - `fp16 d` then `int8 qs[32]` - produced by +// `strata::kernels::quantize_q8_0`. A quad of four elements lies inside one 32-element block, so the block +// scale is constant across it and is loaded once. +#include "strata/kernels/s2_gemv_q8.hpp" + +#include +#include + +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int QK_S2 = 64; +constexpr int QK8_0 = 32; + +__global__ void s2_gemv_q8_kernel(const uint8_t* __restrict__ act, const uint8_t* __restrict__ codes, + const float* __restrict__ scales, float* __restrict__ y, long long n_in, + long long n_out, int threads_per_row) { + extern __shared__ float partial[]; + const long long o = blockIdx.x; + if (o >= n_out) return; + const int tid = threadIdx.x; + + const long long n_quads = n_in / 4; + const uint8_t* c = codes + o * n_quads; // one code byte per quad + const float* s = scales + o * (n_in / QK_S2); + + float a0 = 0.0f, a1 = 0.0f, a2 = 0.0f, a3 = 0.0f; + for (long long q = tid; q < n_quads; q += threads_per_row) { + const uint8_t byte = c[q]; + const float d = s[q >> 4]; // (q*4) >> 6, the S2 group index as a shift + // the activation quad: four int8 in one 32-element block, so one block scale + const long long ablk = (q * 4) / QK8_0; + const uint8_t* blk = act + ablk * 34; + const uint16_t dbits = (uint16_t) (blk[0] | (blk[1] << 8)); + const float dx = __half2float(__ushort_as_half(dbits)); + const int8_t* xq = reinterpret_cast(blk + 2); + const int off = (int) ((q * 4) % QK8_0); + + const float w0 = (float) ((int) (byte & 3) - 1) * d; + const float w1 = (float) ((int) ((byte >> 2) & 3) - 1) * d; + const float w2 = (float) ((int) ((byte >> 4) & 3) - 1) * d; + const float w3 = (float) ((int) ((byte >> 6) & 3) - 1) * d; + a0 += w0 * ((float) xq[off + 0] * dx); + a1 += w1 * ((float) xq[off + 1] * dx); + a2 += w2 * ((float) xq[off + 2] * dx); + a3 += w3 * ((float) xq[off + 3] * dx); + } + partial[tid] = (a0 + a1) + (a2 + a3); + __syncthreads(); + for (int step = threads_per_row / 2; step > 0; step >>= 1) { + if (tid < step) partial[tid] += partial[tid + step]; + __syncthreads(); + } + if (tid == 0) y[o] = partial[0]; +} + +} // namespace + +void s2_gemv_q8(const uint8_t* act, const uint8_t* codes, const float* scales, float* y, int64_t n_in, + int64_t n_out, int threads_per_row, void* stream) { + if (n_in <= 0 || n_out <= 0) return; + if (n_in % QK8_0 != 0 || n_in % QK_S2 != 0) { + std::fprintf(stderr, "s2_gemv_q8: n_in %lld must be a multiple of %d\n", (long long) n_in, QK_S2); + std::exit(1); + } + const size_t smem = (size_t) threads_per_row * sizeof(float); + s2_gemv_q8_kernel<<<(unsigned) n_out, threads_per_row, smem, (cudaStream_t) stream>>>(act, codes, scales, y, + n_in, n_out, + threads_per_row); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "s2_gemv_q8 launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) cudaDeviceSynchronize(); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/s2_gemv_quads.cu b/src/kernels/cuda/s2_gemv_quads.cu new file mode 100644 index 00000000..7cb7cb44 --- /dev/null +++ b/src/kernels/cuda/s2_gemv_quads.cu @@ -0,0 +1,93 @@ +// src/kernels/cuda/s2_gemv_quads.cu - S2 with the code load amortised over four elements. +// +// THE HYPOTHESIS THIS TESTS. L4 measured 66 G weights/s = 0.55 weights/cycle/SM, and at an estimated ~13 +// instructions per weight that is ~7.2 instructions/cycle/SM against an SM's 4/cycle issue ceiling. Compute +// (0.4% of FP32 peak) and bandwidth (2%) are excluded by measurement and the dependency chain was excluded by +// experiment in L4, so instruction ISSUE is what remains. If that is right, cutting instructions per weight +// must move the rate; if the rate does not move, the hypothesis is wrong and that is worth knowing too. +// +// WHERE THE INSTRUCTIONS GO. For S2 four consecutive elements share ONE byte (element i at byte i/4, bits +// (i%4)*2), and four halves of x are eight contiguous bytes. The generic kernel walks with a thread stride +// and loads one byte and one half PER ELEMENT; a thread that takes a contiguous quad instead loads one byte +// and one 64-bit word for all four. Memory instructions per element drop from 2 to 0.5, and the address +// arithmetic is computed once rather than four times. +// +// S2 IS SPECIALISED DELIBERATELY: it is 31.64 GiB of the 38 GiB pack, so it is the kernel that matters. The +// generic `s_gemv_split` still serves S4 and S8. +#include "strata/kernels/s_gemv.hpp" + +#include +#include + +#include +#include + +namespace strata::kernels { +namespace { + +// QK = 64 elements per group; a quad of 4 elements is 1/16 of a group, so +// group = (quad * 4) >> 6 = quad >> 4 +constexpr int QK_S2 = 64; +constexpr int CODES_PER_BYTE_S2 = 4; + +__global__ void s2_gemv_quads_kernel(const uint16_t* __restrict__ x, const uint8_t* __restrict__ codes, + const float* __restrict__ scales, float* __restrict__ y, long long n_in, + long long n_out, int threads_per_row) { + extern __shared__ float partial[]; + const long long o = blockIdx.x; + if (o >= n_out) return; + const int tid = threadIdx.x; + + const long long n_quads = n_in / 4; + const uint8_t* c = codes + o * n_quads; // exactly one code byte per quad + const float* s = scales + o * (n_in / QK_S2); + + float a0 = 0.0f, a1 = 0.0f, a2 = 0.0f, a3 = 0.0f; + for (long long q = tid; q < n_quads; q += threads_per_row) { + const uint8_t byte = c[q]; // ONE load for four codes + const float d = s[q >> 4]; // (q*4) >> 6, the group index as a shift + // ONE 64-bit load for four halves. `x` is 256-byte aligned and four halves are eight bytes, so + // this is an aligned uint2 - if it were not, the misaligned access would fault rather than be slow. + const uint2 xw = *reinterpret_cast(x + q * 4); + const __half2 h01 = *reinterpret_cast(&xw.x); + const __half2 h23 = *reinterpret_cast(&xw.y); + // the four codes, each carrying the -1 bias in the INTEGER domain; the scale is applied once per + // element here rather than once per group, which is the same expression the generic kernel uses + const float w0 = (float) ((int) (byte & 3) - 1) * d; + const float w1 = (float) ((int) ((byte >> 2) & 3) - 1) * d; + const float w2 = (float) ((int) ((byte >> 4) & 3) - 1) * d; + const float w3 = (float) ((int) ((byte >> 6) & 3) - 1) * d; + a0 += w0 * __low2float(h01); + a1 += w1 * __high2float(h01); + a2 += w2 * __low2float(h23); + a3 += w3 * __high2float(h23); + } + partial[tid] = (a0 + a1) + (a2 + a3); + __syncthreads(); + for (int step = threads_per_row / 2; step > 0; step >>= 1) { + if (tid < step) partial[tid] += partial[tid + step]; + __syncthreads(); + } + if (tid == 0) y[o] = partial[0]; +} + +} // namespace + +void s2_gemv_quads(const uint16_t* x, const uint8_t* codes, const float* scales, float* y, int64_t n_in, + int64_t n_out, int threads_per_row) { + if (n_in <= 0 || n_out <= 0) return; + if (n_in % 4 != 0) { + std::fprintf(stderr, "s2_gemv_quads: n_in %lld is not a multiple of 4\n", (long long) n_in); + std::exit(1); + } + const size_t smem = (size_t) threads_per_row * sizeof(float); + s2_gemv_quads_kernel<<<(unsigned) n_out, threads_per_row, smem>>>(x, codes, scales, y, n_in, n_out, + threads_per_row); + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "s2_gemv_quads: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/s_gemv.cu b/src/kernels/cuda/s_gemv.cu new file mode 100644 index 00000000..cdfa4d0b --- /dev/null +++ b/src/kernels/cuda/s_gemv.cu @@ -0,0 +1,686 @@ +// src/kernels/cuda/s_gemv.cu - P2.S2: the S-family GEMV, one thread per output row. +// +// Naive per the phase rule: dequantize on the fly, FP32 accumulation inside the row, no shared memory, no +// vector loads. The kernel is templated on the code WIDTH (a compile-time property of the unpacking loop) and +// takes the rest of the per-type attributes as run-time arguments, because the manifest supplies them per +// tensor and a 13-way switch inside the inner loop would be the naive-but-wrong kind of naive. +#include "strata/kernels/s_gemv.hpp" + +#include +#include + +#include +#include + +namespace strata::kernels { +namespace { + +// `kvalues_iq4nl`: the non-linear codebook, verbatim from ggml-common.h / ggml-quants.c. +// +// **IT WAS IN `__constant__` FOR THE WRONG REASON, AND IT COST 2.12x.** The old comment justified constant +// memory with "every thread reads it and it is the same 16 values for the whole launch". That reasoning is +// exactly backwards: constant memory is fast when the access is UNIFORM - every lane reading the SAME address - +// and a NON-LINEAR codebook means every lane reads a DIFFERENT index, which is the pattern it handles worst. +// +// Measured on `attn_qkv` (IQ4_XS, 10240 x 2560), forcing the Affine codebook so that the memory traffic and the +// instruction count stay identical and only the table read is removed: +// +// codebook probe: IQ4NL 0.2032 ms Affine 0.0960 ms ratio 2.12x +// +// `decode_tbl` below reads a SHARED copy instead: 16 bytes loaded once per block, then an index into shared +// memory that costs at most a four-way bank conflict. The `__constant__` array stays as the source of it. +__constant__ signed char kIq4Nl[16] = {-127, -104, -83, -65, -49, -35, -22, -10, + 1, 13, 25, 38, 53, 69, 89, 113}; + +template +__device__ __forceinline__ float decode(int code, int bias, int codebook) { + if (codebook == (int) Codebook::Iq4Nl) return (float) kIq4Nl[code & 0x0F]; + return (float) (code + bias); // the bias is applied to the CODE, in the integer domain +} + +/// The same decode with the codebook in SHARED memory, which is what the hot kernels use - see the measurement +/// above. A divergent read of a 16-entry table is the one access pattern constant memory is worst at. +template +__device__ __forceinline__ float decode_tbl(int code, int bias, int codebook, const signed char* tbl) { + if (codebook == (int) Codebook::Iq4Nl) return (float) tbl[code & 0x0F]; + return (float) (code + bias); +} + +// ---- Q8_K ACTIVATIONS ---------------------------------------------------------------------------------- +// +// 292 bytes per 256 elements: `{ f32 d ; int8_t qs[256] ; int16_t bsums[16] }`. `bsums` is not read - it +// exists for ggml's AVX2 dot product - but the STRIDE includes it, so a buffer from `quantize_q8_K` passes +// straight in. +// +// The block's `d` sits at a 4-byte-aligned offset from the buffer base as long as the base is aligned, which +// `cudaMalloc` guarantees. +constexpr int Q8K_BLOCK_BYTES = 292; +constexpr int Q8K_BLOCK_ELEMS = 256; + +__device__ __forceinline__ float q8k_at(const uint8_t* __restrict__ x, long long i) { + const uint8_t* blk = x + (i / Q8K_BLOCK_ELEMS) * Q8K_BLOCK_BYTES; + const float d = __ldg((const float*) blk); + const int8_t q = ((const int8_t*) (blk + 4))[i % Q8K_BLOCK_ELEMS]; + return d * (float) q; +} + +/// **THE Q8_0 ACTIVATION**, which is what the LEGACY block formats want: `block_q8_0` is `{fp16 d; int8 qs[32]}`, +/// 34 bytes per 32 elements, produced by `quantize_q8_0`. +/// +/// THE ENGINE HAD NO GEMV FOR THIS, and it was the last activation-contract gap (LEDGER L54). It was described +/// as "Q8_K is not implemented", which is not the situation: `ffn_down_shexp` is IQ4_NL/Q4_0/Q5_0/Q8_0 in EVERY +/// layer, whose `vec_dot_type` is Q8_0 - and its `n_in` is 640, which is not a multiple of 256, so **Q8_K is +/// STRUCTURALLY IMPOSSIBLE for it rather than merely absent**. Every other legacy tensor in the pack is Q2_0, +/// whose attributes happen to be S2's, which is how the S2-only kernel covered them and the hole stayed hidden. +constexpr int Q8_0_BLOCK_BYTES = 34; +constexpr int Q8_0_BLOCK_ELEMS = 32; + +__device__ __forceinline__ float q8_0_at(const uint8_t* __restrict__ x, long long i) { + const uint8_t* blk = x + (i / Q8_0_BLOCK_ELEMS) * Q8_0_BLOCK_BYTES; + // a `uint16_t` needs 2-byte alignment and the stride is 34, so every block's `d` is aligned + const float d = __half2float(__ushort_as_half(*(const uint16_t*) blk)); + const int8_t q = ((const int8_t*) (blk + 2))[i % Q8_0_BLOCK_ELEMS]; + return d * (float) q; +} + +/// The Q8_K path, with the SAME weight decode as `s_gemv_kernel` and a different activation loader. The two +/// are separate kernels rather than one templated on the activation kind because the activation POINTER TYPE +/// differs (`uint16_t*` vs `uint8_t*`) and a templated pointer would need a cast at every call site - which is +/// where a wrong stride would hide. +template +__global__ void s_gemv_q8k_kernel(const uint8_t* __restrict__ x, const uint8_t* __restrict__ codes, + const float* __restrict__ scales, const float* __restrict__ offset, + float* __restrict__ y, long long n_in, long long n_out, int bias, + int codebook, int group_elems, int has_offset) { + // THE CODEBOOK, IN SHARED MEMORY - 16 bytes, loaded once per block. See the note on `kIq4Nl`. + // THE LOAD AND THE BARRIER COME BEFORE THE EARLY RETURN: `o` is per-THREAD in this kernel, so a thread + // that returned first would leave the others waiting at a barrier it never reaches. + __shared__ signed char s_iq4nl[16]; + if (threadIdx.x < 16) s_iq4nl[threadIdx.x] = kIq4Nl[threadIdx.x]; + __syncthreads(); + const long long o = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (o >= n_out) return; + + constexpr int PER_BYTE = 8 / CODE_BITS; + const long long n_groups = n_in / group_elems; + const long long codes_per_row = n_in / PER_BYTE; + const uint8_t* c = codes + o * codes_per_row; + const float* s = scales + o * n_groups; + const float* off = has_offset ? offset + o * n_groups : nullptr; + + float acc = 0.0f; + for (long long g = 0; g < n_groups; ++g) { + const float d = s[g]; + const float b = off ? off[g] : 0.0f; + const long long base = g * (long long) group_elems; + for (int j = 0; j < group_elems; ++j) { + const long long i = base + j; + const int code = (c[i / PER_BYTE] >> ((int) (i % PER_BYTE) * CODE_BITS)) & ((1 << CODE_BITS) - 1); + // The offset belongs to the WEIGHT and is applied before the activation multiply - see the note in + // `s_gemv_kernel`, where writing it the other way rounded differently and only the Q4_K case could + // tell. + const float w = decode_tbl(code, bias, codebook, s_iq4nl) * d + b; + acc += w * q8k_at(x, i); + } + } + y[o] = acc; +} + +/// One warp per output row, lanes striding the reduction axis: for a fixed `i` consecutive lanes read +/// consecutive activations, which is the coalescing that matters once the output width is small. +/// +/// **`group_shift`, NOT `group_elems`, AND IT COSTS A DIVISION PER ELEMENT IF YOU GET IT WRONG.** The +/// sibling fp16 kernel was given this treatment ("a 64-bit integer division on this hardware is a long +/// instruction sequence - one per element, in the hottest loop in the engine") and THIS one was not: it kept +/// `i / group_elems` with `group_elems` a RUNTIME value, once per element, in the kernel that carries most of +/// the model's weight bytes. Every group size the format defines is a power of two, so the divisor is passed +/// as its logarithm and the division becomes a shift; the host refuses a non-power-of-two group rather than +/// computing a wrong index. +/// ONE KERNEL FOR BOTH QUANTIZED ACTIVATIONS, templated on which block format `x` holds. A second copy with a +/// different loader would be a second thing to get wrong, and the only difference IS the loader - the weight +/// decode, the quad loop, the shared codebook and the reduction are identical. +template +__global__ void s_gemv_q8_split_kernel(const uint8_t* __restrict__ x, const uint8_t* __restrict__ codes, + const float* __restrict__ scales, const float* __restrict__ offset, + float* __restrict__ y, long long n_in, long long n_out, int bias, + int codebook, int group_shift, int has_offset) { + constexpr int PER_BYTE = 8 / CODE_BITS; + const int warps_per_block = (int) (blockDim.x >> 5); + const long long o = (long long) blockIdx.x * warps_per_block + (threadIdx.x >> 5); + if (o >= n_out) return; + const int lane = threadIdx.x & 31; + + // THE CODEBOOK, IN SHARED MEMORY. 16 bytes, loaded once per block by the first 16 threads, instead of a + // divergent constant read per element - measured at 2.12x of this whole kernel (see the note on `kIq4Nl`). + __shared__ signed char s_iq4nl[16]; + if (threadIdx.x < 16) s_iq4nl[threadIdx.x] = kIq4Nl[threadIdx.x]; + __syncthreads(); + + const long long n_groups = n_in >> group_shift; + const long long codes_per_row = n_in / PER_BYTE; + const uint8_t* c = codes + o * codes_per_row; + const float* s = scales + o * n_groups; + const float* off = has_offset ? offset + o * n_groups : nullptr; + + float acc0 = 0.0f, acc1 = 0.0f, acc2 = 0.0f, acc3 = 0.0f; + float acc4 = 0.0f, acc5 = 0.0f, acc6 = 0.0f, acc7 = 0.0f; + float acc8 = 0.0f, acc9 = 0.0f, acc10 = 0.0f, acc11 = 0.0f; + float acc12 = 0.0f, acc13 = 0.0f, acc14 = 0.0f, acc15 = 0.0f; + // ---- EIGHT CONSECUTIVE ELEMENTS PER LANE, from TWO code loads and ONE scale. + // + // **THIS IS THE EXPERIMENT THE `#pragma unroll` NOTE BELOW LEFT OPEN**, and the note said what it would + // be: "Widening QE to 8 or 16 changes the LOADS THEMSELVES - two or four independent 16-bit words per + // lane - which an unroll cannot do because the extraction code is written for exactly four codes." So a + // lane now takes a double quad: two code words issued before either is consumed, which is what raises the + // bytes in flight. The scale and offset are still ONE load, because eight consecutive elements lie in one + // group whenever `group_elems % 8 == 0` - the launcher asserts that, and every group size this format + // defines (16, 32, 64) satisfies it. + // + // The loop used to take `i = lane, lane+32, ...` - a STRIDE, chosen so consecutive lanes read consecutive + // activations - and paid a code load, a shift-and-mask, and a scale load FOR EVERY ELEMENT. But four + // consecutive elements share one code word (1, 2 or 4 bytes depending on width) and lie in one group, + // because every group size this format defines is a multiple of four. So a lane takes a QUAD: + // + // one code load instead of four + // one scale load instead of four + // one shift per element instead of a divide, a modulo and a shift + // + // THE BIT ORDER IS THE SAME ONE, verified for all three widths: element `i+k` is bits + // `[k*CODE_BITS, (k+1)*CODE_BITS)` of the little-endian word at byte `i/PER_BYTE`, which is what the old + // `(c[e/PER_BYTE] >> ((e%PER_BYTE)*CODE_BITS)) & MASK` computed one element at a time. + // + // THE SUMMATION ORDER CHANGES, which is why `s_gemv_parity` and `s_gemv_q8k_parity` carry tolerances and + // compare against the naive kernel - they are the guard for this, and they are the reason it is safe to do. + constexpr int QE = 16; + // BYTES PER FOUR-CODE WORD, not per octet. The two are easy to confuse and the first version of this + // did: it branched on the octet's total width and so read a uint32 for S4 (whose four codes are a + // uint16) and single bytes for S8 (whose four codes are a uint32). `s_gemv_q8k_parity` failed on + // exactly that, which is what it is for. + constexpr int QW = 4 * CODE_BITS / 8; // 1 for S2, 2 for S4, 4 for S8 + constexpr unsigned MASK = (1u << CODE_BITS) - 1u; + long long i = (long long) lane * QE; + // **`#pragma unroll 4` WAS TRIED HERE AND DID NOTHING.** Measured: 184.6 -> 183.9 GB/s at n_out 10240, + // with `s_gemv_parity` and `s_gemv_q8k_parity` both still clean. The hypothesis it was testing is L93 - + // that this kernel's flat ~6 ns per output row is a bytes-in-flight limit, one code load and one scale + // load per lane-iteration with only 20 iterations. A 4x unroll issues four of each before the first is + // consumed, so either the compiler was already doing it or **the limit is not instruction scheduling.** + // Removed rather than kept, because a hint that does not change the timing is a claim that is not true. + // + // What it does NOT rule out is the structural version of the same idea: QE is 4, so a lane consumes ONE + // 2-byte code word per iteration. Widening QE to 8 or 16 changes the LOADS THEMSELVES - two or four + // independent 16-bit words per lane - which an unroll cannot do because the extraction code is written for + // exactly four codes. That is the experiment that would settle it, and it is not this one. + for (; i + QE <= n_in; i += 32 * QE) { + const long long g = i >> group_shift; // the whole octet is in one group: group_elems % 8 == 0 + const float d = s[g]; + const float b = off ? off[g] : 0.0f; + const uint8_t* cp = c + i / PER_BYTE; + unsigned v, v2, v3, v4; + if (QW == 1) { v = cp[0]; v2 = cp[1]; v3 = cp[2]; v4 = cp[3]; } + else if (QW == 2) { v = *(const uint16_t*) cp; v2 = *(const uint16_t*) (cp + 2); + v3 = *(const uint16_t*) (cp + 4); v4 = *(const uint16_t*) (cp + 6); } + else { v = *(const uint32_t*) cp; v2 = *(const uint32_t*) (cp + 4); + v3 = *(const uint32_t*) (cp + 8); v4 = *(const uint32_t*) (cp + 12); } + // ---- THE ACTIVATION BLOCK IS HOISTED OUT OF THE SIXTEEN ELEMENTS, AND THAT WAS THE KERNEL'S COST. + // + // `q8k_at(x, i + k)` computes `(i + k) / 256` and `(i + k) % 256` on EVERY call: a 64-bit integer + // division AND modulo per element, sixteen times per lane-iteration. A 64-bit division on a GPU is a + // software routine of roughly a hundred cycles, and the compiler cannot strength-reduce it because it + // cannot prove `(i + k) / 256 == i / 256` without knowing `i % 256`. + // + // But `QE` is 16, `Q8K_BLOCK_ELEMS` is 256 and `Q8_0_BLOCK_ELEMS` is 32, and the loop advances `i` by + // `32 * QE`. So `i % 256` and `i % 32` are ALWAYS multiples of 16 - which means all QE elements of one + // lane-iteration lie inside ONE block, for either activation kind. One division and one modulo per + // iteration replace sixteen of each, and the sixteen activations become sixteen consecutive int8 loads + // from a pointer computed once. + // + // This is exact, not an approximation: `xi + QE <= blk_elems` holds for every reachable `xi`, so no + // lane-iteration can straddle a block boundary. The tail loop below still uses the accessors because + // its elements do not share a block, and it runs at most once per lane. + const int blk_elems = Q8K ? Q8K_BLOCK_ELEMS : Q8_0_BLOCK_ELEMS; + const uint8_t* xb = x + (i / blk_elems) * (Q8K ? Q8K_BLOCK_BYTES : Q8_0_BLOCK_BYTES); + const int xi = (int) (i % blk_elems); + const float xd = Q8K ? __ldg((const float*) xb) + : __half2float(__ushort_as_half(*(const uint16_t*) xb)); + const int8_t* xq = (const int8_t*) (xb + (Q8K ? 4 : 2)) + xi; + + const float w0 = decode_tbl((int) (v & MASK), bias, codebook, s_iq4nl) * d + b; + const float w1 = decode_tbl((int) ((v >> CODE_BITS) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w2 = decode_tbl((int) ((v >> (2 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w3 = decode_tbl((int) ((v >> (3 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w4 = decode_tbl((int) (v2 & MASK), bias, codebook, s_iq4nl) * d + b; + const float w5 = decode_tbl((int) ((v2 >> CODE_BITS) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w6 = decode_tbl((int) ((v2 >> (2 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w7 = decode_tbl((int) ((v2 >> (3 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b; + acc0 += w0 * (xd * (float) xq[0]); + acc1 += w1 * (xd * (float) xq[1]); + acc2 += w2 * (xd * (float) xq[2]); + acc3 += w3 * (xd * (float) xq[3]); + acc4 += w4 * (xd * (float) xq[4]); + acc5 += w5 * (xd * (float) xq[5]); + acc6 += w6 * (xd * (float) xq[6]); + acc7 += w7 * (xd * (float) xq[7]); + const float w8 = decode_tbl((int) (v3 & MASK), bias, codebook, s_iq4nl) * d + b; + const float w9 = decode_tbl((int) ((v3 >> CODE_BITS) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w10 = decode_tbl((int) ((v3 >> (2 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w11 = decode_tbl((int) ((v3 >> (3 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w12 = decode_tbl((int) (v4 & MASK), bias, codebook, s_iq4nl) * d + b; + const float w13 = decode_tbl((int) ((v4 >> CODE_BITS) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w14 = decode_tbl((int) ((v4 >> (2 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b; + const float w15 = decode_tbl((int) ((v4 >> (3 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b; + acc8 += w8 * (xd * (float) xq[8]); + acc9 += w9 * (xd * (float) xq[9]); + acc10 += w10 * (xd * (float) xq[10]); + acc11 += w11 * (xd * (float) xq[11]); + acc12 += w12 * (xd * (float) xq[12]); + acc13 += w13 * (xd * (float) xq[13]); + acc14 += w14 * (xd * (float) xq[14]); + acc15 += w15 * (xd * (float) xq[15]); + } + // the last, partial quad - at most one per lane + for (; i < n_in; i += 32 * QE) { + for (int k = 0; k < QE && i + k < n_in; ++k) { + const long long e = i + k; + const long long g = e >> group_shift; + const int code = (c[e / PER_BYTE] >> ((int) (e % PER_BYTE) * CODE_BITS)) & MASK; + acc0 += (decode_tbl(code, bias, codebook, s_iq4nl) * s[g] + (off ? off[g] : 0.0f)) * (Q8K ? q8k_at(x, e) : q8_0_at(x, e)); + } + } + float acc = (((acc0 + acc1) + (acc2 + acc3)) + ((acc4 + acc5) + (acc6 + acc7))) + + (((acc8 + acc9) + (acc10 + acc11)) + ((acc12 + acc13) + (acc14 + acc15))); + for (int step = 16; step > 0; step >>= 1) acc += __shfl_down_sync(0xFFFFFFFFu, acc, step); + if (lane == 0) y[o] = acc; +} + +template +__global__ void s_gemv_kernel(const uint16_t* __restrict__ x, const uint8_t* __restrict__ codes, + const float* __restrict__ scales, const float* __restrict__ offset, + float* __restrict__ y, long long n_in, long long n_out, int bias, int codebook, + int group_elems, int has_offset) { + // THE CODEBOOK, IN SHARED MEMORY - 16 bytes, loaded once per block. See the note on `kIq4Nl`. + // THE LOAD AND THE BARRIER COME BEFORE THE EARLY RETURN: `o` is per-THREAD in this kernel, so a thread + // that returned first would leave the others waiting at a barrier it never reaches. + __shared__ signed char s_iq4nl[16]; + if (threadIdx.x < 16) s_iq4nl[threadIdx.x] = kIq4Nl[threadIdx.x]; + __syncthreads(); + const long long o = (long long) blockIdx.x * blockDim.x + threadIdx.x; + if (o >= n_out) return; + + constexpr int PER_BYTE = 8 / CODE_BITS; + const long long n_groups = n_in / group_elems; + const long long codes_per_row = n_in / PER_BYTE; + const uint8_t* c = codes + o * codes_per_row; + const float* s = scales + o * n_groups; + const float* off = has_offset ? offset + o * n_groups : nullptr; + + float acc = 0.0f; + for (long long g = 0; g < n_groups; ++g) { + const float d = s[g]; + const float b = off ? off[g] : 0.0f; + const long long base = g * (long long) group_elems; + for (int j = 0; j < group_elems; ++j) { + const long long i = base + j; + const int code = (c[i / PER_BYTE] >> ((int) (i % PER_BYTE) * CODE_BITS)) & ((1 << CODE_BITS) - 1); + // The offset belongs to the WEIGHT, so it is applied to the decoded value BEFORE the activation + // multiply: `w = code*scale + offset; acc += w * x`. Writing `acc += code*scale*x + offset*x` + // computes a mathematically equal expression that ROUNDS DIFFERENTLY, because it performs two + // multiplications and an addition where this performs one of each. The no-offset types cannot + // tell the difference - which is why this was wrong until the Q4_K case was added. + const float w = decode_tbl(code, bias, codebook, s_iq4nl) * d + b; + acc += w * __half2float(__ushort_as_half(x[i])); + } + } + y[o] = acc; +} + +} // namespace + +void s_gemv(const uint16_t* x, const uint8_t* codes, const float* scales, const float* offset, float* y, + int64_t n_in, int64_t n_out, const SForm& form) { + if (n_in <= 0 || n_out <= 0) return; + if (form.group_elems <= 0 || n_in % form.group_elems != 0) { + std::fprintf(stderr, "s_gemv: n_in %lld is not a multiple of group_elems %d\n", (long long) n_in, + form.group_elems); + std::exit(1); + } + if (form.has_offset && offset == nullptr) { + std::fprintf(stderr, "s_gemv: form says has_offset but offset is null\n"); + std::exit(1); + } + const int threads = 128; + const long long blocks = (n_out + threads - 1) / threads; + const int cb = (int) form.codebook; + switch (form.code_bits) { + case 2: + s_gemv_kernel<2><<<(unsigned) blocks, threads>>>(x, codes, scales, offset, y, n_in, n_out, + form.code_bias, cb, form.group_elems, + form.has_offset ? 1 : 0); + break; + case 4: + s_gemv_kernel<4><<<(unsigned) blocks, threads>>>(x, codes, scales, offset, y, n_in, n_out, + form.code_bias, cb, form.group_elems, + form.has_offset ? 1 : 0); + break; + case 8: + s_gemv_kernel<8><<<(unsigned) blocks, threads>>>(x, codes, scales, offset, y, n_in, n_out, + form.code_bias, cb, form.group_elems, + form.has_offset ? 1 : 0); + break; + default: + std::fprintf(stderr, "s_gemv: unsupported code_bits %d\n", form.code_bits); + std::exit(1); + } + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "s_gemv: %s\n", cudaGetErrorString(e)); + std::exit(1); + } +} + +namespace { + +// One BLOCK per output row, `threads_per_row` threads splitting it. Parallelism becomes +// n_out * threads_per_row instead of n_out, which is the whole point: gate/up is [2560 x 640] and 640 threads +// cannot fill 48 SMs. +// +// The reduction is a fixed tree over shared memory rather than atomics, so the result is DETERMINISTIC for a +// given threads_per_row - a race would make the parity test flaky rather than wrong, which is the worst kind +// of failing test. +// NOTE ON `g = e >> group_shift`. The group index was `e / group_elems` with `group_elems` a RUNTIME value, +// and a 64-bit integer division on this hardware is a long instruction sequence - one per element, in the +// hottest loop in the engine. Every group size this format defines is a POWER OF TWO (16, 32, 64), so the +// divisor is passed as its logarithm and the division becomes a shift. The host wrapper refuses a +// non-power-of-two group rather than silently computing a wrong index. +template +__global__ void s_gemv_split_kernel(const uint16_t* __restrict__ x, const uint8_t* __restrict__ codes, + const float* __restrict__ scales, const float* __restrict__ offset, + float* __restrict__ y, long long n_in, long long n_out, int bias, + int codebook, int group_elems, int group_shift, int has_offset, + int threads_per_row) { + extern __shared__ float partial[]; + // THE CODEBOOK, IN SHARED MEMORY - 16 bytes, loaded once per block. See the note on `kIq4Nl`. + // THE LOAD AND THE BARRIER COME BEFORE THE EARLY RETURN: `o` is per-THREAD in this kernel, so a thread + // that returned first would leave the others waiting at a barrier it never reaches. + __shared__ signed char s_iq4nl[16]; + if (threadIdx.x < 16) s_iq4nl[threadIdx.x] = kIq4Nl[threadIdx.x]; + __syncthreads(); + const long long o = blockIdx.x; + if (o >= n_out) return; + const int tid = threadIdx.x; + + constexpr int PER_BYTE = 8 / CODE_BITS; + const long long n_groups = n_in / group_elems; + const uint8_t* c = codes + o * (n_in / PER_BYTE); + const float* s = scales + o * n_groups; + const float* off = has_offset ? offset + o * n_groups : nullptr; + + // FOUR ACCUMULATORS, NOT ONE. L2 measured this kernel at 0.4% of FP32 peak and 2.1% of bandwidth, so + // neither limit is the constraint - the constraint is the DEPENDENCY CHAIN: a single `acc += ...` serialises + // every FMA behind the previous one's latency (4-6 cycles each, ~1 per 4 cycles achieved), so the SM sits + // idle. Four independent accumulators let the scheduler keep four FMAs in flight, and the loop is + // unrolled by four so the four chains are interleaved. The summation ORDER changes, which is why the + // parity test carries a tolerance and why this kernel's output is checked against the naive one. + // FOUR CONSECUTIVE ELEMENTS PER THREAD, from ONE code load and ONE scale - the same transformation the + // Q8_K kernel got, for the same reason and with the same guard (`s_gemv_parity`). See the long note there + // for the bit order, which is unchanged, and for why the group size is passed as a shift. + // + // THIS KERNEL IS THE ONE `attn_output` AND `shared_expert` USE, so the fix has to exist in both or half the + // model keeps the old instruction count. + constexpr int QE = 4; + constexpr int QB = QE * CODE_BITS / 8; // 1 for S2, 2 for S4, 4 for S8 + constexpr unsigned MASK = (1u << CODE_BITS) - 1u; + float acc0 = 0.0f, acc1 = 0.0f, acc2 = 0.0f, acc3 = 0.0f; + long long i = (long long) tid * QE; + for (; i + QE <= n_in; i += (long long) threads_per_row * QE) { + const long long g = i >> group_shift; + const float d = s[g]; + const float b = off ? off[g] : 0.0f; + const uint8_t* cp = c + i / PER_BYTE; + unsigned v; + if (QB == 1) v = cp[0]; + else if (QB == 2) v = *(const uint16_t*) cp; + else v = *(const uint32_t*) cp; + // four halves in ONE 8-byte load; `i` is a multiple of four, so `x + i` is 8-byte aligned + const float2 f01 = __half22float2(*reinterpret_cast(x + i)); + const float2 f23 = __half22float2(*reinterpret_cast(x + i + 2)); + acc0 += (decode_tbl((int) (v & MASK), bias, codebook, s_iq4nl) * d + b) * f01.x; + acc1 += (decode_tbl((int) ((v >> CODE_BITS) & MASK), bias, codebook, s_iq4nl) * d + b) * f01.y; + acc2 += (decode_tbl((int) ((v >> (2 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b) * f23.x; + acc3 += (decode_tbl((int) ((v >> (3 * CODE_BITS)) & MASK), bias, codebook, s_iq4nl) * d + b) * f23.y; + } + // the last, partial quad - at most one per thread + for (; i < n_in; i += (long long) threads_per_row * QE) { + for (int k = 0; k < QE && i + k < n_in; ++k) { + const long long e = i + k; + const long long g = e >> group_shift; + const int code = (c[e / PER_BYTE] >> ((int) (e % PER_BYTE) * CODE_BITS)) & MASK; + acc0 += (decode_tbl(code, bias, codebook, s_iq4nl) * s[g] + (off ? off[g] : 0.0f)) * + __half2float(__ushort_as_half(x[e])); + } + } + partial[tid] = (acc0 + acc1) + (acc2 + acc3); + __syncthreads(); + for (int step = threads_per_row / 2; step > 0; step >>= 1) { + if (tid < step) partial[tid] += partial[tid + step]; + __syncthreads(); + } + if (tid == 0) y[o] = partial[0]; +} + +} // namespace + +static void s_gemv_split_impl(const uint16_t* x, const uint8_t* codes, const float* scales, + const float* offset, float* y, int64_t n_in, int64_t n_out, const SForm& form, + int threads_per_row, cudaStream_t stream, bool sync) { + if (n_in <= 0 || n_out <= 0) return; + if (threads_per_row < 1 || (threads_per_row & (threads_per_row - 1)) != 0 || threads_per_row > 1024) { + std::fprintf(stderr, "s_gemv_split: threads_per_row must be a power of two in 1..1024, got %d\n", + threads_per_row); + std::exit(1); + } + const int cb = (int) form.codebook; + // Every group size this format defines is a power of two, which is what lets the per-element group index + // be a shift. Refuse anything else rather than compute a wrong index quietly. + if (form.group_elems <= 0 || (form.group_elems & (form.group_elems - 1)) != 0) { + std::fprintf(stderr, "s_gemv_split: group_elems must be a power of two, got %d\n", form.group_elems); + std::exit(1); + } + // AND THE QUAD MUST FIT INSIDE ONE GROUP: four consecutive elements share a scale and the kernel reads + // exactly one. Every group size this format defines is 16, 32 or 64, so this cannot fire on a real pack. + if (form.group_elems % 4 != 0) { + std::fprintf(stderr, "s_gemv_split: group_elems %d is not a multiple of 4\n", form.group_elems); + std::exit(1); + } + int group_shift = 0; + while ((1 << group_shift) < form.group_elems) ++group_shift; + const size_t smem = (size_t) threads_per_row * sizeof(float); + const unsigned grid = (unsigned) n_out; + switch (form.code_bits) { + case 2: + s_gemv_split_kernel<2><<>>( + x, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, form.group_elems, + group_shift, form.has_offset ? 1 : 0, threads_per_row); + break; + case 4: + s_gemv_split_kernel<4><<>>( + x, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, form.group_elems, + group_shift, form.has_offset ? 1 : 0, threads_per_row); + break; + case 8: + s_gemv_split_kernel<8><<>>( + x, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, form.group_elems, + group_shift, form.has_offset ? 1 : 0, threads_per_row); + break; + default: + std::fprintf(stderr, "s_gemv_split: unsupported code_bits %d\n", form.code_bits); + std::exit(1); + } + if (sync) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "s_gemv_split: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +void s_gemv_split(const uint16_t* x, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, int threads_per_row) { + s_gemv_split_impl(x, codes, scales, offset, y, n_in, n_out, form, threads_per_row, (cudaStream_t) 0, true); +} + +void s_gemv_split_async(const uint16_t* x, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, int threads_per_row, + void* stream) { + s_gemv_split_impl(x, codes, scales, offset, y, n_in, n_out, form, threads_per_row, (cudaStream_t) stream, + false); +} + +// ---- the Q8_K entry points ----------------------------------------------------------------------------- + +namespace { + +bool q8k_form_ok(const SForm& form, int64_t n_in, const char* who) { + if (form.group_elems <= 0 || n_in % form.group_elems != 0) { + std::fprintf(stderr, "%s: n_in %lld is not a multiple of group_elems %d\n", who, (long long) n_in, + form.group_elems); + return false; + } + if (n_in % Q8K_BLOCK_ELEMS != 0) { + // `quantize_q8_K` requires this too, and a partial block would read past the end of the activation. + std::fprintf(stderr, "%s: n_in %lld is not a multiple of the Q8_K block %d\n", who, (long long) n_in, + Q8K_BLOCK_ELEMS); + return false; + } + return true; +} + +void finish(const char* who, void* stream) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "%s launch: %s\n", who, cudaGetErrorString(e)); + std::exit(1); + } + if (stream != nullptr) return; + const cudaError_t s = cudaDeviceSynchronize(); + if (s != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", who, cudaGetErrorString(s)); + std::exit(1); + } +} + +} // namespace + +void s_gemv_q8k(const uint8_t* x_q8k, const uint8_t* codes, const float* scales, const float* offset, float* y, + int64_t n_in, int64_t n_out, const SForm& form, void* stream) { + if (n_in <= 0 || n_out <= 0) return; + if (!q8k_form_ok(form, n_in, "s_gemv_q8k")) std::exit(1); + const int threads = 128; + const unsigned blocks = (unsigned) ((n_out + threads - 1) / threads); + const int cb = (int) form.codebook; + const int ho = form.has_offset ? 1 : 0; + switch (form.code_bits) { + case 4: + s_gemv_q8k_kernel<4><<>>( + x_q8k, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, form.group_elems, ho); + break; + case 8: + s_gemv_q8k_kernel<8><<>>( + x_q8k, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, form.group_elems, ho); + break; + default: + // 2-bit S2 never has a Q8_K activation: its `vec_dot_type` is Q8_0. Refusing is better than + // running a kernel that would be numerically wrong in a way the caller cannot see. + std::fprintf(stderr, "s_gemv_q8k: code_bits %d has no Q8_K contract " + "(S2 uses Q8_0; see docs/activation-contract.md)\n", form.code_bits); + std::exit(1); + } + finish("s_gemv_q8k", stream); +} + +void s_gemv_q8k_split(const uint8_t* x_q8k, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, void* stream) { + if (n_in <= 0 || n_out <= 0) return; + if (!q8k_form_ok(form, n_in, "s_gemv_q8k_split")) std::exit(1); + // THE GROUP SIZE IS PASSED AS ITS LOGARITHM: see the note on the kernel. A non-power-of-two group would + // make the shift a WRONG INDEX rather than a slow one, so it is refused here. + int group_shift = 0; + while ((1 << group_shift) < form.group_elems) ++group_shift; + if ((1 << group_shift) != form.group_elems) { + std::fprintf(stderr, "s_gemv_q8k_split: group_elems %d is not a power of two\n", form.group_elems); + std::exit(1); + } + // AND THE QUAD MUST FIT INSIDE ONE GROUP. Four consecutive elements share a scale, so a group smaller + // than four would need two of them and the kernel reads exactly one. Every group size this format + // defines is 16, 32 or 64, so this cannot fire on a real pack - it is here because a future one could. + if (form.group_elems % 16 != 0) { + std::fprintf(stderr, "s_gemv_q8k_split: group_elems %d is not a multiple of 16\n", form.group_elems); + std::exit(1); + } + const int threads = 256; + const int warps = threads / 32; + const unsigned blocks = (unsigned) ((n_out + warps - 1) / warps); + const int cb = (int) form.codebook; + const int ho = form.has_offset ? 1 : 0; + switch (form.code_bits) { + case 4: + s_gemv_q8_split_kernel<4, true><<>>( + x_q8k, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, group_shift, ho); + break; + case 8: + s_gemv_q8_split_kernel<8, true><<>>( + x_q8k, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, group_shift, ho); + break; + default: + std::fprintf(stderr, "s_gemv_q8k_split: code_bits %d has no Q8_K contract\n", form.code_bits); + std::exit(1); + } + finish("s_gemv_q8k_split", stream); +} + +void s_gemv_q8_0_split(const uint8_t* x_q8_0, const uint8_t* codes, const float* scales, const float* offset, + float* y, int64_t n_in, int64_t n_out, const SForm& form, void* stream) { + if (n_in <= 0 || n_out <= 0) return; + // THE ACTIVATION IS `block_q8_0`, 32 elements per block, so `n_in` must be a multiple of 32 - which is a + // WEAKER requirement than Q8_K's 256 and is exactly why this kernel has to exist: `ffn_down_shexp` has + // n_in = 640, a multiple of 32 that is NOT a multiple of 256. + if (n_in % Q8_0_BLOCK_ELEMS != 0) { + std::fprintf(stderr, "s_gemv_q8_0_split: n_in %lld is not a multiple of %d\n", (long long) n_in, + Q8_0_BLOCK_ELEMS); + std::exit(1); + } + // the same two checks `s_gemv_q8k_split` makes, for the same reasons - see the notes there + if (form.group_elems <= 0 || (form.group_elems & (form.group_elems - 1)) != 0) { + std::fprintf(stderr, "s_gemv_q8_0_split: group_elems must be a power of two, got %d\n", + form.group_elems); + std::exit(1); + } + if (form.group_elems % 4 != 0) { + std::fprintf(stderr, "s_gemv_q8_0_split: group_elems %d is not a multiple of 4\n", form.group_elems); + std::exit(1); + } + int group_shift = 0; + while ((1 << group_shift) < form.group_elems) ++group_shift; + + const int threads = 256; + const int warps = threads / 32; + const unsigned blocks = (unsigned) ((n_out + warps - 1) / warps); + const int cb = (int) form.codebook; + const int ho = form.has_offset ? 1 : 0; + switch (form.code_bits) { + case 4: + s_gemv_q8_split_kernel<4, false><<>>( + x_q8_0, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, group_shift, ho); + break; + case 8: + s_gemv_q8_split_kernel<8, false><<>>( + x_q8_0, codes, scales, offset, y, n_in, n_out, form.code_bias, cb, group_shift, ho); + break; + default: + std::fprintf(stderr, "s_gemv_q8_0_split: code_bits %d has no Q8_0 contract\n", form.code_bits); + std::exit(1); + } + finish("s_gemv_q8_0_split", stream); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/sampler.cu b/src/kernels/cuda/sampler.cu new file mode 100644 index 00000000..3e1daaf2 --- /dev/null +++ b/src/kernels/cuda/sampler.cu @@ -0,0 +1,320 @@ +// src/kernels/cuda/sampler.cu - P2.S2: the sampler chain, in the order docs/sampling.md settles. +// +// penalties -> top_k -> top_p -> temperature -> pick +// +// THE ORDER IS THE WHOLE CONTENT OF THIS FILE. `docs/sampling.md` transcribes it from llama.cpp's own chain +// (`common/sampling.cpp` L357/360/375/381/399) and the two facts that are easy to get backwards are that +// TEMPERATURE COMES AFTER THE TRUNCATION FILTERS and PENALTIES COME AFTER TEMPERATURE. The intuitive order - +// scale first, then truncate, with penalties as pre-processing - is a different distribution. Both produce a +// valid token, so only a comparison at the distribution level can tell them apart; the parity test does that +// explicitly by running the wrong order and requiring it to differ. +// +// Everything happens on the logits of ONE token in one thread. The vocab is 248,320, which is far too large +// to sort per token on a naive path, so `top_k` uses `k` passes of a maximum scan - 20 x 248,320 = 5.0M +// comparisons per token. Phase 3's note ("sort-free sampler: top-k = 20 makes this easy") is the optimisation; +// this is the correctness version. +#include "strata/kernels/sampler.hpp" + +#include + +#include +#include +#include + +namespace strata::kernels { +namespace { + +// Philox 4x32-10, the counter-based generator the phase asks for. Counter-based matters because it makes the +// stream a function of (seed, position) rather than of how many draws came before - so a batch can be sampled +// in any order and a run is reproducible. +__device__ __forceinline__ uint32_t philox4x32_round(uint32_t& c0, uint32_t& c1, uint32_t& c2, uint32_t& c3, + uint32_t k0, uint32_t k1) { + const uint32_t hi0 = __umulhi(0x9E3779B9u, c0); + const uint32_t hi1 = __umulhi(0xBB67AE85u, c2); + const uint32_t lo0 = 0x9E3779B9u * c0; + const uint32_t lo1 = 0xBB67AE85u * c2; + const uint32_t n0 = hi1 ^ c1 ^ k0; + const uint32_t n1 = lo1; + const uint32_t n2 = hi0 ^ c3 ^ k1; + const uint32_t n3 = lo0; + c0 = n0; c1 = n1; c2 = n2; c3 = n3; + return 0; +} + +__device__ __forceinline__ float philox_uniform(uint64_t seed, uint64_t counter) { + uint32_t c0 = (uint32_t) counter, c1 = (uint32_t) (counter >> 32); + uint32_t c2 = (uint32_t) seed, c3 = (uint32_t) (seed >> 32); + for (int i = 0; i < 10; ++i) { + philox4x32_round(c0, c1, c2, c3, (uint32_t) i, 0u); + } + // 24 bits of mantissa, so the value is uniform in [0,1) with no rounding to 1.0 + return (float) (c0 >> 8) * (1.0f / 16777216.0f); +} + +// `count_in_history` and the penalty application, transcribed from `llama_sampler_penalties_apply`. +// The repeat penalty MULTIPLIES for non-positive logits and DIVIDES for positive ones - dividing +// unconditionally is the natural reading of the source paper and it INVERTS the penalty on half the +// vocabulary. The presence penalty is `float(count > 0)`, a boolean, not the count. +__device__ __forceinline__ int history_count(const int* __restrict__ h, int n, int v) { + int c = 0; + for (int i = 0; i < n; ++i) if (h[i] == v) ++c; + return c; +} + +__device__ __forceinline__ float apply_penalties(float logit, int count, const SamplerParams& p) { + if (count <= 0) return logit; + if (logit <= 0.0f) logit *= p.penalty_repeat; + else logit /= p.penalty_repeat; + logit -= (float) count * p.penalty_freq + (count > 0 ? 1.0f : 0.0f) * p.penalty_present; + return logit; +} + +/// **THE GREEDY ARGMAX, ONE BLOCK PER TOKEN, COVERING THE VOCABULARY.** +/// +/// **WHY THIS IS A SEPARATE KERNEL AND NOT A BRANCH.** `sampler_kernel` is launched as a grid over TOKENS +/// with 64 threads and a `if (t >= n_tokens) return;` at the top. The decode path has `n_tokens == 1`, so +/// that launch was `<<<1, 64>>>`, 63 threads exited on the first line, and ONE THREAD walked all 248,320 +/// logits in a dependent loop on one SM of 48. Measured in isolation (`bench/micro/sampler_cost.cu`): +/// **3.11 ms per token**, 5.7% of an ~54 ms token, and the whole of round 309's `sample` phase - the two +/// synchronisations around it are 0.03 ms each. +/// +/// The obvious repair is to parallelise the scan inside `sampler_kernel`, and it is WRONG: with one thread +/// per token, a block reduction over the vocabulary has nothing to reduce, and the threads that returned +/// early are not there for `__syncthreads` or `__shfl_down_sync`. The first attempt did exactly that and +/// produced the token `5120` thirty-two times. The grid has to be over tokens with the BLOCK over the +/// vocabulary, which is a different launch configuration and therefore a different kernel. +/// +/// **THE TIE RULE IS UNCHANGED AND THAT IS THE WHOLE CORRECTNESS ARGUMENT.** The serial scan walked `v` +/// ascending with `if (s > bv)`, so the LOWEST index wins a tie. Each thread keeps that rule over its own +/// strided subset and the reduction resolves two candidates by taking the larger value and, on equality, the +/// SMALLER index - the same total order, so `sampler_parity` and C1 see no change. +__global__ void sampler_greedy_kernel(const float* __restrict__ logits, int n_vocab, + const int* __restrict__ history, int history_len, const SamplerParams p, + int pmin, int plen, int* __restrict__ out) { + const int t = blockIdx.x; + const float* l = logits + (size_t) t * n_vocab; + (void) pmin; + const int* hrow = history ? history + (size_t) t * history_len : nullptr; + int hlen = 0; + if (hrow) { + hlen = plen < history_len ? plen : history_len; + if (hlen < 0) hlen = 0; + hrow += history_len - hlen; // the window is the TAIL + } + + // `n_vocab` is the "no candidate" index: it loses every comparison to a real one, so a thread with no + // elements contributes nothing rather than contributing a bogus zero. + float bv = __int_as_float(0xff800000); // -inf + int best = n_vocab; + for (int v = threadIdx.x; v < n_vocab; v += blockDim.x) { + const float s = apply_penalties(l[v], hrow ? history_count(hrow, hlen, v) : 0, p); + if (s > bv) { bv = s; best = v; } + } + for (int off = 16; off > 0; off >>= 1) { + const float ov = __shfl_down_sync(0xFFFFFFFFu, bv, off); + const int oi = __shfl_down_sync(0xFFFFFFFFu, best, off); + if (ov > bv || (ov == bv && oi < best)) { bv = ov; best = oi; } + } + __shared__ float sv[32]; + __shared__ int si[32]; + const int warp = (int) (threadIdx.x >> 5), lane = (int) (threadIdx.x & 31); + if (lane == 0) { sv[warp] = bv; si[warp] = best; } + __syncthreads(); + if (warp == 0) { + const int nw = (int) ((blockDim.x + 31) >> 5); + float wv = lane < nw ? sv[lane] : __int_as_float(0xff800000); + int wi = lane < nw ? si[lane] : n_vocab; + for (int off = 16; off > 0; off >>= 1) { + const float ov = __shfl_down_sync(0xFFFFFFFFu, wv, off); + const int oi = __shfl_down_sync(0xFFFFFFFFu, wi, off); + if (ov > wv || (ov == wv && oi < wi)) { wv = ov; wi = oi; } + } + // A tie between two `-inf` candidates leaves `wi == n_vocab`, and the serial version answered 0. + if (lane == 0) out[t] = (wi < n_vocab) ? wi : 0; + } +} + +__global__ void sampler_kernel(const float* __restrict__ logits, int n_vocab, int n_tokens, + const int* __restrict__ history, int history_len, const SamplerParams p, + int* __restrict__ out) { + const int t = blockIdx.x * blockDim.x + threadIdx.x; + if (t >= n_tokens) return; + + // Work on a private copy: the chain mutates the distribution and the caller's logits are shared with the + // next stage. A 248,320-float allocation per thread is far too much for shared memory, so this uses a + // two-pass approach instead - first find the keep set, then normalise over it - and never materialises a + // second full-size buffer. + const float* l = logits + (size_t) t * n_vocab; + + // Temperature is needed by BOTH paths below, so it is computed here; the chain still APPLIES it after + // the truncation filters - the survivors are chosen on the raw logits and only then scaled. + const float inv_t = p.temperature > 0.0f ? 1.0f / p.temperature : 0.0f; + + // ---- penalties: NOT IMPLEMENTED, and the ORDER they must take is transcribed here so the next person + // does not have to guess it. `llama_sampler_penalties_apply` (src/llama-sampler.cpp) runs AFTER + // temperature in the chain (L381 against L375), and for every token seen in the last `penalty_last_n`: + // + // if (logit <= 0) logit *= penalty_repeat; else logit /= penalty_repeat; + // logit -= float(count) * penalty_freq + float(count > 0) * penalty_present; + // + // TWO details there are counter-intuitive. The repeat penalty MULTIPLIES for non-positive logits and + // DIVIDES for positive ones - the source notes the paper only ever divided, "but that would cause tokens + // with negative logits to become more likely, which is obviously wrong". And the presence penalty rides + // on `float(count > 0)`, a BOOLEAN cast to float, not the count - so it applies once however many times + // the token appeared. Both are the kind of thing a plausible implementation gets wrong while still + // producing valid tokens. + // + // It needs the token history, which is not in this signature, so a caller passing a non-zero + // `penalty_last_n` gets a loud failure rather than a silently unpenalised sample. + // ---- top_k: k passes of a maximum scan, skipping the already-taken + // ---- top_p: cumulative mass over the survivors in descending order + // The two are done together in one selection pass so the survivors and their mass are consistent. + // GREEDY IS THE GLOBAL ARGMAX, WHATEVER THE FILTERS SAY - so it is computed directly and the filter chain + // is skipped entirely. No filter can remove the maximum: top_k keeps the k largest, top_p keeps a prefix + // of the descending order that always contains the largest (min_keep >= 1), and temperature is monotonic so + // it cannot reorder anything. That also makes greedy INDEPENDENT OF THE ORDER, which is why the ordering + // test cannot use it - see sampler_parity.cpp. + // The penalty window is the last `penalty_last_n` entries of this row's history. + const int* hrow = history ? history + (size_t) t * history_len : nullptr; + int hlen = 0; + if (hrow) { + hlen = p.penalty_last_n < history_len ? p.penalty_last_n : history_len; + if (hlen < 0) hlen = 0; + hrow += history_len - hlen; // the window is the TAIL + } + + if (p.greedy || p.temperature <= 0.0f) { + // Greedy is the global argmax ONLY when no penalty is active: a penalty can lower a token enough to + // change the maximum, so the argmax has to be taken over the penalised logits, not the raw ones. + // + // *** TEMPERATURE MUST NOT BE APPLIED HERE. THIS WAS A REAL BUG. *** + // This branch used to read `apply_penalties(l[v] * inv_t, ...)`. `inv_t` is 0.0f whenever + // temperature <= 0 (see above), so at temperature 0 EVERY logit became 0.0f and the argmax returned + // index 0 - the sampler emitted token 0 forever, whatever the model predicted. OpenAI clients send + // `temperature: 0` for greedy decoding, so this was reachable from any ordinary client. + // It is also pointless even when inv_t is non-zero: scaling by a positive constant is monotonic and + // cannot reorder the argmax, which is exactly what the note above this block already says. + int best = 0; + float bv = apply_penalties(l[0], hrow ? history_count(hrow, hlen, 0) : 0, p); + for (int v = 1; v < n_vocab; ++v) { + const float s = apply_penalties(l[v], hrow ? history_count(hrow, hlen, v) : 0, p); + if (s > bv) { bv = s; best = v; } + } + out[t] = best; + return; + } + + const int KMAX = 64; + int keep_ids[KMAX]; + float keep_logit[KMAX]; + int k = p.top_k > 0 ? (p.top_k < KMAX ? p.top_k : KMAX) : KMAX; + if (p.top_k <= 0) k = 0; // 0 means "no top-k filter" -> handled by the mask below + + if (k <= 0) { + std::printf("sampler: the sampled path needs top_k in 1..%d (got %d); greedy needs no filters\n", KMAX, p.top_k); + return; // leave out[t] unwritten rather than returning an uninitialised token + } + int n_keep = 0; + { + for (int i = 0; i < k; ++i) { + int best = -1; + float bv = 0.0f; + for (int v = 0; v < n_vocab; ++v) { + bool taken = false; + for (int j = 0; j < i; ++j) if (keep_ids[j] == v) { taken = true; break; } + if (taken) continue; + if (best < 0 || l[v] > bv) { best = v; bv = l[v]; } + } + keep_ids[i] = best; + keep_logit[i] = bv; + } + n_keep = k; + + // ---- top_p over the survivors, in descending order (which the selection above already produced) + if (p.top_p < 1.0f) { + // softmax over the keep set for the cumulative mass + float mx = keep_logit[0]; + for (int i = 1; i < n_keep; ++i) mx = fmaxf(mx, keep_logit[i]); + double sum = 0.0; + for (int i = 0; i < n_keep; ++i) sum += exp((double) keep_logit[i] - (double) mx); + double cum = 0.0; + int cut = n_keep; + for (int i = 0; i < n_keep; ++i) { + cum += exp((double) keep_logit[i] - (double) mx) / sum; + if (cum >= (double) p.top_p) { cut = i + 1; break; } + } + if (cut < p.min_keep) cut = p.min_keep < n_keep ? p.min_keep : n_keep; + n_keep = cut; + } + } + + + if (p.greedy || p.temperature <= 0.0f) { + // DEAD CODE AS WRITTEN: the identical condition at the top of this function already returned, so this + // block cannot be reached. It is kept and corrected rather than deleted because if that early return + // is ever narrowed, this is the path that would run - and it carried the same `* inv_t` bug (every + // logit 0.0f at temperature 0, so `best` would always be `keep_ids[0]`). Temperature is monotonic and + // cannot reorder a maximum, so it must not appear here at all. + // argmax over the SURVIVORS, ties by smallest id (the same convention as the router and top_k) + int best = keep_ids[0]; + float bv = keep_logit[0]; + for (int i = 1; i < n_keep; ++i) { + const float v = keep_logit[i]; + if (v > bv) { bv = v; best = keep_ids[i]; } + } + out[t] = best; + return; + } + + // ---- pick: temperature, then penalties, then softmax over the survivors, then one uniform draw. + // The ORDER is the chain's: the survivors were chosen on the RAW logits, temperature scales them, and the + // penalty is applied to the scaled value - which is why the `logit <= 0` branch inside the penalty sees a + // temperature-scaled logit and not the model's own. + for (int i = 0; i < n_keep; ++i) { + keep_logit[i] = apply_penalties(keep_logit[i] * inv_t, + hrow ? history_count(hrow, hlen, keep_ids[i]) : 0, p); + } + float mx = keep_logit[0]; + for (int i = 1; i < n_keep; ++i) mx = fmaxf(mx, keep_logit[i]); + double sum = 0.0; + for (int i = 0; i < n_keep; ++i) sum += exp((double) keep_logit[i] - (double) mx); + const float u = philox_uniform(p.seed, p.counter + (uint64_t) t); + double cum = 0.0; + int pick = keep_ids[n_keep - 1]; + for (int i = 0; i < n_keep; ++i) { + cum += exp((double) keep_logit[i] - (double) mx) / sum; + if ((double) u < cum) { pick = keep_ids[i]; break; } + } + out[t] = pick; +} + +} // namespace + +void sample_tokens(const float* logits, int n_tokens, int n_vocab, const int* history, int history_len, + const SamplerParams& p, int* out, void* stream) { + if (n_tokens <= 0 || n_vocab <= 0) return; + if (p.penalty_last_n > 0 && (history == nullptr || history_len <= 0)) { + std::fprintf(stderr, "sample_tokens: penalty_last_n %d needs a history (got %p, len %d)\n", + p.penalty_last_n, (const void*) history, history_len); + std::exit(1); + } + const int threads = 64; + const unsigned grid = (unsigned) ((n_tokens + threads - 1) / threads); + if (p.greedy || p.temperature <= 0.0f) { + // One block per token, 1,024 threads over the vocabulary. See `sampler_greedy_kernel`. + const int gthreads = 1024; + sampler_greedy_kernel<<<(unsigned) n_tokens, gthreads, 0, (cudaStream_t) stream>>>( + logits, n_vocab, history, history_len, p, p.penalty_last_n, p.penalty_last_n, out); + } else { + sampler_kernel<<>>(logits, n_vocab, n_tokens, history, + history_len, p, out); + } + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "sample_tokens launch: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + if (stream == nullptr) cudaDeviceSynchronize(); +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/shared_expert.cu b/src/kernels/cuda/shared_expert.cu new file mode 100644 index 00000000..81402a9f --- /dev/null +++ b/src/kernels/cuda/shared_expert.cu @@ -0,0 +1,338 @@ +// src/kernels/cuda/shared_expert.cu - P2.S2: the shared expert. +// +// `ref/moe.py::shared_expert`, transcribed: +// +// h = silu(x @ gate_shexp.T) * (x @ up_shexp.T) <- SILU GOES ON GATE, not on up +// h = h @ down_shexp.T (nt, n_embd) +// g = sigmoid(x @ gate_inp_shexp) (nt,) ONE SCALAR PER TOKEN +// return h * g[:, None] +// +// TWO THINGS HERE WERE BELIEVED WRONG FOR MANY ROUNDS and `docs/semantics.md` records both. SILU GOES ON THE +// GATE TENSOR: the opposite reading is plausible, produces the right shapes, and is wrong. And +// `ffn_gate_inp_shexp` is `(n_embd,)` whose output is "one value per token" - a SCALAR gate obtained by dotting +// that vector with the hidden state, NOT a per-expert gate and NOT a per-dimension elementwise one. Both were +// live readings of the same shapes until the source comment settled them. +// +// The result is ADDED to the routed output - not router-weighted, not renormalised against it. +// +// The legacy canonical projections use their explicit Q8_0/Q8_K activation images. The optional native +// BF16 path affects only the scalar gate: it reads the original F32 input and uses pinned CUDA MMVF plus +// an FP32 sigmoid. Native projection overrides independently select CUDA Q8_1 MMVQ and FP32 SwiGLU. +#include "strata/kernels/shared_expert.hpp" +#include "strata/kernels/bf16_gemv.hpp" +#include "strata/kernels/bf16_bits.hpp" +#include "strata/kernels/f16_bits.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/s2_gemv_q8.hpp" +#include "strata/kernels/s_gemv.hpp" +#include "strata/kernels/native_mmvq.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int THREADS = 128; +bool native_bf16 = false; + +// `f32_to_f16` used to live here as a private copy. Round 198 found it - and the identical one in +// `quantize_act.cu` - returning a NaN for every FINITE value that overflows fp16 (1e30, 65536, 1e45) instead +// of saturating to inf, because `exp >= 31` conflates an f32 inf/NaN with an out-of-range finite exponent. +// This file's fixture is all O(1), so it could not see it. The conversion now lives in one place, with the +// regimes spelled out. + +// silu(x) = x / (1 + exp(-x)), written as `ref/moe.py` writes it, in DOUBLE then cast - the reference works in +// float64 and a float32 exp differs in the last bits. The multiplication by `up` is the reference's order. +__global__ void swiglu_kernel(const float* __restrict__ gate, const float* __restrict__ up, + float* __restrict__ out, int n) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n) return; + const double x = (double) gate[i]; + out[i] = (float) (x / (1.0 + exp(-x))) * up[i]; +} + +__global__ void native_swiglu_kernel(const float* gate, const float* up, float* out, int n) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n) return; + // Pinned CUDA unary.cuh op_silu, then unary_gated_op_kernel's multiply. + // Explicit intrinsics reproduce its --use_fast_math operations without + // changing compilation of the default legacy arithmetic in this file. + out[i] = __fdividef(gate[i], 1.0f + __expf(-gate[i])) * up[i]; +} + +__global__ void to_f16_kernel(const float* __restrict__ in, uint16_t* __restrict__ out, int n) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) out[i] = f16_from_f32(in[i]); +} + +// The per-token scalar gate: sigmoid(dot(x, w)) with w = `ffn_gate_inp_shexp` (n_embd,). +// +// **ONE THREAD WAS THE LARGEST SINGLE KERNEL IN THE MODEL, AND THE COMMENT DEFENDING IT SAID WHY IT SHOULD +// NOT BE.** It read: "n_embd is 2560, so this is a short serial loop and the parallelism is elsewhere." +// 2560 iterations is not short when each one is a DOUBLE multiply-add. This card's double rate is **1/64** of +// its float rate, so the loop is a 2560-long dependency chain through a 1/64-rate unit - tens of microseconds +// from the chain alone, before counting 2560 sequential global loads issued by one thread with no coalescing. +// +// MEASURED, and this is what the number looks like from outside: `shared_expert` is **0.2374 ms of a +// 0.8883 ms block - 26.7% of the whole block, 11.4 ms per token** - for three projections that read 5.5 MB and +// whose memory floor is 0.009 ms. It is seven kernels, and six of them are GEMVs or elementwise passes over +// 640-2560 elements. This is the one that cannot be explained by bandwidth. +// +// THE DOUBLE ACCUMULATOR IS KEPT. `ref/gr.py` reduces in float64 and `block_sum` in `gr.cu` exists for the +// same reason - the summation order of a tree is not the reference's, so the defence is enough precision that +// the order stops mattering. What changes is the CHAIN: ten terms per thread instead of 2560, then a +// tree-reduce. Through a sigmoid, a 1e-16 relative difference in the dot is not a difference. +// +// **BOTH OPERANDS ARE BF16, AND THAT IS THE REFERENCE'S CONTRACT, NOT A STORAGE DETAIL.** `gate_inp_shexp` is a +// BF16 weight, so ggml converts the activation to its `vec_dot_type` - BF16 - exactly as it does for the router +// (`docs/activation-contract.md`). This kernel used to take `const float* w` while the LOADER had already +// re-rounded the tensor to 2 B/elem, so it read 2560 floats out of a 5120-byte buffer: 5120 B past the end of +// the tensor, inside the 4.5 GiB arena, where nothing faults. The dot came out astronomically large, sigmoid +// saturated to 1.0, and `scale_kernel` multiplied the shared expert's output by it - which is why the FIRST +// symptom was 2560 non-finite outputs rather than a wrong scalar. +// +// A wrong scalar here is the quiet failure mode: sigmoid bounds the damage to [0,1], so a gate that should be +// 0.5 and reads 1.0 scales the shared expert by 2x and produces perfectly finite, perfectly plausible logits. +__device__ __forceinline__ double warp_sum_d(double v) { + for (int off = 16; off > 0; off >>= 1) v += __shfl_down_sync(0xFFFFFFFFu, v, off); + return __shfl_sync(0xFFFFFFFFu, v, 0); +} + +__global__ void scalar_gate_kernel(const uint16_t* __restrict__ x_bf16, const uint16_t* __restrict__ w_bf16, + float* __restrict__ out, int n_embd) { + __shared__ double scratch[8]; // 8 warps: the launch is <<<1, 256>>> + double acc = 0.0; + for (int i = threadIdx.x; i < n_embd; i += blockDim.x) + acc += (double) f32_from_bf16(x_bf16[i]) * (double) f32_from_bf16(w_bf16[i]); + __syncthreads(); + const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5; + acc = warp_sum_d(acc); + if (lane == 0) scratch[warp] = acc; + __syncthreads(); + const int nw = ((int) blockDim.x + 31) >> 5; + if (warp == 0) { + acc = (threadIdx.x < nw) ? scratch[threadIdx.x] : 0.0; + acc = warp_sum_d(acc); + if (threadIdx.x == 0) out[0] = (float) (1.0 / (1.0 + exp(-acc))); + } +} + +__global__ void scale_kernel(float* __restrict__ out, const float* __restrict__ g, int n) { + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) out[i] *= g[0]; +} + +__global__ void native_scalar_sigmoid_kernel(float* gate) { + // Match the single-token CUDA sigmoid's FP32 fast-math operations without changing legacy kernels' + // compilation flags. The dot product was already reduced by the pinned native MMVF implementation. + gate[0] = __fdividef(1.0f, 1.0f + __expf(-gate[0])); +} + +/// The MoE block's final combination. See the header for the two readings it exists to pin. +__global__ void moe_combine_kernel(const float* __restrict__ parts, const float* __restrict__ weights, + const float* __restrict__ shared, float* __restrict__ y, int n_embd, + int k, int has_shared) { + const int j = blockIdx.x * blockDim.x + threadIdx.x; + if (j >= n_embd) return; + // Accumulate in DOUBLE, in the reference's own order (`for i in range(k): out[t] += w[t,i] * g[0]`). + // f32 would be defensible for ten terms, but the reference is float64 and this is one line. + double acc = 0.0; + for (int e = 0; e < k; ++e) acc += (double) weights[e] * (double) parts[(size_t) e * n_embd + j]; + // The SHARED output is added PLAIN - not router-weighted, not renormalised against the routed sum. + if (has_shared) acc += (double) shared[j]; + y[j] = (float) acc; +} + +} // namespace + +void shared_expert_set_native_bf16(bool enabled) { native_bf16 = enabled; } + +namespace { +__global__ void scale_rows_kernel(float* __restrict__ out, const float* __restrict__ g, int n) { + const int t = blockIdx.y; + const int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i < n) out[(size_t) t * n + i] *= g[t]; +} +} // namespace + +void shared_expert_multi(int n_tok, const float* x, const uint16_t* x_bf16, const NativeSharedWeights& nw, + const uint16_t* gate_inp_bf16, float* gate, float* up, float* g, float* out, int64_t n_embd, + int64_t n_ff, void* stream) { + if (n_tok < 1 || n_tok > 8 || !nw.q8_1 || !nw.gate_data || !nw.up_data || !nw.down_data || !stream) + throw std::invalid_argument("shared_expert_multi: needs 1..8 tokens, native weights, scratch and a stream"); + cudaStream_t cs = (cudaStream_t) stream; + native_quantize_q8_1(x, nw.q8_1, (int) n_embd, n_tok, stream); + native_mmvq(nw.gate_type, nw.gate_data, nw.q8_1, gate, (int) n_embd, (int) n_ff, n_tok, stream); + native_mmvq(nw.up_type, nw.up_data, nw.q8_1, up, (int) n_embd, (int) n_ff, n_tok, stream); + const int n = (int) (n_ff * n_tok); + native_swiglu_kernel<<<(unsigned) ((n + THREADS - 1) / THREADS), THREADS, 0, cs>>>(gate, up, gate, n); + native_quantize_q8_1(gate, nw.q8_1, (int) n_ff, n_tok, stream); + native_mmvq(nw.down_type, nw.down_data, nw.q8_1, out, (int) n_ff, (int) n_embd, n_tok, stream); + for (int t = 0; t < n_tok; ++t) { + if (native_bf16) { + bf16_gemv_fp32_mmvf(x + (size_t) t * n_embd, gate_inp_bf16, g + t, n_embd, 1, stream); + native_scalar_sigmoid_kernel<<<1, 1, 0, cs>>>(g + t); + } else { + scalar_gate_kernel<<<1, 256, 0, cs>>>(x_bf16 + (size_t) t * n_embd, gate_inp_bf16, g + t, (int) n_embd); + } + } + scale_rows_kernel<<>>( + out, g, (int) n_embd); + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) throw std::runtime_error(std::string("shared_expert_multi: ") + cudaGetErrorString(e)); +} + +uint64_t shared_expert_scratch_bytes(int64_t n_ff) { + // gate (n_ff f32) | up (n_ff f32) | q8_0 (n_ff/32*34) | q8k (n_ff/256*292) | g (1 f32), 16-byte aligned + const uint64_t a = ((uint64_t) n_ff * 4 + 15) & ~15ull; + const uint64_t q0 = ((uint64_t) (n_ff / 32) * 34 + 15) & ~15ull; + const uint64_t qk = ((uint64_t) (n_ff / 256) * 292 + 15) & ~15ull; + return a * 2 + q0 + qk + 32; +} + +void shared_expert(const uint8_t* x_q8_0, const uint8_t* x_q8k, const uint16_t* x_bf16, const SForm& gate_form, + const uint8_t* gate_codes, const float* gate_scales, const float* gate_off, + const SForm& up_form, const uint8_t* up_codes, const float* up_scales, const float* up_off, + const SForm& down_form, const uint8_t* down_codes, const float* down_scales, + const float* down_off, const uint16_t* gate_inp_bf16, float* scratch, float* out, + int64_t n_embd, int64_t n_ff, int tpr, void* stream, const float* x_f32, + const NativeSharedWeights* native) { + if (n_embd <= 0 || n_ff <= 0) return; + const bool use_native = native_bf16; + const bool native_gate = native && native->gate_data && native_mmvq_supported(native->gate_type); + const bool native_up = native && native->up_data && native_mmvq_supported(native->up_type); + const bool native_down = native && native->down_data && native_mmvq_supported(native->down_type); + const bool native_projection = native_gate || native_up || native_down; + if ((use_native || native_gate || native_up) && !x_f32) + throw std::invalid_argument("shared_expert native input projection requires unrounded x_f32"); + if (native_projection) { + if (!native->q8_1 || !stream || n_embd > INT_MAX || n_ff > INT_MAX) + throw std::invalid_argument("shared_expert native projections require scratch, stream and int32 dimensions"); + // Validate every active shape before any kernel is enqueued. + if (native_gate) native_mmvq_weight_bytes(native->gate_type, (int) n_embd, (int) n_ff); + if (native_up) native_mmvq_weight_bytes(native->up_type, (int) n_embd, (int) n_ff); + if (native_down) native_mmvq_weight_bytes(native->down_type, (int) n_ff, (int) n_embd); + } + if (scratch == nullptr) { + std::fprintf(stderr, "shared_expert: scratch is null; the caller owns it " + "(see shared_expert_scratch_bytes)\n"); + std::exit(1); + } + // CARVED FROM THE CALLER'S SCRATCH. This used to be four `cudaMalloc`s and four `cudaFree`s per call, which + // is illegal during stream capture AND a token-path allocation - P2.T10 forbids both. + uint8_t* p = (uint8_t*) scratch; + const uint64_t a = ((uint64_t) n_ff * 4 + 15) & ~15ull; + const uint64_t q0 = ((uint64_t) (n_ff / 32) * 34 + 15) & ~15ull; + const uint64_t qk = ((uint64_t) (n_ff / 256) * 292 + 15) & ~15ull; + float* gate = (float*) p; + float* up = (float*) (p + a); + uint8_t* h_q8_0 = (uint8_t*) (p + a * 2); + uint8_t* h_q8k = (uint8_t*) (p + a * 2 + q0); + float* g = (float*) (p + a * 2 + q0 + qk); + + // WHICH ACTIVATION THIS PROJECTION WANTS, READ FROM ITS OWN FORM. See `SForm::act_kind`: the three + // families cannot be told apart by the other fields, so the kind is carried rather than derived. + auto gemv = [&](const SForm& f, const uint8_t* codes, const float* scales, const float* off, + const uint8_t* act80, const uint8_t* actq8k, float* y, int64_t nin, int64_t nout) { + if (f.code_bits == 2) { + s2_gemv_q8(act80, codes, scales, y, nin, nout, tpr, stream); + } else if (f.act_kind == 1) { + s_gemv_q8k_split(actq8k, codes, scales, off, y, nin, nout, f, stream); + } else { + s_gemv_q8_0_split(act80, codes, scales, off, y, nin, nout, f, stream); + } + }; + + const unsigned g_ff = (unsigned) ((n_ff + THREADS - 1) / THREADS); + const unsigned g_embd = (unsigned) ((n_embd + THREADS - 1) / THREADS); + + // gate and up projections, then silu(gate) * up in place in `gate` + if (native_gate || native_up) + native_quantize_q8_1(x_f32, native->q8_1, (int) n_embd, 1, stream); + if (native_gate) + native_mmvq(native->gate_type, native->gate_data, native->q8_1, gate, (int) n_embd, (int) n_ff, 1, stream); + else + gemv(gate_form, gate_codes, gate_scales, gate_off, x_q8_0, x_q8k, gate, n_embd, n_ff); + if (native_up) + native_mmvq(native->up_type, native->up_data, native->q8_1, up, (int) n_embd, (int) n_ff, 1, stream); + else + gemv(up_form, up_codes, up_scales, up_off, x_q8_0, x_q8k, up, n_embd, n_ff); + if (native_projection) + native_swiglu_kernel<<>>(gate, up, gate, (int) n_ff); + else + swiglu_kernel<<>>(gate, up, gate, (int) n_ff); + + // down: (n_ff) -> (n_embd), and THE INTERMEDIATE IS QUANTIZED TO THE DOWN WEIGHT'S OWN CONTRACT - which is + // what `ggml_mul_mat` does for every matmul in the model. It used to be rounded to fp16 with no + // justification beyond "the kernel takes fp16". + if (native_down) { + native_quantize_q8_1(gate, native->q8_1, (int) n_ff, 1, stream); + native_mmvq(native->down_type, native->down_data, native->q8_1, out, (int) n_ff, (int) n_embd, 1, stream); + } else if (down_form.act_kind == 1) { + if (n_ff % 256 != 0) { + std::fprintf(stderr, "shared_expert: the down weight wants Q8_K but n_ff %lld is not a multiple " + "of 256; Q8_K is structurally impossible here\n", (long long) n_ff); + std::exit(1); + } + quantize_q8_K(gate, h_q8k, n_ff, stream); + gemv(down_form, down_codes, down_scales, down_off, h_q8_0, h_q8k, out, n_ff, n_embd); + } else if (down_form.code_bits == 2) { + quantize_q8_0(gate, h_q8_0, n_ff, stream); + gemv(down_form, down_codes, down_scales, down_off, h_q8_0, h_q8k, out, n_ff, n_embd); + } else { + quantize_q8_0(gate, h_q8_0, n_ff, stream); + gemv(down_form, down_codes, down_scales, down_off, h_q8_0, h_q8k, out, n_ff, n_embd); + } + + // the per-token scalar gate, then the multiply. Note the gate is computed from `x`, the ORIGINAL hidden + // state, not from anything the expert produced. The historical branch uses BF16-rounded inputs; the + // native branch uses the pinned CUDA FP32 activation contract. + // `<<<1, 256>>>`: one block, because the output is ONE scalar and a second block would only add a global + // round trip. 256 threads is the reduction's width, not the problem's size. + if (use_native) { + bf16_gemv_fp32_mmvf(x_f32, gate_inp_bf16, g, n_embd, 1, stream); + native_scalar_sigmoid_kernel<<<1, 1, 0, (cudaStream_t) stream>>>(g); + } else { + scalar_gate_kernel<<<1, 256, 0, (cudaStream_t) stream>>>(x_bf16, gate_inp_bf16, g, (int) n_embd); + } + scale_kernel<<>>(out, g, (int) n_embd); + + if (stream == nullptr) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "shared_expert: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +void moe_combine(const float* parts, const float* weights, const float* shared, float* y, int64_t n_embd, + int64_t k, void* stream) { + if (n_embd <= 0 || k <= 0) return; + // k > 64 is refused rather than truncated: silently summing the first 64 of a longer list would be a + // wrong answer that looks like a right one, and no geometry in this artifact comes close to it. + if (k > 64) { + std::fprintf(stderr, "moe_combine: k = %lld exceeds 64\n", (long long) k); + std::exit(1); + } + const unsigned grid = (unsigned) ((n_embd + THREADS - 1) / THREADS); + moe_combine_kernel<<>>(parts, weights, shared, y, (int) n_embd, + (int) k, shared != nullptr); + if (stream == nullptr) { + const cudaError_t e = cudaDeviceSynchronize(); + if (e != cudaSuccess) { + std::fprintf(stderr, "moe_combine: %s\n", cudaGetErrorString(e)); + std::exit(1); + } + } +} + +} // namespace strata::kernels diff --git a/src/kernels/cuda/verify_kernels.cu b/src/kernels/cuda/verify_kernels.cu new file mode 100644 index 00000000..f9abe84d --- /dev/null +++ b/src/kernels/cuda/verify_kernels.cu @@ -0,0 +1,450 @@ +// src/kernels/cuda/verify_kernels.cu - see include/strata/kernels/verify_kernels.hpp. +// +// The per-token arithmetic of every kernel here is transcribed from its single-token original (fused_gdn.cu, +// elementwise.cu) with the same operation order, so a verify window reproduces plain decode bit for bit. +#include "strata/kernels/verify_kernels.hpp" + +#include + +#include +#include + +namespace strata::kernels { +namespace { + +constexpr int S = 128; // GDN state size +constexpr int RG = 4; +constexpr int RPG = S / RG; + +void check(const char* what) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); std::exit(1); } +} + +__global__ void __launch_bounds__(S) gdn_conv_l2_multi_kernel(const float* __restrict__ hist, + const float* __restrict__ qkv, + const float* __restrict__ w, float* __restrict__ h, + int C, int qk_heads, float eps, int t_begin) { + __shared__ float part[S / 32]; + const int t = t_begin + blockIdx.y; + const int c = blockIdx.x * S + threadIdx.x; + // the window of token t: [hist0, hist1, hist2, x_0, ..., x_t], its last four entries + float win[3]; +#pragma unroll + for (int j = 0; j < 3; ++j) { + const int src = t + j; // index into [hist(3) | x...] + win[j] = src < 3 ? hist[c * 3 + src] : qkv[(size_t) (src - 3) * C + c]; + } + const float v0 = win[0], v1 = win[1], v2 = win[2], x = qkv[(size_t) t * C + c]; + float sum = v0 * w[c * 4] + v1 * w[c * 4 + 1] + v2 * w[c * 4 + 2] + x * w[c * 4 + 3]; + float y = sum / (1.0f + __expf(-sum)); + if ((int) blockIdx.x < qk_heads) { + float sq = y * y; + for (int o = 16; o > 0; o >>= 1) sq += __shfl_xor_sync(0xffffffffu, sq, o); + if ((threadIdx.x & 31) == 0) part[threadIdx.x >> 5] = sq; + __syncthreads(); + const float ss = part[0] + part[1] + part[2] + part[3]; + y *= rsqrtf(ss + eps); + } + h[(size_t) t * C + c] = y; +} + +__global__ void gdn_conv_commit_kernel(float* __restrict__ hist, const float* __restrict__ qkv, int C, + const int32_t* __restrict__ n_keep) { + const int c = blockIdx.x * blockDim.x + threadIdx.x; + if (c >= C) return; + const int n = *n_keep; + if (n <= 0) return; + float seq[3]; +#pragma unroll + for (int j = 0; j < 3; ++j) { + const int src = n + j; // the last three of [hist(3) | x_0..x_{n-1}] + seq[j] = src < 3 ? hist[c * 3 + src] : qkv[(size_t) (src - 3) * C + c]; + } + hist[c * 3] = seq[0]; + hist[c * 3 + 1] = seq[1]; + hist[c * 3 + 2] = seq[2]; +} + +__global__ void __launch_bounds__(256) gdn_ab_multi_kernel(const float* __restrict__ x, const uint16_t* __restrict__ wa, + const uint16_t* __restrict__ wb, + const float* __restrict__ dt, + const float* __restrict__ ssm_a, float* __restrict__ gate, + float* __restrict__ beta, int n, int h_v, int T) { + const int row = blockIdx.x * 8 + (threadIdx.x >> 5), lane = threadIdx.x & 31; + if (row >= 2 * h_v) return; + const bool is_beta = row >= h_v; + const int r = is_beta ? row - h_v : row; + const uint4* w4 = reinterpret_cast((is_beta ? wb : wa) + (size_t) r * n); + float acc[kVerifyMaxT]; +#pragma unroll + for (int t = 0; t < kVerifyMaxT; ++t) acc[t] = 0.0f; + for (int j = lane; j < n / 8; j += 32) { + const uint4 wv = __ldg(w4 + j); +#pragma unroll + for (int t = 0; t < kVerifyMaxT; ++t) { + if (t >= T) break; + const float* xt = x + (size_t) t * n; + const float4 xa = *reinterpret_cast(xt + j * 8); + const float4 xb = *reinterpret_cast(xt + j * 8 + 4); + float a = acc[t]; + a = fmaf(__uint_as_float(wv.x << 16), xa.x, a); a = fmaf(__uint_as_float(wv.x & 0xffff0000u), xa.y, a); + a = fmaf(__uint_as_float(wv.y << 16), xa.z, a); a = fmaf(__uint_as_float(wv.y & 0xffff0000u), xa.w, a); + a = fmaf(__uint_as_float(wv.z << 16), xb.x, a); a = fmaf(__uint_as_float(wv.z & 0xffff0000u), xb.y, a); + a = fmaf(__uint_as_float(wv.w << 16), xb.z, a); a = fmaf(__uint_as_float(wv.w & 0xffff0000u), xb.w, a); + acc[t] = a; + } + } +#pragma unroll + for (int t = 0; t < kVerifyMaxT; ++t) { + if (t >= T) break; + float a = acc[t]; + for (int o = 16; o > 0; o >>= 1) a += __shfl_xor_sync(0xffffffffu, a, o); + if (lane != 0) continue; + if (is_beta) { + beta[(size_t) t * h_v + r] = 1.0f / (1.0f + __expf(-a)); + } else { + const float v = a + dt[r]; + const float sp = v > 20.0f ? v : log1pf(__expf(v)); + gate[(size_t) t * h_v + r] = sp * ssm_a[r]; + } + } +} + +__global__ void __launch_bounds__(S * RG) gdn_step_norm_multi_kernel(float* __restrict__ state, + const float* __restrict__ hbuf, int C, + const float* __restrict__ gate, + const float* __restrict__ beta, + const float* __restrict__ z, + const float* __restrict__ gamma, float eps, + float* __restrict__ y, int h_k, int h_v, int T, + const int32_t* __restrict__ n_keep, int t_out_begin) { + __shared__ float sk[S], sq[S]; + __shared__ float red[RG][S]; + __shared__ float wsum[S * RG / 32]; + const int head = blockIdx.x; + const int col = threadIdx.x; + const int rg = threadIdx.y; + const int tid = rg * S + col; + const int qh = head % h_k; + const int qk = S * h_k; // q at [0, qk), k at [qk, 2qk), v at [2qk, ...) + const int value_dim = S * h_v; + const int n = n_keep ? *n_keep : T; + float s[RPG]; + float* base = state + ((size_t) (rg * RPG) * h_v + head) * S + col; + const size_t row_stride = (size_t) h_v * S; +#pragma unroll + for (int r = 0; r < RPG; ++r) s[r] = base[r * row_stride]; + for (int t = 0; t < n; ++t) { + const float* ht = hbuf + (size_t) t * C; + __syncthreads(); // the previous token is done with sk/sq/red/wsum + if (tid < S) { sk[tid] = ht[qk + qh * S + tid]; sq[tid] = ht[qh * S + tid]; } + __syncthreads(); + const float g = __expf(gate[(size_t) t * h_v + head]); + float kv = 0.0f; +#pragma unroll + for (int r = 0; r < RPG; ++r) kv = fmaf(s[r], sk[rg * RPG + r], kv); + red[rg][col] = kv; + __syncthreads(); + const float kv_col = red[0][col] + red[1][col] + red[2][col] + red[3][col]; + const float delta = (ht[2 * qk + head * S + col] - g * kv_col) * beta[(size_t) t * h_v + head]; + float o = 0.0f; +#pragma unroll + for (int r = 0; r < RPG; ++r) { + s[r] = fmaf(g, s[r], sk[rg * RPG + r] * delta); + o = fmaf(s[r], sq[rg * RPG + r], o); + } + __syncthreads(); + red[rg][col] = o; + __syncthreads(); + float oc = 0.0f, sq_part = 0.0f; + if (rg == 0) { + oc = (red[0][col] + red[1][col] + red[2][col] + red[3][col]) * rsqrtf((float) S); + sq_part = oc * oc; + } + if (t < t_out_begin) continue; // a replayed token: its state update is needed, its output is not + for (int o2 = 16; o2 > 0; o2 >>= 1) sq_part += __shfl_xor_sync(0xffffffffu, sq_part, o2); + if ((tid & 31) == 0) wsum[tid >> 5] = sq_part; + __syncthreads(); + if (rg == 0) { + const float ss = wsum[0] + wsum[1] + wsum[2] + wsum[3]; + const float scale = rsqrtf(ss / (float) S + eps); + const float zz = z[(size_t) t * value_dim + head * S + col]; + y[(size_t) t * value_dim + head * S + col] = oc * scale * gamma[col] * (1.0f / (1.0f + __expf(-zz))); + } + } + if (n_keep != nullptr && n > 0) { +#pragma unroll + for (int r = 0; r < RPG; ++r) base[r * row_stride] = s[r]; + } +} + +__global__ void embedding_gather_dev_kernel(const uint8_t* __restrict__ codes, const float* __restrict__ scales, + const float* __restrict__ offsets, const int32_t* __restrict__ tokens, + int64_t n, int code_bits, int code_bias, int group_elems, + unsigned long long row_codes, unsigned long long row_groups, + float* __restrict__ out) { + const int t = blockIdx.y; + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n) return; + const unsigned long long token = (unsigned long long) tokens[t]; + const uint8_t* c = codes + token * row_codes; + const float* sc = scales + token * row_groups; + const float* of = offsets ? offsets + token * row_groups : nullptr; + const int per_byte = 8 / code_bits; + const unsigned mask = (1u << code_bits) - 1u; + const int code = (c[i / per_byte] >> ((i % per_byte) * code_bits)) & mask; + const int64_t group = i / group_elems; + const float product = __fmul_rn((float) (code + code_bias), sc[group]); + out[(size_t) t * n + i] = __fadd_rn(product, of ? of[group] : 0.0f); +} + +__global__ void broadcast_streams_kernel(const float* __restrict__ x, float* __restrict__ R, int64_t n, int hc) { + const int t = blockIdx.y; + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n * hc) return; + R[(size_t) t * n * hc + i] = x[(size_t) t * n + i % n]; +} + +__global__ void copy_indexed_kernel(float* __restrict__ dst, const float* __restrict__ src, int64_t stride, + const int32_t* __restrict__ index, int64_t n) { + const int idx = *index; + if (idx < 0) return; + for (int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; i < n; i += (int64_t) gridDim.x * blockDim.x) + dst[i] = src[(size_t) idx * stride + i]; +} + +__global__ void fetch_blobs_kernel(const unsigned long long* __restrict__ src, const int32_t* __restrict__ n, + uint4* __restrict__ dst, long long per) { + const long long total = (long long) *n * per; + for (long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; i < total; + i += (long long) gridDim.x * blockDim.x) { + const long long k = i / per, off = i - k * per; + dst[i] = ((const uint4*) src[k])[off]; + } +} + +__global__ void rebase_ptrs_kernel(unsigned long long* ptr, const int32_t* n, unsigned long long base, long long bytes) { + const int k = threadIdx.x; + if (k < *n) ptr[k] = base + (unsigned long long) k * (unsigned long long) bytes; +} + +__global__ void add_streams_broadcast_kernel(const float* __restrict__ h, const float* __restrict__ e, + float* __restrict__ R, int64_t n, int hc) { + const int t = blockIdx.y; + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n * hc) return; + R[(size_t) t * n * hc + i] = h[(size_t) t * n * hc + i] + e[(size_t) t * n + i % n]; +} + +__global__ void ident_hits_kernel(const int32_t* __restrict__ ids, int n, int32_t* __restrict__ slot, + int32_t* __restrict__ dst, int32_t* __restrict__ count) { + const int i = threadIdx.x; + if (i < n) { slot[i] = ids[i]; dst[i] = i; } + if (i == 0) *count = n; +} + +__global__ void gather_rows_kernel(const uint4* __restrict__ src, long long row16, const int32_t* __restrict__ ids, + long long n, uint4* __restrict__ dst) { + const long long total = n * row16; + for (long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; i < total; i += (long long) gridDim.x * blockDim.x) { + const long long r = i / row16, o = i - r * row16; + dst[i] = src[(long long) ids[r] * row16 + o]; + } +} + +__global__ void map_ids_kernel(int32_t* ids, const int32_t* __restrict__ table, int n) { + const int i = threadIdx.x; + if (i < n) ids[i] = table[ids[i]]; +} + +__global__ void row_top_prob_kernel(const float* __restrict__ logits, int n_vocab, const int32_t* __restrict__ ids, + float* __restrict__ probs) { + __shared__ float part[32]; + const int t = blockIdx.x; + const float* l = logits + (size_t) t * n_vocab; + const float m = l[ids[t]]; + float s = 0.0f; + for (int i = threadIdx.x; i < n_vocab; i += blockDim.x) s += __expf(l[i] - m); + for (int o = 16; o > 0; o >>= 1) s += __shfl_xor_sync(0xffffffffu, s, o); + if ((threadIdx.x & 31) == 0) part[threadIdx.x >> 5] = s; + __syncthreads(); + if (threadIdx.x == 0) { + float tot = 0.0f; + for (int w = 0; w < (int) (blockDim.x >> 5); ++w) tot += part[w]; + probs[t] = 1.0f / tot; + } +} + +__global__ void mtp_select_kernel(const float* __restrict__ R_src, int64_t stride, const int32_t* __restrict__ ids, + const int32_t* __restrict__ row_dev, float* __restrict__ R_dst, + int32_t* __restrict__ tok_dst, int32_t* out, int j, const float* probs, float* out_p) { + const int row = *row_dev; + for (int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; i < stride; i += (int64_t) gridDim.x * blockDim.x) + R_dst[i] = R_src[(size_t) row * stride + i]; + if (blockIdx.x == 0 && threadIdx.x == 0) { + const int32_t tok = ids[row]; + *tok_dst = tok; + if (out != nullptr) ((volatile int32_t*) out)[j] = tok; + if (probs != nullptr && out_p != nullptr) ((volatile float*) out_p)[j] = probs[row]; + } +} + +__global__ void dense_steps_kernel(const int32_t* __restrict__ cells, int n, int32_t* __restrict__ steps) { + const int i = threadIdx.x; + if (i >= n) return; + const int c = cells[i]; + steps[i * 4 + 0] = c; + steps[i * 4 + 1] = c + 1; + steps[i * 4 + 2] = (c + 1) / 4; + steps[i * 4 + 3] = c + 1; +} + +} // namespace + +void fetch_blobs(const unsigned long long* src, const int32_t* n, uint8_t* dst, int64_t blob_bytes, int cap, void* stream) { + if (cap <= 0) return; + if (blob_bytes % 16 != 0) { std::fprintf(stderr, "fetch_blobs: blob size must be a multiple of 16\n"); std::exit(1); } + fetch_blobs_kernel<<<48 * 8, 256, 0, (cudaStream_t) stream>>>(src, n, (uint4*) dst, (long long) (blob_bytes / 16)); + check("fetch_blobs"); +} + +void rebase_ptrs(unsigned long long* ptr, const int32_t* n, uint8_t* base, int64_t blob_bytes, void* stream) { + rebase_ptrs_kernel<<<1, 128, 0, (cudaStream_t) stream>>>(ptr, n, (unsigned long long) base, (long long) blob_bytes); + check("rebase_ptrs"); +} + +void add_streams_broadcast(const float* h, const float* e, float* R, int64_t n_embd, int hc, int n_tok, void* stream) { + add_streams_broadcast_kernel<<>>(h, e, R, n_embd, hc); + check("add_streams_broadcast"); +} + +void ident_hits(const int32_t* ids, int n, int32_t* slot, int32_t* dst, int32_t* count, void* stream) { + if (n < 1 || n > 1024) { std::fprintf(stderr, "ident_hits: n out of range\n"); std::exit(1); } + ident_hits_kernel<<<1, 1024, 0, (cudaStream_t) stream>>>(ids, n, slot, dst, count); + check("ident_hits"); +} + +void mtp_select(const float* R_src, int64_t R_stride, const int32_t* ids, const int32_t* row_dev, float* R_dst, + int32_t* tok_dst, int32_t* out, int j, void* stream, const float* probs, float* out_p) { + mtp_select_kernel<<<16, 256, 0, (cudaStream_t) stream>>>(R_src, R_stride, ids, row_dev, R_dst, tok_dst, out, j, + probs, out_p); + check("mtp_select"); +} + +void gather_rows(const uint8_t* src, int64_t row_bytes, const int32_t* ids, int64_t n, uint8_t* dst, void* stream) { + if (row_bytes % 16 != 0) { std::fprintf(stderr, "gather_rows: row size must be a multiple of 16\n"); std::exit(1); } + gather_rows_kernel<<<48 * 8, 256, 0, (cudaStream_t) stream>>>((const uint4*) src, row_bytes / 16, ids, n, (uint4*) dst); + check("gather_rows"); +} + +void map_ids(int32_t* ids, const int32_t* table, int n, void* stream) { + map_ids_kernel<<<1, 64, 0, (cudaStream_t) stream>>>(ids, table, n); + check("map_ids"); +} + +void row_top_prob(const float* logits, int n_rows, int n_vocab, const int32_t* ids, float* probs, void* stream) { + row_top_prob_kernel<<>>(logits, n_vocab, ids, probs); + check("row_top_prob"); +} + +namespace { +__global__ void window_ids_kernel(int32_t* steps, int window, int32_t* ids, long long stride) { + const int q = blockIdx.y; + int32_t* st = steps + q * 4; + const int n_kv = st[1]; + const int start = n_kv > window ? n_kv - window : 0; + const int width = n_kv - start; + for (int j = blockIdx.x * blockDim.x + threadIdx.x; j < width; j += gridDim.x * blockDim.x) + ids[q * stride + j] = start + j; + __syncthreads(); + if (blockIdx.x == 0 && threadIdx.x == 0) st[3] = width; +} +} // namespace + +void window_ids(int32_t* steps, int n, int window, int32_t* ids, int64_t ids_stride, void* stream) { + window_ids_kernel<<>>(steps, window, ids, (long long) ids_stride); + check("window_ids"); +} + +void dense_steps(const int32_t* cells, int n, int32_t* steps, void* stream) { + dense_steps_kernel<<<1, 64, 0, (cudaStream_t) stream>>>(cells, n, steps); + check("dense_steps"); +} + +void gdn_conv_l2_multi(const float* history, const float* qkv, const float* conv_w, float* h, int channels, + int qk_heads, float eps, int n_tok, void* stream, int t_begin) { + if (!history || !qkv || !conv_w || !h || channels % S != 0 || n_tok < 1 || n_tok > kVerifyMaxT) { + std::fprintf(stderr, "gdn_conv_l2_multi: invalid arguments\n"); + std::exit(1); + } + gdn_conv_l2_multi_kernel<<>>( + history, qkv, conv_w, h, channels, qk_heads, eps, t_begin); + check("gdn_conv_l2_multi"); +} + +void gdn_conv_commit(float* history, const float* qkv, int channels, const int32_t* n_keep, void* stream) { + gdn_conv_commit_kernel<<<(unsigned) ((channels + 255) / 256), 256, 0, (cudaStream_t) stream>>>(history, qkv, + channels, n_keep); + check("gdn_conv_commit"); +} + +void gdn_ab_multi(const float* x, const uint16_t* w_alpha, const uint16_t* w_beta, const float* dt, const float* ssm_a, + float* gate, float* beta, int n_embd, int h_v, int n_tok, void* stream) { + if (n_embd % 8 != 0 || n_tok < 1 || n_tok > kVerifyMaxT) { + std::fprintf(stderr, "gdn_ab_multi: invalid arguments\n"); + std::exit(1); + } + gdn_ab_multi_kernel<<<(unsigned) ((2 * h_v + 7) / 8), 256, 0, (cudaStream_t) stream>>>( + x, w_alpha, w_beta, dt, ssm_a, gate, beta, n_embd, h_v, n_tok); + check("gdn_ab_multi"); +} + +void gdn_step_norm_multi(float* state, const float* h, int conv_channels, const float* gate, const float* beta, + const float* z, const float* gamma, float eps, float* y, int h_k, int h_v, int n_tok, + const int32_t* n_keep, void* stream, int t_out_begin) { + if (!state || !h || !gate || !beta || !z || !gamma || !y || h_k <= 0 || h_v % h_k || n_tok < 1 || + n_tok > kVerifyMaxT) { + std::fprintf(stderr, "gdn_step_norm_multi: invalid arguments\n"); + std::exit(1); + } + gdn_step_norm_multi_kernel<<<(unsigned) h_v, dim3(S, RG), 0, (cudaStream_t) stream>>>( + state, h, conv_channels, gate, beta, z, gamma, eps, y, h_k, h_v, n_tok, n_keep, t_out_begin); + check("gdn_step_norm_multi"); +} + +namespace { +__global__ void wait_flag_ge_kernel(const volatile uint32_t* flag, uint32_t value) { + while (*flag < value) __nanosleep(100); + __threadfence_system(); +} +} // namespace + +void wait_flag_ge(const uint32_t* flag, uint32_t value, void* stream) { + wait_flag_ge_kernel<<<1, 1, 0, (cudaStream_t) stream>>>(flag, value); + check("wait_flag_ge"); +} + +void embedding_gather_dev(const uint8_t* codes, const float* scales, const float* offsets, const int32_t* tokens, + int n_tok, int64_t n, int code_bits, int code_bias, int group_elems, uint64_t row_codes, + uint64_t row_groups, float* out, void* stream) { + embedding_gather_dev_kernel<<>>(codes, scales, offsets, tokens, n, code_bits, code_bias, + group_elems, row_codes, row_groups, out); + check("embedding_gather_dev"); +} + +void broadcast_streams(const float* x, float* R, int64_t n_embd, int hc, int n_tok, void* stream) { + broadcast_streams_kernel<<>>(x, R, n_embd, hc); + check("broadcast_streams"); +} + +void copy_indexed(float* dst, const float* src, int64_t stride, const int32_t* index, int64_t n, void* stream) { + const unsigned blocks = (unsigned) ((n + 255) / 256 < 64 ? (n + 255) / 256 : 64); + copy_indexed_kernel<<>>(dst, src, stride, index, n); + check("copy_indexed"); +} + +} // namespace strata::kernels diff --git a/src/kernels/dequant_bf16_test.cpp b/src/kernels/dequant_bf16_test.cpp new file mode 100644 index 00000000..80d3d53e --- /dev/null +++ b/src/kernels/dequant_bf16_test.cpp @@ -0,0 +1,108 @@ +// src/kernels/dequant_bf16_test.cpp - plan v0.3 P5: the device dequantizers against the artifact's validated CPU +// dequantizers (`strata/artifact/dequant.hpp`), on one real tensor of every type the model uses. +// +// dequant_bf16_test SHARD1.gguf [SHARD2.gguf] +// +// For each type: the first 4 rows of the first tensor of that type, FP32 path compared with a relative tolerance of +// 1e-6 (the products are the reference's, reordered at most), BF16 path within half a BF16 ulp of the CPU value. +#include "strata/artifact/dequant.hpp" +#include "strata/artifact/gguf_reader.hpp" +#include "strata/kernels/dequant_bf16.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +bool cpu_block(int type, const uint8_t* b, float* out) { + using namespace strata; + switch (type) { + case 2: dequantize_q4_0(b, out); return true; + case 6: dequantize_q5_0(b, out); return true; + case 8: dequantize_q8_0(b, out); return true; + case 11: dequantize_q3_K(b, out); return true; + case 12: dequantize_q4_K(b, out); return true; + case 13: dequantize_q5_K(b, out); return true; + case 14: dequantize_q6_K(b, out); return true; + case 20: dequantize_iq4_nl(b, out); return true; + case 23: dequantize_iq4_xs(b, out); return true; + case 42: dequantize_q2_0(b, out); return true; + default: return false; + } +} + +float bf16_to_f32(uint16_t h) { + uint32_t u = (uint32_t) h << 16; + float f; + std::memcpy(&f, &u, 4); + return f; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: dequant_bf16_test SHARD.gguf [SHARD2.gguf]\n"); + return 2; + } + std::map done; + int fails = 0, checked = 0; + for (int a = 1; a < argc; ++a) { + strata::GgufFile gguf(argv[a]); + for (const auto& t : gguf.tensors()) { + const int type = (int) t.type; + if (done.count(type) || !strata::kernels::dequant_bf16_supported(type) || t.shape.size() != 2) continue; + int be = 0, bb = 0; + if (!strata::block_geometry(t.type, be, bb)) continue; + const int64_t cols = (int64_t) t.shape[0]; + const int64_t rows = (std::min)(4, (int64_t) t.shape[1]); + const int64_t row_bytes = cols / be * bb; + const uint8_t* host = (const uint8_t*) gguf.tensor_data(t); + void* d_blocks = nullptr; + float* d_f = nullptr; + uint16_t* d_h = nullptr; + cudaMalloc(&d_blocks, (size_t) (rows * row_bytes)); + cudaMalloc(&d_f, (size_t) (rows * cols) * 4); + cudaMalloc(&d_h, (size_t) (rows * cols) * 2); + cudaMemcpy(d_blocks, host, (size_t) (rows * row_bytes), cudaMemcpyHostToDevice); + strata::kernels::dequant_f32(type, d_blocks, 0, rows, cols, d_f, nullptr); + strata::kernels::dequant_bf16(type, d_blocks, 0, rows, cols, d_h, nullptr); + std::vector gf((size_t) (rows * cols)); + std::vector gh((size_t) (rows * cols)); + cudaMemcpy(gf.data(), d_f, gf.size() * 4, cudaMemcpyDeviceToHost); + cudaMemcpy(gh.data(), d_h, gh.size() * 2, cudaMemcpyDeviceToHost); + cudaFree(d_blocks); + cudaFree(d_f); + cudaFree(d_h); + std::vector ref((size_t) (rows * cols)); + for (int64_t r = 0; r < rows; ++r) + for (int64_t b = 0; b < cols / be; ++b) + cpu_block(type, host + r * row_bytes + b * bb, ref.data() + r * cols + b * be); + int64_t bad_f = 0, bad_h = 0; + double worst = 0; + for (size_t i = 0; i < ref.size(); ++i) { + const double e = std::fabs((double) gf[i] - ref[i]); + const double tol = 1e-6 * std::fabs((double) ref[i]) + 1e-30; + if (e > tol) ++bad_f; + worst = (std::max)(worst, e / (std::fabs((double) ref[i]) + 1e-30)); + const double eh = std::fabs((double) bf16_to_f32(gh[i]) - ref[i]); + if (eh > std::ldexp(std::fabs((double) ref[i]), -8) + 1e-30) ++bad_h; + } + ++checked; + done[type] = true; + const bool ok = bad_f == 0 && bad_h == 0; + if (!ok) ++fails; + std::printf("type %2d %-40s %lld x %lld: f32 %s (%lld off, worst rel %.2e), bf16 %s (%lld off)\n", type, + t.name.c_str(), (long long) rows, (long long) cols, bad_f ? "FAIL" : "ok", (long long) bad_f, + worst, bad_h ? "FAIL" : "ok", (long long) bad_h); + } + } + std::printf("dequant_bf16_test: %d types checked, %s\n", checked, fails ? "FAILED" : "OK"); + return fails ? 1 : 0; +} diff --git a/src/kernels/dequant_s2_parity.cpp b/src/kernels/dequant_s2_parity.cpp new file mode 100644 index 00000000..58e39705 --- /dev/null +++ b/src/kernels/dequant_s2_parity.cpp @@ -0,0 +1,143 @@ +// src/kernels/dequant_s2_parity.cpp - P2.S2's parity test for the S2 decode. +// +// THE REFERENCE IS THE CHAIN, NOT A SECOND OPINION. `strata::dequantize_q2_0` (include/strata/artifact/ +// dequant.hpp) is a scalar transcription that `bench/micro/dequant_xcheck` checks against **ggml's own** +// dequantizer on real block bytes; and `tools/canonical_xcheck.py --full` checks that the canonical form +// decodes to the same values on every block of the artifact. So this compares "GPU canonical decode" against +// "CPU decode already proven equal to ggml", and a fault in either the canonicaliser or the kernel shows up +// here. A parity test whose two sides were written together would show neither. +#include "strata/artifact/dequant.hpp" +#include "strata/kernels/dequant_s2.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr int QK = 64; + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +// fp16 patterns that are finite, normal and exactly representable. Random 16-bit patterns would include NaN +// and infinity, and a NaN makes `!=` true for a reason that has nothing to do with the kernel - the test +// would fail for the wrong cause, or worse, a real fault would hide behind a NaN it caused itself. +const uint16_t kScales[] = {0x3C00, 0x3800, 0x3400, 0x3000, 0x2C00, 0x2800, 0x2000, + 0xBC00, 0xB800, 0xB400, 0xB000, 0xAC00, 0x1800, 0x1400}; + +} // namespace + +int main(int argc, char** argv) { + long long n_blocks = 200000; + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--selftest") == 0) selftest = true; + else if (std::strcmp(argv[i], "--blocks") == 0 && i + 1 < argc) n_blocks = std::atoll(argv[++i]); + else { + std::fprintf(stderr, "usage: dequant_s2_parity [--selftest] [--blocks N]\n"); + return 2; + } + } + if (n_blocks <= 0) { std::fprintf(stderr, "--blocks must be positive\n"); return 2; } + + std::mt19937 rng(12345); + const int n_scales = (int) (sizeof(kScales) / sizeof(kScales[0])); + + // raw Q2_0 blocks: { fp16 d ; uint8 qs[16] } - what ggml produced and what the artifact stores + std::vector raw((size_t) n_blocks * 18); + for (long long b = 0; b < n_blocks; ++b) { + const uint16_t d = kScales[rng() % n_scales]; + raw[(size_t) b * 18 + 0] = (uint8_t) (d & 0xFF); + raw[(size_t) b * 18 + 1] = (uint8_t) (d >> 8); + for (int t = 0; t < 16; ++t) raw[(size_t) b * 18 + 2 + t] = (uint8_t) (rng() & 0xFF); + } + + // the CPU reference, over the RAW blocks + std::vector cpu((size_t) n_blocks * QK); + for (long long b = 0; b < n_blocks; ++b) { + strata::dequantize_q2_0(&raw[(size_t) b * 18], &cpu[(size_t) b * QK]); + } + + // canonical planes: S2 packs 4 codes per byte exactly as Q2_0 stores them, so the codes plane is the raw + // qs bytes; the scales plane is the fp16 `d` widened. Copied into planes of their own rather than + // aliased, because that is what the kernel will read in the engine. + std::vector codes((size_t) n_blocks * 16); + std::vector scales((size_t) n_blocks); + for (long long b = 0; b < n_blocks; ++b) { + std::memcpy(&codes[(size_t) b * 16], &raw[(size_t) b * 18 + 2], 16); + const uint16_t d = (uint16_t) (raw[(size_t) b * 18] | (raw[(size_t) b * 18 + 1] << 8)); + scales[(size_t) b] = strata::fp16_to_fp32(d); + } + + uint8_t* d_codes = nullptr; + float *d_scales = nullptr, *d_out = nullptr; + check(cudaMalloc(&d_codes, codes.size()), "cudaMalloc codes"); + check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "cudaMalloc scales"); + check(cudaMalloc(&d_out, cpu.size() * sizeof(float)), "cudaMalloc out"); + check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "copy codes"); + check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice), + "copy scales"); + + strata::kernels::dequant_s2(d_codes, d_scales, d_out, n_blocks); + + std::vector gpu(cpu.size()); + check(cudaMemcpy(gpu.data(), d_out, gpu.size() * sizeof(float), cudaMemcpyDeviceToHost), "copy back"); + + long long bad = 0, first_bad = -1; + for (size_t i = 0; i < cpu.size(); ++i) { + // bit comparison, not a tolerance: S2's decode is exact integer-to-float arithmetic, so ANY + // difference is a bug and a tolerance would only hide which one + if (std::memcmp(&cpu[i], &gpu[i], sizeof(float)) != 0) { + if (first_bad < 0) first_bad = (long long) i; + ++bad; + } + } + + // NON-VACUITY. Two buffers of zeros compare equal, and so do two buffers a broken test filled with the + // same constant - an agreement check that cannot distinguish "both correct" from "both empty" is the + // failure this project keeps finding. So the data must actually vary, and the four codes must all occur: + // an S2 decode that only ever saw one code would agree while being unable to tell -1 from +2. + bool seen[4] = {false, false, false, false}; + for (size_t i = 0; i < codes.size(); ++i) { + for (int t = 0; t < 4; ++t) seen[(codes[i] >> (2 * t)) & 3] = true; + } + int distinct = 0; + for (bool s : seen) distinct += s ? 1 : 0; + long long distinct_vals = 0; + for (size_t i = 0; i < cpu.size() && distinct_vals < 8; ++i) { + bool dup = false; + for (size_t j = 0; j < i && !dup; ++j) dup = (cpu[j] == cpu[i]); + if (!dup) ++distinct_vals; + } + std::printf(" non-vacuity: %d of 4 codes present, %lld distinct values in the first elements\n", distinct, + distinct_vals); + if (distinct != 4 || distinct_vals < 4) { + std::fprintf(stderr, "VACUOUS: the comparison could not have distinguished a wrong decode\n"); + return 1; + } + + std::printf("dequant_s2: %lld blocks, %lld elements, %lld mismatched\n", n_blocks, (long long) cpu.size(), + bad); + if (bad) { + std::printf(" first mismatch at element %lld (block %lld, offset %d): cpu %.9g gpu %.9g\n", + (long long) first_bad, (long long) (first_bad / QK), (int) (first_bad % QK), + (double) cpu[(size_t) first_bad], (double) gpu[(size_t) first_bad]); + return 1; + } + std::printf(" bit-exact against the scalar dequantizer that dequant_xcheck proved equal to ggml\n"); + if (selftest) std::printf("dequant_s2_parity OK\n"); + + cudaFree(d_codes); + cudaFree(d_scales); + cudaFree(d_out); + return 0; +} diff --git a/src/kernels/elementwise_parity.cpp b/src/kernels/elementwise_parity.cpp new file mode 100644 index 00000000..58a2c36d --- /dev/null +++ b/src/kernels/elementwise_parity.cpp @@ -0,0 +1,416 @@ +// src/kernels/elementwise_parity.cpp - P2.S5's test for the layer glue. +// +// The ops are small; the CONVENTIONS in them are not, and each one is a place where a plausible reading +// gives a plausible number: +// +// 1. `softplus` HAS A LARGE-x BRANCH. `log1p(exp(x))` overflows f32 at x > 88 and loses relative precision +// well before that; `ggml_compute_softplus_f32` returns `x` above 20. A rival without the branch is +// asserted observable, because a gate that saturates to +inf makes `exp(gate)` inf and the whole +// recurrence NaN - which reads as a state bug and not as a missing branch. +// 2. `ssm_a` IS NEGATIVE. `gate = softplus(...) * ssm_a`, so `exp(gate) < 1` and the state DECAYS. A +// fixture with a positive `ssm_a` would still produce finite output and the recurrence would blow up +// instead of decaying, so the sign is checked as a property and not assumed. +// 3. `silu` IS COMPUTED IN DOUBLE then cast, because `ref/gdn.py`'s numpy does. f32 `expf` differs in the +// last bits, and the test measures that rather than asserting it away. +#include "strata/kernels/elementwise.hpp" +#include "strata/kernels/f16_bits.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +double rel_l1(const std::vector& a, const std::vector& b) { + double d = 0, m = 0; + for (size_t i = 0; i < a.size(); ++i) { + d += std::fabs((double) a[i] - (double) b[i]); + m += std::fabs((double) a[i]); + } + return d / (m > 1e-30 ? m : 1e-30); +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: elementwise_parity [--selftest]\n"); return 2; } + } + int bad = 0; + const int64_t H_V = 48; + + // ---- 1. gdn_gate, with the reference's own softplus + { + const int n = (int) H_V; + std::mt19937 rng(11); + std::normal_distribution g(0.0f, 1.0f); + std::vector alpha(n), dt(n), a(n), want(n); + for (int i = 0; i < n; ++i) { + // A MIXTURE THAT ACTUALLY CROSSES THE BRANCH. The first version drew alpha from a normal with + // sigma 3, so the largest value was about 9 and `softplus` never took its `x > 20` path - the + // "branch is observable" check then reported **0.00% apart, 0 non-finite**, which is the fixture + // saying it cannot see the thing it was written to see. Every third head is now large. + const bool large = (i % 3) == 0; + // Up to ~120, so the fixture spans WELL PAST f32's `exp` overflow at 88. A fixture that stopped + // at 88 would show the branch as unobservable and would be right: `log1pf(expf(x))` equals `x` to + // f32 precision for the whole range 20..88, so ggml's threshold of 20 is CONSERVATIVE and the + // behaviour only actually changes where `expf` overflows. + alpha[i] = large ? (22.0f + 6.0f * (float) (i % 17)) : g(rng) * 3.0f; + dt[i] = g(rng) * 0.5f; + // NEGATIVE, as the artifact's `ssm_a = -exp(A_log)` is. Checked below as a property. + a[i] = -(std::fabs(g(rng)) + 0.1f); + const double x = (double) alpha[i] + (double) dt[i]; + const double sp = x > 20.0 ? x : std::log1p(std::exp(x)); + want[(size_t) i] = (float) (sp * (double) a[i]); + } + // the fixture must EXERCISE the branch, or check 1 below measures nothing + { + int over = 0, past_overflow = 0; + for (int i = 0; i < n; ++i) { + const float x = alpha[(size_t) i] + dt[(size_t) i]; + if (x > 20.0f) ++over; + if (x > 88.0f) ++past_overflow; + } + std::printf(" %-44s %s (%d above 20, %d above 88)\n", "the fixture crosses the branch", + over ? "yes" : "*** NO ***", over, past_overflow); + if (!over) ++bad; + } + // the sign property, asserted rather than assumed + int positive = 0; + for (int i = 0; i < n; ++i) if (a[(size_t) i] >= 0.0f) ++positive; + std::printf(" %-44s %s (%d of %d non-negative)\n", "the fixture's ssm_a is negative", + positive ? "*** NO ***" : "yes", positive, n); + if (positive) ++bad; + + float *d_a = nullptr, *d_dt = nullptr, *d_sa = nullptr, *d_g = nullptr; + check(cudaMalloc(&d_a, n * 4), "a"); + check(cudaMalloc(&d_dt, n * 4), "dt"); + check(cudaMalloc(&d_sa, n * 4), "sa"); + check(cudaMalloc(&d_g, n * 4), "g"); + check(cudaMemcpy(d_a, alpha.data(), n * 4, cudaMemcpyHostToDevice), "ca"); + check(cudaMemcpy(d_dt, dt.data(), n * 4, cudaMemcpyHostToDevice), "cd"); + check(cudaMemcpy(d_sa, a.data(), n * 4, cudaMemcpyHostToDevice), "cs"); + strata::kernels::gdn_gate(d_a, d_dt, d_sa, d_g, 1, H_V, nullptr); + std::vector got((size_t) n); + check(cudaMemcpy(got.data(), d_g, n * 4, cudaMemcpyDeviceToHost), "cg"); + + const double rel = rel_l1(want, got); + std::printf(" %-44s rel %.3e\n", "gdn_gate vs the reference", rel); + // double softplus and double multiply on both sides, then one cast + if (!(rel <= 1e-6)) { std::printf(" *** over 1e-6 ***\n"); ++bad; } + + // TRAP: no large-x branch. `log1p(exp(x))` in f32, with no `x > 20` path. + // + // AND THE MEASUREMENT SAYS WHERE THE BRANCH ACTUALLY MATTERS. The first version of this fixture + // spanned 20..88 and reported **0 heads differ**: `log1pf(expf(x))` agrees with `x` to f32 precision + // over that whole range, so ggml's threshold of 20 is CONSERVATIVE - the branch changes the answer only + // where `expf` OVERFLOWS, at about 88. The check reports the smallest x at which the two readings + // part company, so the number is in the output rather than in this comment. + { + int wrong = 0, differing = 0; + float first_differ = -1.0f; + std::vector rival((size_t) n); + for (int i = 0; i < n; ++i) { + const float x = alpha[(size_t) i] + dt[(size_t) i]; + const float sp = std::log1p(std::exp(x)); // no branch, f32 + rival[(size_t) i] = sp * a[(size_t) i]; + if (!std::isfinite(sp)) ++wrong; + const float branched = x > 20.0f ? x : std::log1pf(std::exp(x)); + if (sp != branched) { + ++differing; + if (first_differ < 0.0f || x < first_differ) first_differ = x; + } + } + const double r = rel_l1(want, rival); + const bool visible = r > 0.05 || wrong > 0; + std::printf(" %-44s %s (%.2f%% apart, %d non-finite, %d heads differ, first at x = %.1f)\n", + "the softplus large-x branch is observable", visible ? "yes" : "*** NO ***", + r * 100, wrong, differing, (double) first_differ); + if (!visible) ++bad; + } + // PROPERTY: exp(gate) < 1 for every element, which is what makes the state decay + { + int over = 0; + for (float v : got) if (!(std::exp(v) < 1.0f)) ++over; + std::printf(" %-44s %s (%d of %d not < 1)\n", "exp(gate) < 1 for every head", + over ? "*** NO ***" : "yes", over, n); + if (over) ++bad; + } + cudaFree(d_a); cudaFree(d_dt); cudaFree(d_sa); cudaFree(d_g); + } + + // ---- 2. silu, in double then cast + { + const int n = 4096; + std::mt19937 rng(22); + std::normal_distribution g(0.0f, 3.0f); + std::vector x((size_t) n), want((size_t) n); + for (int i = 0; i < n; ++i) { + x[(size_t) i] = g(rng); + const double v = (double) x[(size_t) i]; + want[(size_t) i] = (float) (v / (1.0 + std::exp(-v))); + } + float* d_x = nullptr; + check(cudaMalloc(&d_x, n * 4), "sx"); + check(cudaMemcpy(d_x, x.data(), n * 4, cudaMemcpyHostToDevice), "csx"); + strata::kernels::silu_inplace(d_x, n, nullptr); + std::vector got((size_t) n); + check(cudaMemcpy(got.data(), d_x, n * 4, cudaMemcpyDeviceToHost), "csg"); + const double rel = rel_l1(want, got); + std::printf("\n %-44s rel %.3e\n", "silu (double) vs the reference", rel); + if (!(rel <= 1e-7)) { std::printf(" *** over 1e-7 ***\n"); ++bad; } + cudaFree(d_x); + } + + // ---- 3. scale and the f32->f16 bridge + { + const int n = 1024; + std::vector x((size_t) n), want((size_t) n); + for (int i = 0; i < n; ++i) x[(size_t) i] = (float) (i - n / 2) * 0.013f; + const float s = 1.0f / std::sqrt(128.0f); + for (int i = 0; i < n; ++i) want[(size_t) i] = x[(size_t) i] * s; + + float* d_x = nullptr; + uint16_t* d_h = nullptr; + check(cudaMalloc(&d_x, n * 4), "ex"); + check(cudaMalloc(&d_h, n * 2), "eh"); + check(cudaMemcpy(d_x, x.data(), n * 4, cudaMemcpyHostToDevice), "cex"); + strata::kernels::scale_inplace(d_x, n, s, nullptr); + std::vector got((size_t) n); + check(cudaMemcpy(got.data(), d_x, n * 4, cudaMemcpyDeviceToHost), "ceg"); + int diff = 0; + for (int i = 0; i < n; ++i) if (got[(size_t) i] != want[(size_t) i]) ++diff; + std::printf(" %-44s %d of %d differ\n", "scale_inplace is exact", diff, n); + if (diff) ++bad; + + strata::kernels::f32_to_f16_bulk(d_x, d_h, n, nullptr); + std::vector h((size_t) n); + check(cudaMemcpy(h.data(), d_h, n * 2, cudaMemcpyDeviceToHost), "ceh"); + int hbad = 0; + for (int i = 0; i < n; ++i) + if (h[(size_t) i] != strata::kernels::f16_from_f32(got[(size_t) i])) ++hbad; + std::printf(" %-44s %d of %d differ\n", "f32_to_f16_bulk uses the shared conversion", hbad, n); + if (hbad) ++bad; + cudaFree(d_x); cudaFree(d_h); + } + + // ---- 4. rms_norm_weighted, and the TWO RIVAL READINGS it exists to be told apart from. + // + // This kernel's whole reason for being a separate entry point from `gdn_l2_norm` is that the two differ by + // one `/ cols`, and that a swap produces a well-scaled, plausible tensor. So the test computes BOTH wrong + // readings and requires each to differ from the right one by more than the tolerance before it judges the + // kernel - the same discipline the shared-expert test uses for silu-on-gate. + { + const int64_t rows = 24, cols = 256; // the real `attn_q`-norm shape + const float eps = 1e-6f; + std::vector x((size_t) (rows * cols)); + for (size_t i = 0; i < x.size(); ++i) x[i] = (float) std::sin((double) i * 0.017) * 1.7f + 0.4f; + std::vector w((size_t) cols); + for (int64_t c = 0; c < cols; ++c) w[(size_t) c] = 0.5f + 0.002f * (float) (c % 97); + + // the oracle: f64, mean, times w + auto ref = [&](bool use_sum, bool use_w) { + std::vector y((size_t) (rows * cols)); + for (int64_t r = 0; r < rows; ++r) { + double ss = 0; + for (int64_t c = 0; c < cols; ++c) { + const double v = x[(size_t) (r * cols + c)]; + ss += v * v; + } + const double den = std::sqrt((use_sum ? ss : ss / (double) cols) + (double) eps); + for (int64_t c = 0; c < cols; ++c) { + const double v = x[(size_t) (r * cols + c)]; + y[(size_t) (r * cols + c)] = (v / den) * (use_w ? (double) w[(size_t) c] : 1.0); + } + } + std::vector out((size_t) (rows * cols)); + for (size_t i = 0; i < out.size(); ++i) out[i] = (float) y[i]; + return out; + }; + const std::vector want = ref(false, true); + const std::vector rival_sum = ref(true, false); // what gdn_l2_norm computes + const std::vector rival_now = ref(false, false); // the (1+w)-vs-w / no-weight confusion + + auto rel = [&](const std::vector& a, const std::vector& b) { + double d = 0, m = 0; + for (size_t i = 0; i < a.size(); ++i) { + d += std::fabs((double) a[i] - (double) b[i]); + m += std::fabs((double) a[i]); + } + return d / (m > 1e-30 ? m : 1e-30); + }; + const double r_sum = rel(want, rival_sum), r_now = rel(want, rival_now); + std::printf(" %-44s %.2f%% apart\n", "sum-vs-mean is observable", r_sum * 100); + std::printf(" %-44s %.2f%% apart\n", "with-w-vs-without-w is observable", r_now * 100); + // `sqrt(cols)` = 16 for the first and the weight's own spread for the second; a fixture that cannot + // separate them is not testing the kernel that was written. + if (!(r_sum > 0.5)) { std::printf(" *** the sum reading is NOT observable ***\n"); ++bad; } + if (!(r_now > 0.5)) { std::printf(" *** the no-weight reading is NOT observable ***\n"); ++bad; } + + float* d_x = nullptr; + float* d_w = nullptr; + check(cudaMalloc(&d_x, x.size() * 4), "m rmsx"); + check(cudaMalloc(&d_w, w.size() * 4), "m rmsw"); + check(cudaMemcpy(d_x, x.data(), x.size() * 4, cudaMemcpyHostToDevice), "c rmsx"); + check(cudaMemcpy(d_w, w.data(), w.size() * 4, cudaMemcpyHostToDevice), "c rmsw"); + strata::kernels::rms_norm_weighted(d_x, d_w, rows, cols, eps, nullptr); + std::vector got((size_t) (rows * cols)); + check(cudaMemcpy(got.data(), d_x, got.size() * 4, cudaMemcpyDeviceToHost), "c rmsg"); + + const double r_got = rel(want, got); + double worst = 0; + for (size_t i = 0; i < got.size(); ++i) { + const double m = std::fabs((double) want[i]); + const double e = std::fabs((double) got[i] - (double) want[i]) / (m > 1e-30 ? m : 1e-30); + if (e > worst) worst = e; + } + int nonfinite = 0; + for (float v : got) if (!std::isfinite(v)) ++nonfinite; + std::printf(" %-44s rel %.3e worst %.3e nonfinite %d\n", "rms_norm_weighted vs f64 oracle", r_got, + worst, nonfinite); + if (nonfinite) ++bad; + // The kernel accumulates in f32 over 256 terms; 1e-6 is loose enough for that and far tighter than the + // 16x the rival readings are off by. + if (!(r_got < 1e-6)) { std::printf(" *** rms_norm_weighted is WRONG ***\n"); ++bad; } + // and a null `w` must be legal, because `ref/qsa.py` allows it. + // + // THE INPUT HAS TO BE RE-UPLOADED FIRST. The kernel is IN PLACE, so `d_x` currently holds the + // NORMALISED tensor from the call above; running it again normalises a second time and every element + // differs. That is exactly what the first version of this check reported - "6144 of 6144 differ" - + // and it is a fixture bug rather than a kernel bug: the oracle reads the ORIGINAL `x`, so the two + // sides were never looking at the same input. + const std::vector want_null = ref(false, false); + check(cudaMemcpy(d_x, x.data(), x.size() * 4, cudaMemcpyHostToDevice), "c rmsx2"); + strata::kernels::rms_norm_weighted(d_x, nullptr, rows, cols, eps, nullptr); + check(cudaMemcpy(got.data(), d_x, got.size() * 4, cudaMemcpyDeviceToHost), "c rmsn"); + int null_bad = 0; + for (size_t i = 0; i < got.size(); ++i) + if (std::fabs((double) got[i] - (double) want_null[i]) > 1e-6) ++null_bad; + std::printf(" %-44s %d of %zu differ\n", "a null weight is legal", null_bad, got.size()); + if (null_bad) ++bad; + cudaFree(d_x); + cudaFree(d_w); + } + + // Packed embedding rows: every code width, scale-group size, optional offset, + // row boundary and partial CUDA block. Compare bits with a scalar CPU decoder. + // Volatile materializes the multiply so this oracle cannot silently use FMA. + { + cudaStream_t stream; + check(cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking), "embedding stream"); + std::mt19937 rng(77); + std::uniform_real_distribution values(-2.0f, 2.0f); + int mismatches = 0, guards = 0, fma_diff = 0, cases = 0; + for (const int bits : {2, 4, 8}) { + const int bias = bits == 2 ? -1 : bits == 4 ? -7 : -16; + const int per_byte = 8 / bits; + const unsigned mask = (1u << bits) - 1u; + for (const int group : {16, 32, 64}) { + for (const int n : {320, 2560}) { + const int row_bytes = n / per_byte, row_groups = n / group; + std::vector codes((size_t) 3 * row_bytes, 0); + std::vector scales((size_t) 3 * row_groups), offsets(scales.size()); + std::vector unpacked((size_t) 3 * n); + for (size_t i = 0; i < unpacked.size(); ++i) { + const unsigned code = (unsigned) (i * 13 + i / n * 7) & mask; + unpacked[i] = (int) code; + codes[i / per_byte] |= (uint8_t) (code << ((i % per_byte) * bits)); + } + for (size_t i = 0; i < scales.size(); ++i) { + scales[i] = values(rng); + offsets[i] = values(rng); + } + scales[0] = -0.0f; + offsets[0] = 0.0f; + uint8_t* dc = nullptr; + float *ds = nullptr, *dof = nullptr, *out = nullptr; + check(cudaMalloc(&dc, codes.size()), "embedding codes"); + check(cudaMalloc(&ds, scales.size() * sizeof(float)), "embedding scales"); + check(cudaMalloc(&dof, offsets.size() * sizeof(float)), "embedding offsets"); + check(cudaMalloc(&out, ((size_t) n + 2) * sizeof(float)), "embedding output"); + check(cudaMemcpyAsync(dc, codes.data(), codes.size(), cudaMemcpyHostToDevice, stream), "embedding codes upload"); + check(cudaMemcpyAsync(ds, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice, stream), "embedding scales upload"); + check(cudaMemcpyAsync(dof, offsets.data(), offsets.size() * sizeof(float), cudaMemcpyHostToDevice, stream), "embedding offsets upload"); + check(cudaStreamSynchronize(stream), "embedding upload sync"); + + for (const bool with_offset : {false, true}) { + for (int row = 0; row < 3; ++row) { + std::vector want((size_t) n), got((size_t) n + 2, -12345.0f); + for (int i = 0; i < n; ++i) { + const size_t gi = (size_t) row * row_groups + i / group; + const float code = (float) (unpacked[(size_t) row * n + i] + bias); + volatile float product = code * scales[gi]; + const float offset = with_offset ? offsets[gi] : 0.0f; + want[(size_t) i] = product + offset; + const float fused = std::fma(code, scales[gi], offset); + if (std::memcmp(&fused, &want[(size_t) i], sizeof(float)) != 0) ++fma_diff; + } + check(cudaMemcpyAsync(out, got.data(), got.size() * sizeof(float), cudaMemcpyHostToDevice, stream), "embedding output guards"); + strata::kernels::embedding_gather(dc + (size_t) row * row_bytes, + ds + (size_t) row * row_groups, + with_offset ? dof + (size_t) row * row_groups : nullptr, + n, bits, bias, group, out + 1, stream); + check(cudaMemcpyAsync(got.data(), out, got.size() * sizeof(float), cudaMemcpyDeviceToHost, stream), "embedding result"); + check(cudaStreamSynchronize(stream), "embedding sync"); + for (int i = 0; i < n; ++i) { + if (std::memcmp(&want[(size_t) i], &got[(size_t) i + 1], sizeof(float)) != 0) ++mismatches; + } + if (got.front() != -12345.0f || got.back() != -12345.0f) ++guards; + ++cases; + + // Capturing the gather proves that it has no hidden synchronization. + // A following scale also checks that it uses the caller's stream. + if (row == 1) { + cudaGraph_t graph; + cudaGraphExec_t exec; + check(cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal), "embedding capture"); + strata::kernels::embedding_gather(dc + (size_t) row * row_bytes, + ds + (size_t) row * row_groups, + with_offset ? dof + (size_t) row * row_groups : nullptr, + n, bits, bias, group, out + 1, stream); + strata::kernels::scale_inplace(out + 1, n, 2.0f, stream); + check(cudaStreamEndCapture(stream, &graph), "embedding capture end"); + check(cudaGraphInstantiate(&exec, graph, 0), "embedding instantiate"); + check(cudaGraphLaunch(exec, stream), "embedding replay"); + check(cudaMemcpyAsync(got.data(), out, got.size() * sizeof(float), cudaMemcpyDeviceToHost, stream), "embedding graph result"); + check(cudaStreamSynchronize(stream), "embedding graph sync"); + for (int i = 0; i < n; ++i) { + const float expected = want[(size_t) i] * 2.0f; + if (std::memcmp(&expected, &got[(size_t) i + 1], sizeof(float)) != 0) ++mismatches; + } + if (got.front() != -12345.0f || got.back() != -12345.0f) ++guards; + check(cudaGraphExecDestroy(exec), "embedding exec destroy"); + check(cudaGraphDestroy(graph), "embedding graph destroy"); + } + } + } + cudaFree(dc); cudaFree(ds); cudaFree(dof); cudaFree(out); + } + } + } + check(cudaStreamDestroy(stream), "embedding stream destroy"); + std::printf(" embedding gather: %d row cases, %d bit mismatches, %d guard failures, %d FMA differences\n", + cases, mismatches, guards, fma_diff); + if (mismatches || guards || fma_diff == 0) ++bad; + } + + std::printf("\nelementwise: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("elementwise_parity OK\n"); + return 0; +} diff --git a/src/kernels/gdn_parity.cpp b/src/kernels/gdn_parity.cpp new file mode 100644 index 00000000..8d6a3997 --- /dev/null +++ b/src/kernels/gdn_parity.cpp @@ -0,0 +1,397 @@ +// src/kernels/gdn_parity.cpp - P2.S2's test for the GDN recurrence, the conv and the two norms. +// +// `ref/gdn.py` carries eleven PROPERTY checks, several of which exist because a rival reading of the SOURCE +// produces well-formed output. The four that the kernels here can be wrong about are re-pinned below, each +// asserted OBSERVABLE against a deliberately-wrong computation before the kernel is judged: +// +// 1. MODULO head pairing, h % h_k, not INTERLEAVE. With H_k=2 and H_v=4 the two give [0,1,0,1] and +// [0,0,1,1]: they differ at h=1 and h=2. Both produce a full-rank state of the right shape. +// 2. Decay applied BEFORE the rank-1 update. Applying it after is a one-line move that keeps every shape +// and every magnitude. +// 3. The conv's SLIDE DIRECTION. `kernel[0]` must read the OLDEST state row and the new input must land in +// the LAST state row; reversing either is invisible on a symmetric state, so the state rows here are +// labelled so each position is identifiable. +// 4. `l2_norm`'s `+ eps` is an absolute floor on the SQUARED NORM, not on the mean. The mean reading +// differs by sqrt(S) = 11.3x at S = 128. `ref/gdn.py` PROPERTY 1 instruments the derivation, so it is +// not taken on trust there either. +// +// It also checks the STATE LAYOUT, which is the one place these kernels intentionally differ from the +// reference: (S, h_v, S) with j fastest instead of (S, S, h_v). A layout mix-up is silent, so the state is +// filled with a value that encodes its own coordinates. +#include "strata/kernels/gdn.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +float sigmoid_f(float x) { return 1.0f / (1.0f + std::exp(-x)); } + +/// The reference's `gdn_step`, with switches for the rival readings. +/// +/// `state` is passed in the REFERENCE layout (S, S, h_v) - i.e. (i, j, h) - so that this function is a +/// transcription of `ref/gdn.py` and not of the kernel. +void ref_step(std::vector& state, const std::vector& q, const std::vector& k, + const std::vector& v, const std::vector& gate, const std::vector& beta, + int S, int h_k, int h_v, std::vector& o, bool modulo_heads, bool decay_first) { + auto at = [&](int i, int j, int h) -> double& { return state[(size_t) (i * S + j) * h_v + h]; }; + std::vector oo((size_t) h_v * S, 0.0f); + for (int h = 0; h < h_v; ++h) { + const int src = modulo_heads ? (h % h_k) : (h / (h_v / h_k)); + const double dec = std::exp((double) gate[(size_t) h]); + const double b = (double) beta[(size_t) h]; + for (int j = 0; j < S; ++j) { + if (decay_first) for (int i = 0; i < S; ++i) at(i, j, h) *= dec; + double sk = 0; + for (int i = 0; i < S; ++i) sk += at(i, j, h) * (double) k[(size_t) (src * S + i)]; + const double d = ((double) v[(size_t) (h * S + j)] - sk) * b; + for (int i = 0; i < S; ++i) at(i, j, h) += (double) k[(size_t) (src * S + i)] * d; + if (!decay_first) for (int i = 0; i < S; ++i) at(i, j, h) *= dec; + double dot = 0; + for (int i = 0; i < S; ++i) dot += at(i, j, h) * (double) q[(size_t) (src * S + i)]; + oo[(size_t) (h * S + j)] = (float) dot; + } + } + o = oo; +} + +/// `ref/gdn.py::conv_step` in its own (d_conv, C) row-major orientation. +void ref_conv(std::vector& cs, const std::vector& x, const std::vector& kern, int C, + int dc, std::vector& out) { + out.assign((size_t) C, 0.0f); + for (int c = 0; c < C; ++c) { + double acc = 0; + for (int i = 0; i < dc - 1; ++i) acc += (double) cs[(size_t) (i * C + c)] * (double) kern[(size_t) (i * C + c)]; + acc += (double) x[(size_t) c] * (double) kern[(size_t) ((dc - 1) * C + c)]; + out[(size_t) c] = (float) acc; + for (int i = 0; i < dc - 2; ++i) cs[(size_t) (i * C + c)] = cs[(size_t) ((i + 1) * C + c)]; + cs[(size_t) ((dc - 2) * C + c)] = x[(size_t) c]; + } +} + +double rel_l1(const std::vector& a, const std::vector& b) { + double d = 0, m = 0; + for (size_t i = 0; i < a.size(); ++i) { + d += std::fabs((double) a[i] - (double) b[i]); + m += std::fabs((double) a[i]); + } + return d / (m > 1e-30 ? m : 1e-30); +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: gdn_parity [--selftest]\n"); return 2; } + } + + const int S = 128, h_k = 16, h_v = 48; // the artifact's real geometry + std::mt19937 rng(2024); + std::normal_distribution gauss(0.0f, 1.0f); + int bad = 0; + + // ================= 1. the recurrence ================= + { + // The state encodes its own coordinates, so a layout mix-up cannot pass: reading (i,j,h) where the + // buffer holds (i,h,j) gives a DIFFERENT number, not a wrong-but-plausible one. + std::vector st_ref((size_t) S * S * h_v); + std::vector st_dev((size_t) S * h_v * S); + for (int i = 0; i < S; ++i) + for (int j = 0; j < S; ++j) + for (int h = 0; h < h_v; ++h) { + const float val = (float) (0.001 * (i + 2 * j + 3 * h) - 1.0); + st_ref[(size_t) (i * S + j) * h_v + h] = val; + st_dev[(size_t) (i * h_v + h) * S + j] = val; // (S, h_v, S), j fastest + } + + std::vector q((size_t) h_k * S), k((size_t) h_k * S), v((size_t) h_v * S), gate((size_t) h_v), + beta((size_t) h_v); + for (auto& x : q) x = gauss(rng) * 0.1f; + for (auto& x : k) x = gauss(rng) * 0.1f; + for (auto& x : v) x = gauss(rng); + for (auto& x : gate) x = -(2.0f + 4.0f * (float) (rng() % 100) / 100.0f); // dec in (0.0025, 0.135] + for (auto& x : beta) x = (float) (rng() % 100) / 100.0f; + + std::vector want_o; + std::vector st_mod = st_ref; + ref_step(st_mod, q, k, v, gate, beta, S, h_k, h_v, want_o, true, true); + + // the traps + { + std::vector a = st_ref; + std::vector oo; + ref_step(a, q, k, v, gate, beta, S, h_k, h_v, oo, false, true); + const double rel = rel_l1(want_o, oo); + std::printf(" %-42s %-4s (%.2f%% apart)\n", "modulo vs interleave head pairing", + rel > 0.05 ? "yes" : "*** NO ***", rel * 100); + if (!(rel > 0.05)) ++bad; + } + { + std::vector a = st_ref; + std::vector oo; + ref_step(a, q, k, v, gate, beta, S, h_k, h_v, oo, true, false); + const double rel = rel_l1(want_o, oo); + std::printf(" %-42s %-4s (%.2f%% apart)\n", "decay before vs after the update", + rel > 0.05 ? "yes" : "*** NO ***", rel * 100); + // The two readings differ by exactly `k (x) (beta * v * (1 - dec))`, so the fixture is only able + // to see the trap when `dec` is far from 1 and `beta` is far from 0. The first version used + // gate in [-0.99, 0] - dec in [0.37, 1] - and managed 3.25%, which is a threshold the wrong + // reading nearly clears. Deep decay is also the realistic regime: `ssm_a = -exp(A_log) < 0` and + // softplus is positive, so gate is comfortably negative in the real model. + if (!(rel > 0.05)) ++bad; + } + + // the kernel + float *d_st = nullptr, *d_q = nullptr, *d_k = nullptr, *d_v = nullptr, *d_g = nullptr, *d_b = nullptr, + *d_o = nullptr; + check(cudaMalloc(&d_st, st_dev.size() * 4), "st"); + check(cudaMalloc(&d_q, q.size() * 4), "q"); + check(cudaMalloc(&d_k, k.size() * 4), "k"); + check(cudaMalloc(&d_v, v.size() * 4), "v"); + check(cudaMalloc(&d_g, gate.size() * 4), "g"); + check(cudaMalloc(&d_b, beta.size() * 4), "b"); + check(cudaMalloc(&d_o, (size_t) h_v * S * 4), "o"); + check(cudaMemcpy(d_st, st_dev.data(), st_dev.size() * 4, cudaMemcpyHostToDevice), "cst"); + check(cudaMemcpy(d_q, q.data(), q.size() * 4, cudaMemcpyHostToDevice), "cq"); + check(cudaMemcpy(d_k, k.data(), k.size() * 4, cudaMemcpyHostToDevice), "ck"); + check(cudaMemcpy(d_v, v.data(), v.size() * 4, cudaMemcpyHostToDevice), "cv"); + check(cudaMemcpy(d_g, gate.data(), gate.size() * 4, cudaMemcpyHostToDevice), "cg"); + check(cudaMemcpy(d_b, beta.data(), beta.size() * 4, cudaMemcpyHostToDevice), "cb"); + + strata::kernels::GdnShapes sh{S, h_k, h_v}; + strata::kernels::gdn_step(d_st, d_q, d_k, d_v, d_g, d_b, d_o, sh, nullptr); + + std::vector got_o((size_t) h_v * S), got_st(st_dev.size()); + check(cudaMemcpy(got_o.data(), d_o, got_o.size() * 4, cudaMemcpyDeviceToHost), "cgo"); + check(cudaMemcpy(got_st.data(), d_st, got_st.size() * 4, cudaMemcpyDeviceToHost), "cgst"); + + // o, and the STATE read back through the declared layout + const double rel_o = rel_l1(want_o, got_o); + double rel_st = 0, mag_st = 0; + for (int i = 0; i < S; ++i) + for (int j = 0; j < S; ++j) + for (int h = 0; h < h_v; ++h) { + const size_t dev = (size_t) (i * h_v + h) * S + j; + const double w = st_mod[(size_t) (i * S + j) * h_v + h]; + rel_st += std::fabs(w - (double) got_st[dev]); + mag_st += std::fabs(w); + } + rel_st /= (mag_st > 1e-30 ? mag_st : 1e-30); + std::printf("\n %-42s rel %.3e\n", "o vs reference", rel_o); + std::printf(" %-42s rel %.3e\n", "state (read via (S,h_v,S)) vs reference", rel_st); + // The kernel accumulates in f32 and the reference in f64, and the state is a RECURRENCE over 128 + // terms, so 1e-5 is the arithmetic's floor here and not a structural allowance. + if (!(rel_o <= 1e-5)) { std::printf(" *** o over 1e-5 ***\n"); ++bad; } + if (!(rel_st <= 1e-5)) { std::printf(" *** state over 1e-5 ***\n"); ++bad; } + + // a zero state stays zero, and a zero gate/beta leaves the state untouched (the reference's + // PROPERTY 4 setup) - a cheap independent invariant that no layout can fake + { + std::vector zero(st_dev.size(), 0.0f), zg((size_t) h_v, 0.0f), zb((size_t) h_v, 0.0f); + check(cudaMemcpy(d_st, zero.data(), zero.size() * 4, cudaMemcpyHostToDevice), "z0"); + check(cudaMemcpy(d_g, zg.data(), zg.size() * 4, cudaMemcpyHostToDevice), "z1"); + check(cudaMemcpy(d_b, zb.data(), zb.size() * 4, cudaMemcpyHostToDevice), "z2"); + strata::kernels::gdn_step(d_st, d_q, d_k, d_v, d_g, d_b, d_o, sh, nullptr); + std::vector zz(zero.size()); + check(cudaMemcpy(zz.data(), d_st, zz.size() * 4, cudaMemcpyDeviceToHost), "z3"); + double nz = 0; + for (float x : zz) nz += std::fabs(x); + const bool ok = nz == 0.0; + std::printf(" %-42s %s\n", "gate=beta=0 from a zero state is a no-op", ok ? "yes" : "*** NO ***"); + if (!ok) ++bad; + } + + // ephemeral state: unchanged when the state is untouched but beta=1 — the delta rule writing k*d + cudaFree(d_st); cudaFree(d_q); cudaFree(d_k); cudaFree(d_v); cudaFree(d_g); cudaFree(d_b); cudaFree(d_o); + } + + // ================= 2. the conv ================= + { + const int C = 24, dc = 4; // small, so the host reference is a plain loop + // TWO DIFFERENT LAYOUTS MEET HERE, and this is where the first version of this test failed at 0.136. + // `ref/gdn.py` carries the conv state as (d_conv-1, C) ROW-MAJOR - (i, c) at i*C + c - because it is + // numpy. The kernel carries it as (C, d_conv-1) with c fastest - c*(dc-1) + i - because that is + // ggml's `ne = {d_conv-1, C}` with ne0 fast, and it is a runtime buffer so either is legal. The + // mapping between them is written out below rather than assumed, in both directions. + std::vector cs_ref((size_t) (dc - 1) * C), x((size_t) C), kern((size_t) dc * C); + for (int i = 0; i < dc - 1; ++i) + for (int c = 0; c < C; ++c) cs_ref[(size_t) (i * C + c)] = (float) (10 * (i + 1) + c); // labelled + for (int c = 0; c < C; ++c) x[(size_t) c] = (float) (100 + c); + for (size_t i = 0; i < kern.size(); ++i) kern[i] = gauss(rng); + + auto to_dev_layout = [&](const std::vector& r) { + std::vector d(r.size()); + for (int i = 0; i < dc - 1; ++i) + for (int c = 0; c < C; ++c) d[(size_t) (c * (dc - 1) + i)] = r[(size_t) (i * C + c)]; + return d; + }; + auto to_ref_layout = [&](const std::vector& d) { + std::vector r(d.size()); + for (int i = 0; i < dc - 1; ++i) + for (int c = 0; c < C; ++c) r[(size_t) (i * C + c)] = d[(size_t) (c * (dc - 1) + i)]; + return r; + }; + + std::vector cs = to_dev_layout(cs_ref); + std::vector want; + std::vector cs_want_ref = cs_ref; + ref_conv(cs_want_ref, x, kern, C, dc, want); + const std::vector cs_want = to_dev_layout(cs_want_ref); + + // THE LAYOUT: the manifest's `ssm_conv1d.weight` has ne = [d_conv, C], so ne0 = d_conv is fast. + // The kernel takes `kW[c*dc + i]`; the reference's `kern` is (dc, C) row-major. The transcription + // below is the ONLY place the two meet. + std::vector kW((size_t) C * dc); + for (int c = 0; c < C; ++c) + for (int i = 0; i < dc; ++i) kW[(size_t) (c * dc + i)] = kern[(size_t) (i * C + c)]; + // and the rival reading, which must differ or the fixture cannot see a transposed weight + std::vector kW_wrong((size_t) C * dc); + for (size_t i = 0; i < kern.size(); ++i) kW_wrong[i] = kern[i]; + { + // apply the WRONG layout by hand: tap i of channel c is kW[c*dc+i] read as kW[i*C+c] + std::vector cs2 = cs; + std::vector out_bad((size_t) C, 0.0f); + for (int c = 0; c < C; ++c) { + double a = 0; + for (int i = 0; i < dc - 1; ++i) a += (double) cs2[(size_t) (i * C + c)] * (double) kW_wrong[(size_t) (c * dc + i)]; + a += (double) x[(size_t) c] * (double) kW_wrong[(size_t) (c * dc + dc - 1)]; + out_bad[(size_t) c] = (float) a; + } + const double rel = rel_l1(want, out_bad); + std::printf("\n %-42s %-4s (%.1f%% apart)\n", "conv weight layout is observable", + rel > 0.05 ? "yes" : "*** NO ***", rel * 100); + if (!(rel > 0.05)) ++bad; + } + + float *d_cs = nullptr, *d_x = nullptr, *d_w = nullptr, *d_o = nullptr; + check(cudaMalloc(&d_cs, cs.size() * 4), "cs"); + check(cudaMalloc(&d_x, x.size() * 4), "cx"); + check(cudaMalloc(&d_w, kW.size() * 4), "cw"); + check(cudaMalloc(&d_o, (size_t) C * 4), "co"); + check(cudaMemcpy(d_cs, cs.data(), cs.size() * 4, cudaMemcpyHostToDevice), "ccs"); + check(cudaMemcpy(d_x, x.data(), x.size() * 4, cudaMemcpyHostToDevice), "ccx"); + check(cudaMemcpy(d_w, kW.data(), kW.size() * 4, cudaMemcpyHostToDevice), "ccw"); + strata::kernels::gdn_conv_step(d_cs, d_x, d_w, d_o, C, dc, nullptr); + std::vector got((size_t) C), got_cs(cs.size()); + check(cudaMemcpy(got.data(), d_o, got.size() * 4, cudaMemcpyDeviceToHost), "cgo"); + check(cudaMemcpy(got_cs.data(), d_cs, got_cs.size() * 4, cudaMemcpyDeviceToHost), "cgs"); + const double rel_out = rel_l1(want, got), rel_cs = rel_l1(cs_want, got_cs); + std::printf(" %-42s rel %.3e\n", "conv out vs reference", rel_out); + std::printf(" %-42s rel %.3e\n", "conv state slide (mapped) vs reference", rel_cs); + if (!(rel_out <= 1e-6)) { std::printf(" *** conv out ***\n"); ++bad; } + if (!(rel_cs <= 1e-6)) { std::printf(" *** conv state ***\n"); ++bad; } + cudaFree(d_cs); cudaFree(d_x); cudaFree(d_w); cudaFree(d_o); + } + + // ================= 3. l2_norm ================= + { + const int rows = 16, cols = 128; + std::vector x((size_t) rows * cols), x_ref; + for (auto& val : x) val = gauss(rng) * 3.0f; + x_ref = x; + const float eps = 1e-6f; + for (int r = 0; r < rows; ++r) { + double acc = 0; + for (int i = 0; i < cols; ++i) acc += (double) x_ref[(size_t) (r * cols + i)] * (double) x_ref[(size_t) (r * cols + i)]; + const float inv = (float) (1.0 / std::sqrt(acc + (double) eps)); + for (int i = 0; i < cols; ++i) x_ref[(size_t) (r * cols + i)] *= inv; + } + // The rival reading puts THE SAME eps on the MEAN instead of on the squared norm, which at + // ||x||^2 ~ 1152 and cols = 128 changes the divisor from sqrt(1152) to sqrt(9) - a factor of 11.3, so + // the trap is large here. It would be a much smaller correction on a near-zero row, where the floor + // is the whole answer; that regime is not what this fixture exercises. + std::vector x_mean = x; + for (int r = 0; r < rows; ++r) { + double acc = 0; + for (int i = 0; i < cols; ++i) acc += (double) x_mean[(size_t) (r * cols + i)] * (double) x_mean[(size_t) (r * cols + i)]; + const float inv = (float) (1.0 / std::sqrt(acc / cols + (double) eps)); + for (int i = 0; i < cols; ++i) x_mean[(size_t) (r * cols + i)] *= inv; + } + const double rel_trap = rel_l1(x_ref, x_mean); + std::printf("\n %-42s %-4s (%.1f%% apart)\n", "eps on sum vs on mean is observable", + rel_trap > 0.05 ? "yes" : "*** NO ***", rel_trap * 100); + if (!(rel_trap > 0.05)) ++bad; + + float* d_x = nullptr; + check(cudaMalloc(&d_x, x.size() * 4), "lx"); + check(cudaMemcpy(d_x, x.data(), x.size() * 4, cudaMemcpyHostToDevice), "lcx"); + strata::kernels::gdn_l2_norm(d_x, rows, cols, eps, nullptr); + std::vector got(x.size()); + check(cudaMemcpy(got.data(), d_x, got.size() * 4, cudaMemcpyDeviceToHost), "lcg"); + const double rel = rel_l1(x_ref, got); + std::printf(" %-42s rel %.3e\n", "l2_norm vs reference", rel); + if (!(rel <= 1e-6)) ++bad; + cudaFree(d_x); + } + + // ================= 4. the closing norm ================= + { + const int S2 = 128, hv2 = 8; + std::vector o((size_t) hv2 * S2), z((size_t) hv2 * S2), sn((size_t) S2), y_ref((size_t) hv2 * S2); + for (auto& val : o) val = gauss(rng); + for (auto& val : z) val = gauss(rng); + for (auto& val : sn) val = 1.0f + 0.1f * gauss(rng); + const float eps = 1e-6f; + for (int h = 0; h < hv2; ++h) { + double acc = 0; + for (int i = 0; i < S2; ++i) acc += (double) o[(size_t) (h * S2 + i)] * (double) o[(size_t) (h * S2 + i)]; + const float inv = (float) (1.0 / std::sqrt(acc / S2 + (double) eps)); + for (int i = 0; i < S2; ++i) + y_ref[(size_t) (h * S2 + i)] = o[(size_t) (h * S2 + i)] * inv * sn[(size_t) i] * + sigmoid_f(z[(size_t) (h * S2 + i)]); + } + float *d_o = nullptr, *d_z = nullptr, *d_sn = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_o, o.size() * 4), "no"); + check(cudaMalloc(&d_z, z.size() * 4), "nz"); + check(cudaMalloc(&d_sn, sn.size() * 4), "ns"); + check(cudaMalloc(&d_y, y_ref.size() * 4), "ny"); + check(cudaMemcpy(d_o, o.data(), o.size() * 4, cudaMemcpyHostToDevice), "nco"); + check(cudaMemcpy(d_z, z.data(), z.size() * 4, cudaMemcpyHostToDevice), "ncz"); + check(cudaMemcpy(d_sn, sn.data(), sn.size() * 4, cudaMemcpyHostToDevice), "ncs"); + strata::kernels::gdn_out_norm(d_o, d_z, d_sn, d_y, hv2, S2, eps, nullptr); + std::vector got(y_ref.size()); + check(cudaMemcpy(got.data(), d_y, got.size() * 4, cudaMemcpyDeviceToHost), "ncy"); + const double rel = rel_l1(y_ref, got); + std::printf("\n %-42s rel %.3e\n", "out norm (rms * ssm_norm * sigmoid z)", rel); + if (!(rel <= 1e-6)) ++bad; + + // SiLU instead of sigmoid is the qwen3.5 reading; `ref/gdn.py` says this artifact does NOT use it + std::vector y_silu(y_ref.size()); + for (int h = 0; h < hv2; ++h) { + double acc = 0; + for (int i = 0; i < S2; ++i) acc += (double) o[(size_t) (h * S2 + i)] * (double) o[(size_t) (h * S2 + i)]; + const float inv = (float) (1.0 / std::sqrt(acc / S2 + (double) eps)); + for (int i = 0; i < S2; ++i) { + const float zz = z[(size_t) (h * S2 + i)]; + y_silu[(size_t) (h * S2 + i)] = + o[(size_t) (h * S2 + i)] * inv * sn[(size_t) i] * (zz / (1.0f + std::exp(-zz))); + } + } + const double rel_silu = rel_l1(y_ref, y_silu); + std::printf(" %-42s %-4s (%.2f%% apart)\n", "sigmoid vs SiLU on the gate is observable", + rel_silu > 0.05 ? "yes" : "*** NO ***", rel_silu * 100); + if (!(rel_silu > 0.05)) ++bad; + cudaFree(d_o); cudaFree(d_z); cudaFree(d_sn); cudaFree(d_y); + } + + std::printf("\ngdn: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("gdn_parity OK\n"); + return 0; +} diff --git a/src/kernels/gr_parity.cpp b/src/kernels/gr_parity.cpp new file mode 100644 index 00000000..b690af1b --- /dev/null +++ b/src/kernels/gr_parity.cpp @@ -0,0 +1,615 @@ +// src/kernels/gr_parity.cpp - P2.S2's test for the gated residual / hyper-connection. +// +// `ref/gr.py` opens by listing the details "a prose reading gets wrong", and every one of them is a reading +// that has the RIGHT SHAPES and produces a PLAUSIBLE number. So each is computed here the wrong way round and +// required to differ materially BEFORE the kernel is judged against the right one. A test that only does the +// second half passes against either reading: +// +// 1. PER-STREAM RMSNorm. The source comment reads ambiguously; the code reduces over ne[0] = n_embd, i.e. +// one RMS per stream. The rival reading is one RMS over the whole hc*n_embd stack. +// 2. `/ hc` INSIDE the silu: `lo = silu(proj / hc)`. Moving it outside is a one-line change that keeps +// every shape. +// 3. SiLU on `lo` and SIGMOID on the gate, a few lines apart in the same function. Swapping them is the +// obvious slip, and both are monotone saturating functions so the magnitudes stay comparable. +// 4. `mean` over the stream axis, not `sum`. A factor of hc = 4, which is exactly the kind of error that +// looks like a scale problem rather than a structural one. +// 5. ACTIVATION PRECISION. The historical BF16-rounded CPU contract and the pinned CUDA single-token +// FP32-activation contract must be distinguishable. Both are tested against explicit references; +// neither is selected merely because the weight is BF16. The scalar fixture isolates xn and lo +// rounding from reduction order, and graph replay must preserve the precision chosen at capture. +// 6. `gr_write`'s `2*sigmoid`, which centres the gate on 1 so a ZERO injection is a plain residual add. +// Asserted as a property, not as a value, because that is what the source comment claims. +#include "strata/kernels/gr.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +/// The same rule the kernel uses, on the host - `ref/quant.py::bf16`. +float to_bf16(float f) { + uint32_t i; + std::memcpy(&i, &f, 4); + i = (i + ((i >> 16) & 1u) + 0x7FFFu) & 0xFFFF0000u; + float o; + std::memcpy(&o, &i, 4); + return o; +} + +uint16_t bf16_bits(float f) { + uint32_t i; + std::memcpy(&i, &f, 4); + return (uint16_t) (i >> 16); +} + +struct Opts { + bool per_stream_norm = true; ///< false: one RMS over the whole stack (WRONG) + bool scale_inside_silu = true; ///< false: silu(proj)/hc instead of silu(proj/hc) (WRONG) + bool sigmoid_on_gate = true; ///< false: silu on the gate and sigmoid on lo (WRONG) + bool mean_over_streams = true; ///< false: sum over streams instead of mean (WRONG) + bool round_activation = true; ///< false: pinned CUDA single-token BF16 MMVF contract +}; + +/// `ref/gr.py::gr_read`, transcribed with switches for the rival readings. +void reference(const std::vector& R, const std::vector& w_norm, + const std::vector& w_down, const std::vector& w_up, + const std::vector& w_inject, float eps, long long n_embd, long long hc, long long hc_lr, + const Opts& o, std::vector& mixed, std::vector& inject) { + const long long hc_dim = hc * n_embd; + std::vector xn((size_t) hc_dim); + if (o.per_stream_norm) { + for (long long c = 0; c < hc; ++c) { + double ms = 0; + for (long long d = 0; d < n_embd; ++d) ms += (double) R[(size_t) (c * n_embd + d)] * R[(size_t) (c * n_embd + d)]; + ms /= (double) n_embd; + const float rs = (float) (1.0 / std::sqrt(ms + (double) eps)); + for (long long d = 0; d < n_embd; ++d) { + const size_t i = (size_t) (c * n_embd + d); + xn[i] = R[i] * rs * w_norm[i]; + } + } + } else { + double ms = 0; + for (long long i = 0; i < hc_dim; ++i) ms += (double) R[(size_t) i] * R[(size_t) i]; + ms /= (double) hc_dim; + const float rs = (float) (1.0 / std::sqrt(ms + (double) eps)); + for (long long i = 0; i < hc_dim; ++i) xn[(size_t) i] = R[(size_t) i] * rs * w_norm[(size_t) i]; + } + + std::vector act((size_t) hc_dim); + for (long long i = 0; i < hc_dim; ++i) act[(size_t) i] = o.round_activation ? to_bf16(xn[(size_t) i]) : xn[(size_t) i]; + + // lo + std::vector lo((size_t) hc_lr); + for (long long k = 0; k < hc_lr; ++k) { + double a = 0; + for (long long i = 0; i < hc_dim; ++i) a += (double) act[(size_t) i] * (double) w_down[(size_t) (k * hc_dim + i)]; + const float p = (float) a; + if (o.sigmoid_on_gate) lo[(size_t) k] = o.scale_inside_silu ? (p / (float) hc) / (1.0f + std::exp(-(p / (float) hc))) + : (p / (1.0f + std::exp(-p))) / (float) hc; + else lo[(size_t) k] = o.scale_inside_silu ? 1.0f / (1.0f + std::exp(-(p / (float) hc))) + : (1.0f / (1.0f + std::exp(-p))) / (float) hc; + } + std::vector lq((size_t) hc_lr); + for (long long k = 0; k < hc_lr; ++k) + lq[(size_t) k] = o.round_activation ? to_bf16(lo[(size_t) k]) : lo[(size_t) k]; + + // gate and the gated mean + mixed.assign((size_t) n_embd, 0.0f); + for (long long d = 0; d < n_embd; ++d) { + float m = 0.0f; + for (long long c = 0; c < hc; ++c) { + const long long i = c * n_embd + d; + double a = 0; + for (long long k = 0; k < hc_lr; ++k) a += (double) lq[(size_t) k] * (double) w_up[(size_t) (i * hc_lr + k)]; + const float g = (float) a; + const float s = o.sigmoid_on_gate ? 1.0f / (1.0f + std::exp(-g)) : g / (1.0f + std::exp(-g)); + m += xn[(size_t) i] * s; + } + mixed[(size_t) d] = o.mean_over_streams ? m / (float) hc : m; + } + + inject.assign((size_t) hc, 0.0f); + for (long long c = 0; c < hc; ++c) { + double a = 0; + for (long long i = 0; i < hc_dim; ++i) a += (double) act[(size_t) i] * (double) w_inject[(size_t) (c * hc_dim + i)]; + inject[(size_t) c] = (float) a; + } +} + +/// Normalised L1 difference. `mag_out` returns the mean |a|, for reporting. +double rel_diff(const std::vector& a, const std::vector& b, double* mag_out = nullptr) { + double d = 0, mag = 0; + for (size_t i = 0; i < a.size(); ++i) { + d += std::fabs((double) a[i] - (double) b[i]); + mag += std::fabs((double) a[i]); + } + if (mag_out) *mag_out = mag / (double) (a.empty() ? 1 : a.size()); + return d / (mag > 1e-30 ? mag : 1e-30); +} + +/// Relative error against the magnitude of the TERMS, not of the result. +/// +/// `R + block_out * w` cancels wherever block_out*w is close to -R, and a plain |want-got|/|want| then +/// reports the CONDITION NUMBER instead of the arithmetic. This is the same metric mistake the project has +/// now made four times: rounds 169 (Q4_K), 189 (RoPE), 194 (shared_expert) and this one. For a sum, the +/// denominator is the sum of the term magnitudes. +double rel_terms(double want, double got, double term_a, double term_b) { + const double den = std::fabs(term_a) + std::fabs(term_b); + return std::fabs(want - got) / (den > 1e-30 ? den : 1e-30); +} + +const char* activation_mode_name(int mode) { + return mode == 2 ? "native pinned GR" : (mode == 1 ? "FP32" : "BF16"); +} +void select_activation_mode(int mode) { + strata::kernels::gr_set_native_mmvf(mode == 2); + // In mode 2 the precision-only switch is deliberately false: native MMVF must imply FP32 by itself. + strata::kernels::gr_set_fp32_activations(mode == 1); +} + +// A diagonal GR fixture makes activation precision observable without summation-order ambiguity. +// Two-wide matrices satisfy native MMVF's pair ABI, while each nonzero dot has only one nonzero product. +// At R=1 and eps=0, xn=gamma exactly; any rounding in xn or lo comes from the selected activation contract. +int scalar_activation_contract() { + using namespace strata::kernels; + const GrShapes sh{2, 1, 2}; + float *d_R = nullptr, *d_norm = nullptr, *d_mixed = nullptr, *d_inject = nullptr; + uint16_t* d_weights = nullptr; + void* d_scratch = nullptr; + check(cudaMalloc(&d_R, 2 * sizeof(float)), "scalar R"); + check(cudaMalloc(&d_norm, 2 * sizeof(float)), "scalar norm"); + check(cudaMalloc(&d_mixed, 2 * sizeof(float)), "scalar mixed"); + check(cudaMalloc(&d_inject, sizeof(float)), "scalar inject"); + check(cudaMalloc(&d_weights, 10 * sizeof(uint16_t)), "scalar weights"); + check(cudaMalloc(&d_scratch, gr_workspace_bytes(sh)), "scalar scratch"); + GrWorkspace ws; + gr_workspace_init(sh, d_scratch, ws); + const float ones[] = {1.0f, 1.0f}; + const uint16_t weights[] = { + bf16_bits(0.5f), 0, 0, 0, // down: lo[0]=silu(xn[0]/2), lo[1]=0 + bf16_bits(1.5f), 0, bf16_bits(1.5f), 0, // both gate rows consume lo[0] + bf16_bits(2.0f), 0 // injection: 2*xn[0] + }; + check(cudaMemcpy(d_R, ones, sizeof(ones), cudaMemcpyHostToDevice), "scalar upload R"); + check(cudaMemcpy(d_weights, weights, sizeof(weights), cudaMemcpyHostToDevice), "scalar upload weights"); + cudaStream_t stream; + check(cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking), "scalar stream"); + int bad = 0; + for (float gamma : {1.0f, 1.00390625f}) { + const float gammas[] = {gamma, gamma}; + check(cudaMemcpy(d_norm, gammas, sizeof(gammas), cudaMemcpyHostToDevice), "scalar upload norm"); + float mixed_by_mode[3][2] = {}, inject_by_mode[3] = {}; + for (int mode = 0; mode < 3; ++mode) { + select_activation_mode(mode); + gr_read(d_R, d_norm, d_weights, d_weights + 4, d_weights + 8, 0.0f, + sh, ws, d_mixed, d_inject, stream); + check(cudaStreamSynchronize(stream), "scalar warmup"); + cudaGraph_t graph; + cudaGraphExec_t executable; + check(cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal), "scalar capture begin"); + gr_read(d_R, d_norm, d_weights, d_weights + 4, d_weights + 8, 0.0f, + sh, ws, d_mixed, d_inject, stream); + check(cudaStreamEndCapture(stream, &graph), "scalar capture end"); + check(cudaGraphInstantiate(&executable, graph, nullptr, nullptr, 0), "scalar instantiate"); + // Changing both host options after capture cannot change the graph's selected kernels. + select_activation_mode((mode + 1) % 3); + // Warmup results must not make a missing/no-op captured launch pass. + // Poison the outputs and intermediate workspace before replay. + check(cudaMemsetAsync(d_mixed, 0xa5, 2 * sizeof(float), stream), "scalar poison mixed"); + check(cudaMemsetAsync(d_inject, 0xa5, sizeof(float), stream), "scalar poison inject"); + check(cudaMemsetAsync(d_scratch, 0xa5, ws.bytes, stream), "scalar poison workspace"); + check(cudaGraphLaunch(executable, stream), "scalar replay"); + check(cudaStreamSynchronize(stream), "scalar replay sync"); + check(cudaMemcpy(mixed_by_mode[mode], d_mixed, 2 * sizeof(float), cudaMemcpyDeviceToHost), "scalar mixed copy"); + check(cudaMemcpy(&inject_by_mode[mode], d_inject, sizeof(float), cudaMemcpyDeviceToHost), "scalar inject copy"); + const float activation = mode ? gamma : to_bf16(gamma); + const float projection = activation * 0.5f; + const float lo = projection / (1.0f + std::exp(-projection)); + const float gate = (mode ? lo : to_bf16(lo)) * 1.5f; + const float expected_mixed = gamma / (1.0f + std::exp(-gate)); + const float expected_inject = activation * 2.0f; + // Each mode has its own activation contract and bounded scalar-formula + // check. Native fast exp/div need not match ordinary FP32 postops bit + // for bit: native_gr_postops_parity establishes that arithmetic with + // actual pinned CUDA graphs. The nonzero projection/injection here + // has only one exactly representable product and remains exact. + const bool ok = std::fabs(mixed_by_mode[mode][0] - expected_mixed) <= 2e-7f && + mixed_by_mode[mode][0] == mixed_by_mode[mode][1] && + inject_by_mode[mode] == expected_inject; + std::printf(" scalar gamma %.8f %s activation/capture %s (mixed %.9f, ref %.9f)\n", + gamma, activation_mode_name(mode), ok ? "pass" : "FAIL", + mixed_by_mode[mode][0], expected_mixed); + if (!ok) ++bad; + check(cudaGraphExecDestroy(executable), "scalar graph exec destroy"); + check(cudaGraphDestroy(graph), "scalar graph destroy"); + + const float sentinel = -73.25f; + check(cudaMemcpy(d_inject, &sentinel, sizeof(float), cudaMemcpyHostToDevice), "scalar sentinel"); + select_activation_mode(mode); + gr_read(d_R, d_norm, d_weights, d_weights + 4, nullptr, 0.0f, + sh, ws, d_mixed, d_inject, stream); + check(cudaStreamSynchronize(stream), "scalar final mixer sync"); + float final_mixed[2] = {}, untouched = 0.0f; + check(cudaMemcpy(final_mixed, d_mixed, sizeof(final_mixed), cudaMemcpyDeviceToHost), "scalar final mixed"); + check(cudaMemcpy(&untouched, d_inject, sizeof(float), cudaMemcpyDeviceToHost), "scalar sentinel copy"); + const bool final_ok = std::memcmp(final_mixed, mixed_by_mode[mode], sizeof(final_mixed)) == 0 && untouched == sentinel; + std::printf(" scalar final mixer %s %s\n", activation_mode_name(mode), final_ok ? "pass" : "FAIL"); + if (!final_ok) ++bad; + } + if (std::fabs(mixed_by_mode[1][0] - mixed_by_mode[0][0]) <= 1e-5f || + (gamma != 1.0f && inject_by_mode[0] == inject_by_mode[1])) { + std::printf(" scalar precision fixture is not observable\n"); + ++bad; + } + if (inject_by_mode[1] != inject_by_mode[2]) { + std::printf(" native MMVF changed the exact scalar FP32 injection projection\n"); + ++bad; + } + } + select_activation_mode(0); + check(cudaStreamDestroy(stream), "scalar stream destroy"); + cudaFree(d_R); cudaFree(d_norm); cudaFree(d_mixed); cudaFree(d_inject); + cudaFree(d_weights); cudaFree(d_scratch); + return bad; +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: gr_parity [--selftest]\n"); return 2; } + } + + const long long n_embd = 256, hc = 4, hc_lr = 32; + const long long hc_dim = hc * n_embd; + const float eps = 1e-6f; + std::mt19937 rng(1234); + std::normal_distribution gauss(0.0f, 1.0f); + + // Weights are BF16-VALUED f32, which is what the pack holds for a BF16 source type. Generating plain f32 + // weights would make the kernel (which stores 16 bits) differ from the reference by the rounding itself, + // and the test would then be measuring the fixture. + auto bf16_weight = [&](size_t n, float sigma) { + std::vector v(n); + for (auto& x : v) x = to_bf16(gauss(rng) * sigma); + return v; + }; + std::vector R((size_t) hc_dim); + for (auto& x : R) x = gauss(rng); + // STREAM SCALES 1, 4, 16, 64. Without them trap 1 is only ~5% observable, because four streams of i.i.d. + // normal noise have nearly the same RMS by accident - so a whole-stack RMS and a per-stream RMS agree to + // within the noise, and the fixture would be asserting a distinction it cannot see. Giving the streams + // different magnitudes is what makes "per-stream" a claim with content. + for (long long c = 0; c < hc; ++c) + for (long long d = 0; d < n_embd; ++d) R[(size_t) (c * n_embd + d)] *= std::pow(4.0f, (float) c); + // gamma stored as (1 + w), so values near 1 - a reader expecting a plain scale is the one this catches + std::vector w_norm((size_t) hc_dim); + for (auto& x : w_norm) x = 1.0f + 0.1f * gauss(rng); + std::vector w_down = bf16_weight((size_t) (hc_lr * hc_dim), 0.2f); + std::vector w_up = bf16_weight((size_t) (hc_dim * hc_lr), 0.2f); + std::vector w_inject = bf16_weight((size_t) (hc * hc_dim), 0.05f); + + // NO PERMUTATION: the kernel takes both matrices exactly as the manifest (and `ref/gr.py`) store them. + // `w_down` is (hc_lr, hc_dim) row-major and `w_up` is (hc_dim, hc_lr) row-major, so the bit-copy below is + // the whole conversion. An earlier version of this kernel wanted both TRANSPOSED - the test built them + // that way and asserted the orientation observable - and that is gone because the warp-per-row mapping + // makes it unnecessary. What replaces it is the general orientation trap further down: a weight array + // read with the wrong index expression must still produce a visibly different answer. + std::vector q_down(w_down.size()), q_up(w_up.size()), q_inject(w_inject.size()); + for (size_t i = 0; i < w_down.size(); ++i) q_down[i] = bf16_bits(w_down[i]); + for (size_t i = 0; i < w_up.size(); ++i) q_up[i] = bf16_bits(w_up[i]); + for (size_t i = 0; i < w_inject.size(); ++i) q_inject[i] = bf16_bits(w_inject[i]); + + // ---- device side + float *d_R = nullptr, *d_norm = nullptr, *d_mixed = nullptr, *d_inject = nullptr; + uint16_t *d_down = nullptr, *d_up = nullptr, *d_inj = nullptr; + check(cudaMalloc(&d_R, R.size() * sizeof(float)), "m R"); + check(cudaMalloc(&d_norm, w_norm.size() * sizeof(float)), "m norm"); + check(cudaMalloc(&d_down, q_down.size() * sizeof(uint16_t)), "m down"); + check(cudaMalloc(&d_up, q_up.size() * sizeof(uint16_t)), "m up"); + check(cudaMalloc(&d_inj, q_inject.size() * sizeof(uint16_t)), "m inj"); + check(cudaMalloc(&d_mixed, (size_t) n_embd * sizeof(float)), "m mixed"); + check(cudaMalloc(&d_inject, (size_t) hc * sizeof(float)), "m inject"); + check(cudaMemcpy(d_R, R.data(), R.size() * sizeof(float), cudaMemcpyHostToDevice), "c R"); + check(cudaMemcpy(d_norm, w_norm.data(), w_norm.size() * sizeof(float), cudaMemcpyHostToDevice), "c norm"); + check(cudaMemcpy(d_down, q_down.data(), q_down.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "c down"); + check(cudaMemcpy(d_up, q_up.data(), q_up.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "c up"); + check(cudaMemcpy(d_inj, q_inject.data(), q_inject.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "c inj"); + + const strata::kernels::GrShapes sh{n_embd, hc, hc_lr}; + // DEVICE memory: the workspace is written by the kernel. + void* d_ws_raw = nullptr; + check(cudaMalloc(&d_ws_raw, strata::kernels::gr_workspace_bytes(sh)), "m ws"); + strata::kernels::GrWorkspace ws; + strata::kernels::gr_workspace_init(sh, d_ws_raw, ws); + strata::kernels::gr_read(d_R, d_norm, d_down, d_up, d_inj, eps, sh, ws, d_mixed, d_inject, nullptr); + std::vector got_mixed((size_t) n_embd), got_inject((size_t) hc); + check(cudaMemcpy(got_mixed.data(), d_mixed, got_mixed.size() * sizeof(float), cudaMemcpyDeviceToHost), "c mixed"); + check(cudaMemcpy(got_inject.data(), d_inject, got_inject.size() * sizeof(float), cudaMemcpyDeviceToHost), "c inject"); + + int bad = 0; + + // ---- the traps, asserted OBSERVABLE before the kernel is judged against the right reading + Opts right; + std::vector want_mixed, want_inject; + reference(R, w_norm, w_down, w_up, w_inject, eps, n_embd, hc, hc_lr, right, want_mixed, want_inject); + + struct Trap { const char* name; Opts o; double floor; }; + const Trap traps[] = { + {"per-stream RMSNorm vs whole-stack", {false, true, true, true, true}, 0.05}, + {"/hc inside the silu vs outside", {true, false, true, true, true}, 0.05}, + {"SiLU on lo / sigmoid on gate, swapped", {true, true, false, true, true}, 0.05}, + {"mean over streams vs sum", {true, true, true, false, true}, 0.50}, + {"BF16 vs FP32 activation contracts", {true, true, true, true, false}, 1e-4}, + }; + for (const Trap& t : traps) { + std::vector m, i; + reference(R, w_norm, w_down, w_up, w_inject, eps, n_embd, hc, hc_lr, t.o, m, i); + const double rel = rel_diff(want_mixed, m); + const bool observable = rel > t.floor; + std::printf(" %-40s %-4s (%.4f%% apart, floor %.3f%%)\n", t.name, observable ? "yes" : "*** NO ***", + rel * 100, t.floor * 100); + if (!observable) { + std::printf(" *** the fixture cannot see this trap, so passing proves nothing about it ***\n"); + ++bad; + } + } + + // ---- the kernel against the correct reference + double mag = 0; + const double rel_mixed = rel_diff(want_mixed, got_mixed, &mag); + std::printf("\n %-40s worst %.3e (mean |ref| %.4f)\n", "mixed vs reference", rel_mixed, mag); + + // The tolerance is set by SUMMATION ORDER, not by the structure: both sides round the activation to bf16 + // and both weights are bf16-valued, so every product is exact in f32 and only the order differs. A + // structural error is several orders of magnitude larger - the trap floors above say so. + if (!(rel_mixed <= 1e-4)) { + std::printf(" *** over 1e-4, which summation order does not explain - look for a structural bug ***\n"); + ++bad; + } + + const double rel_inject = rel_diff(want_inject, got_inject); + std::printf(" %-40s rel %.3e\n", "inject vs reference", rel_inject); + if (!(rel_inject <= 1e-4)) { + // Say WHICH elements and by how much: a whole-array L1 hides whether this is one stream wrong (an + // indexing bug) or all of them slightly wrong (a precision bug), and those need different responses. + std::printf(" want:"); + for (long long c = 0; c < hc; ++c) std::printf(" %12.6f", (double) want_inject[(size_t) c]); + std::printf("\n got :"); + for (long long c = 0; c < hc; ++c) std::printf(" %12.6f", (double) got_inject[(size_t) c]); + std::printf("\n"); + ++bad; + } + + // Same geometry and weights with the CUDA single-token activation contract. The scalar fixture + // below is the independent precision check; this case exercises multi-stream reductions and indexing. + { + Opts fp32; + fp32.round_activation = false; + std::vector wm, wi, gm((size_t) n_embd), gi((size_t) hc); + reference(R, w_norm, w_down, w_up, w_inject, eps, n_embd, hc, hc_lr, fp32, wm, wi); + strata::kernels::gr_set_fp32_activations(true); + strata::kernels::gr_read(d_R, d_norm, d_down, d_up, d_inj, eps, sh, ws, d_mixed, d_inject, nullptr); + check(cudaMemcpy(gm.data(), d_mixed, gm.size() * sizeof(float), cudaMemcpyDeviceToHost), "FP32 mixed"); + check(cudaMemcpy(gi.data(), d_inject, gi.size() * sizeof(float), cudaMemcpyDeviceToHost), "FP32 inject"); + strata::kernels::gr_set_fp32_activations(false); + const double rm = rel_diff(wm, gm), ri = rel_diff(wi, gi); + const bool ok = rm <= 1e-4 && ri <= 1e-4; + std::printf(" FP32 activations vs reference: %s (mixed %.3e, inject %.3e)\n", + ok ? "pass" : "FAIL", rm, ri); + if (!ok) ++bad; + strata::kernels::gr_read(d_R, d_norm, d_down, d_up, d_inj, eps, sh, ws, d_mixed, d_inject, nullptr); + check(cudaMemcpy(gm.data(), d_mixed, gm.size() * sizeof(float), cudaMemcpyDeviceToHost), "restored mixed"); + check(cudaMemcpy(gi.data(), d_inject, gi.size() * sizeof(float), cudaMemcpyDeviceToHost), "restored inject"); + const bool restored = std::memcmp(gm.data(), got_mixed.data(), gm.size() * sizeof(float)) == 0 && + std::memcmp(gi.data(), got_inject.data(), gi.size() * sizeof(float)) == 0; + std::printf(" restoring default activation contract: %s\n", restored ? "byte-identical" : "FAIL"); + if (!restored) ++bad; + } + + // ---- THE WEIGHT ORIENTATION MUST BE OBSERVABLE. The kernel now takes both matrices exactly as the + // manifest stores them, so there is no permutation for a loader to forget - but the INDEX EXPRESSIONS + // inside the kernel are still a place to be wrong, and a wrong one gives right-shaped, wrong-valued + // output. Feed transposed copies through the same code path and require the answer to differ. + { + uint16_t *d_down_bad = nullptr, *d_up_bad = nullptr; + check(cudaMalloc(&d_down_bad, q_down.size() * sizeof(uint16_t)), "m down_bad"); + check(cudaMalloc(&d_up_bad, q_up.size() * sizeof(uint16_t)), "m up_bad"); + std::vector tr_down(q_down.size()), tr_up(q_up.size()); + for (long long k = 0; k < hc_lr; ++k) + for (long long i = 0; i < hc_dim; ++i) + tr_down[(size_t) (i * hc_lr + k)] = q_down[(size_t) (k * hc_dim + i)]; + for (long long i = 0; i < hc_dim; ++i) + for (long long k = 0; k < hc_lr; ++k) + tr_up[(size_t) (k * hc_dim + i)] = q_up[(size_t) (i * hc_lr + k)]; + check(cudaMemcpy(d_down_bad, tr_down.data(), tr_down.size() * 2, cudaMemcpyHostToDevice), "cb d"); + check(cudaMemcpy(d_up_bad, tr_up.data(), tr_up.size() * 2, cudaMemcpyHostToDevice), "cb u"); + std::vector bad_mixed((size_t) n_embd); + float* d_bad = nullptr; + check(cudaMalloc(&d_bad, bad_mixed.size() * sizeof(float)), "m bad"); + strata::kernels::gr_read(d_R, d_norm, d_down_bad, d_up_bad, d_inj, eps, sh, ws, d_bad, d_inject, + nullptr); + check(cudaMemcpy(bad_mixed.data(), d_bad, bad_mixed.size() * sizeof(float), cudaMemcpyDeviceToHost), "cb b"); + const double rel = rel_diff(want_mixed, bad_mixed); + const bool visible = rel > 0.05; + std::printf(" %-40s %s (%.1f%% apart)\n", "weight orientation is observable", + visible ? "yes" : "*** NO ***", rel * 100); + if (!visible) ++bad; + cudaFree(d_down_bad); cudaFree(d_up_bad); cudaFree(d_bad); + } + + // ---- the FINAL mixer passes w_inject = nullptr, and then nothing may be written + float sentinel = -12345.0f; + check(cudaMemcpy(d_inject, &sentinel, sizeof(float), cudaMemcpyHostToDevice), "c sentinel"); + strata::kernels::gr_read(d_R, d_norm, d_down, d_up, nullptr, eps, sh, ws, d_mixed, d_inject, nullptr); + float after = 0.0f; + check(cudaMemcpy(&after, d_inject, sizeof(float), cudaMemcpyDeviceToHost), "c after"); + const bool untouched = (after == sentinel); + std::printf(" %-40s %s\n", "null w_inject writes nothing", untouched ? "yes" : "*** NO ***"); + if (!untouched) ++bad; + + // ---- gr_write: the `2*sigmoid` centring, asserted as the PROPERTY the source comment claims + std::vector block_out((size_t) n_embd); + for (auto& x : block_out) x = gauss(rng); + std::vector zero_inj((size_t) hc, 0.0f); + float *d_Rw = nullptr, *d_bo = nullptr, *d_zi = nullptr, *d_outw = nullptr; + check(cudaMalloc(&d_Rw, R.size() * sizeof(float)), "m Rw"); + check(cudaMalloc(&d_bo, block_out.size() * sizeof(float)), "m bo"); + check(cudaMalloc(&d_zi, zero_inj.size() * sizeof(float)), "m zi"); + check(cudaMalloc(&d_outw, R.size() * sizeof(float)), "m outw"); + check(cudaMemcpy(d_Rw, R.data(), R.size() * sizeof(float), cudaMemcpyHostToDevice), "c Rw"); + check(cudaMemcpy(d_bo, block_out.data(), block_out.size() * sizeof(float), cudaMemcpyHostToDevice), "c bo"); + check(cudaMemcpy(d_zi, zero_inj.data(), zero_inj.size() * sizeof(float), cudaMemcpyHostToDevice), "c zi"); + + strata::kernels::gr_write(d_Rw, d_bo, d_zi, sh, d_outw, nullptr); + std::vector got_write((size_t) hc_dim); + check(cudaMemcpy(got_write.data(), d_outw, got_write.size() * sizeof(float), cudaMemcpyDeviceToHost), "c outw"); + + double worst_plain = 0; + for (long long c = 0; c < hc; ++c) + for (long long d = 0; d < n_embd; ++d) { + const size_t i = (size_t) (c * n_embd + d); + const double want = (double) R[i] + (double) block_out[(size_t) d]; + worst_plain = std::fmax(worst_plain, rel_terms(want, (double) got_write[i], (double) R[i], + (double) block_out[(size_t) d])); + } + const bool plain = worst_plain < 1e-6; + std::printf(" %-40s %s (worst rel %.3e)\n", "zero injection -> plain residual add", + plain ? "yes" : "*** NO ***", worst_plain); + if (!plain) ++bad; + + // the rival reading: sigmoid without the 2 halves the update, and must be visible. + // + // The comparison is on the DELTA `out - R`, not on `out`. The claim is about the gate WEIGHT, and `R` + // now reaches 64 in the last stream, so measuring `R + block_out*w` against the wrong reading dilutes a + // 100% error in `w` down to 2.27% - which is how this check first passed as "not observable" while being + // perfectly observable. Measure the quantity the claim is about. + { + std::vector got_delta((size_t) hc_dim), want_delta((size_t) hc_dim), wrong_delta((size_t) hc_dim); + for (long long c = 0; c < hc; ++c) + for (long long d = 0; d < n_embd; ++d) { + const size_t i = (size_t) (c * n_embd + d); + got_delta[i] = (float) ((double) got_write[i] - (double) R[i]); + want_delta[i] = block_out[(size_t) d]; // 2*sigmoid(0) = 1.0 + wrong_delta[i] = block_out[(size_t) d] * 0.5f; // 1*sigmoid(0) = 0.5 + } + const double rel = rel_diff(want_delta, wrong_delta); + const double rel_got = rel_diff(want_delta, got_delta); + const bool visible = rel > 0.05 && rel_got < 0.05; + std::printf(" %-40s %s (readings %.0f%% apart, kernel within %.2e)\n", + "1*sigmoid vs 2*sigmoid is observable", visible ? "yes" : "*** NO ***", rel * 100, rel_got); + if (!visible) ++bad; + } + + // a non-zero injection: every stream receives the SAME block output, differing only by weight + std::vector inj((size_t) hc); + for (auto& x : inj) x = gauss(rng) * 3.0f; + check(cudaMemcpy(d_zi, inj.data(), inj.size() * sizeof(float), cudaMemcpyHostToDevice), "c inj2"); + strata::kernels::gr_write(d_Rw, d_bo, d_zi, sh, d_outw, nullptr); + check(cudaMemcpy(got_write.data(), d_outw, got_write.size() * sizeof(float), cudaMemcpyDeviceToHost), "c outw2"); + double worst_stream = 0; + for (long long c = 0; c < hc; ++c) { + const double w = 2.0 / (1.0 + std::exp(-(double) inj[(size_t) c] / (double) hc)); + for (long long d = 0; d < n_embd; ++d) { + const size_t i = (size_t) (c * n_embd + d); + const double want = (double) R[i] + (double) block_out[(size_t) d] * w; + worst_stream = std::fmax(worst_stream, rel_terms(want, (double) got_write[i], (double) R[i], + (double) block_out[(size_t) d] * w)); + } + } + const bool streams_ok = worst_stream < 1e-6; + std::printf(" %-40s %s (worst rel %.3e)\n", "every stream adds the same block output", + streams_ok ? "yes" : "*** NO ***", worst_stream); + if (!streams_ok) ++bad; + + // ---- THE REAL DIMENSIONS. Exercise all activation/projection variants at n_embd=2560, hc=4, hc_lr=320. + // Workspace sizing and every full weight row must agree with the model geometry. + { + const long long rn = 2560, rhc = 4, rlr = 320, rdim = rhc * rn; + std::mt19937 rrng(99); + std::normal_distribution rg(0.0f, 1.0f); + auto rbf16 = [&](size_t n, float sigma) { + std::vector v(n); + for (auto& x : v) x = to_bf16(rg(rrng) * sigma); + return v; + }; + std::vector rR((size_t) rdim); + for (auto& x : rR) x = rg(rrng); + for (long long c = 0; c < rhc; ++c) + for (long long d = 0; d < rn; ++d) rR[(size_t) (c * rn + d)] *= std::pow(4.0f, (float) c); + std::vector rnorm((size_t) rdim); + for (auto& x : rnorm) x = 1.0f + 0.1f * rg(rrng); + std::vector rdown = rbf16((size_t) (rlr * rdim), 0.2f); + std::vector rup = rbf16((size_t) (rdim * rlr), 0.2f); + std::vector rinj = rbf16((size_t) (rhc * rdim), 0.05f); + + std::vector qd(rdown.size()), qu(rup.size()), qi(rinj.size()); + for (size_t i = 0; i < rdown.size(); ++i) qd[i] = bf16_bits(rdown[i]); + for (size_t i = 0; i < rup.size(); ++i) qu[i] = bf16_bits(rup[i]); + for (size_t i = 0; i < rinj.size(); ++i) qi[i] = bf16_bits(rinj[i]); + + float *dR = nullptr, *dN = nullptr, *dM = nullptr, *dI = nullptr; + uint16_t *dD = nullptr, *dU = nullptr, *dJ = nullptr; + check(cudaMalloc(&dR, rR.size() * 4), "rR"); + check(cudaMalloc(&dN, rnorm.size() * 4), "rN"); + check(cudaMalloc(&dD, qd.size() * 2), "rD"); + check(cudaMalloc(&dU, qu.size() * 2), "rU"); + check(cudaMalloc(&dJ, qi.size() * 2), "rJ"); + check(cudaMalloc(&dM, (size_t) rn * 4), "rM"); + check(cudaMalloc(&dI, (size_t) rhc * 4), "rI"); + check(cudaMemcpy(dR, rR.data(), rR.size() * 4, cudaMemcpyHostToDevice), "crR"); + check(cudaMemcpy(dN, rnorm.data(), rnorm.size() * 4, cudaMemcpyHostToDevice), "crN"); + check(cudaMemcpy(dD, qd.data(), qd.size() * 2, cudaMemcpyHostToDevice), "crD"); + check(cudaMemcpy(dU, qu.data(), qu.size() * 2, cudaMemcpyHostToDevice), "crU"); + check(cudaMemcpy(dJ, qi.data(), qi.size() * 2, cudaMemcpyHostToDevice), "crJ"); + + const strata::kernels::GrShapes rsh{rn, rhc, rlr}; + void* rws_raw = nullptr; + check(cudaMalloc(&rws_raw, strata::kernels::gr_workspace_bytes(rsh)), "m rws"); + strata::kernels::GrWorkspace rws; + strata::kernels::gr_workspace_init(rsh, rws_raw, rws); + for (int mode = 0; mode < 3; ++mode) { + select_activation_mode(mode); + strata::kernels::gr_read(dR, dN, dD, dU, dJ, eps, rsh, rws, dM, dI, nullptr); + std::vector gm((size_t) rn), gi((size_t) rhc); + check(cudaMemcpy(gm.data(), dM, gm.size() * 4, cudaMemcpyDeviceToHost), "cgm"); + check(cudaMemcpy(gi.data(), dI, gi.size() * 4, cudaMemcpyDeviceToHost), "cgi"); + + Opts precision; + precision.round_activation = mode == 0; + std::vector wm, wi; + reference(rR, rnorm, rdown, rup, rinj, eps, rn, rhc, rlr, precision, wm, wi); + const double rm = rel_diff(wm, gm), ri = rel_diff(wi, gi); + const bool ok = rm <= 1e-4 && ri <= 1e-4; + std::printf("\n real dims 2560/4/320 %s %s (mixed %.3e, inject %.3e)\n", + activation_mode_name(mode), ok ? "pass" : "*** FAIL ***", rm, ri); + if (!ok) ++bad; + } + select_activation_mode(0); + cudaFree(rws_raw); + cudaFree(dR); cudaFree(dN); cudaFree(dD); cudaFree(dU); cudaFree(dJ); cudaFree(dM); cudaFree(dI); + } + + bad += scalar_activation_contract(); + std::printf("\ngr_read/gr_write: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("gr_parity OK\n"); + return 0; +} diff --git a/src/kernels/iq_parity.cpp b/src/kernels/iq_parity.cpp new file mode 100644 index 00000000..f9ffb8dc --- /dev/null +++ b/src/kernels/iq_parity.cpp @@ -0,0 +1,87 @@ +// src/kernels/iq_parity.cpp - plan v0.3 P6: the i-quant kernels against gguf-py on real rows. +// +// python tools/iq_fixture.py --out logs/iq_fixture && build/iq_parity logs/iq_fixture +// +// Dequant must match gguf-py's values to fp32 rounding; the MMVQ dot (q8_1 activations) must match the float +// matrix-vector product within the activation rounding (a few 1e-3 relative). +#include "strata/kernels/iq_kernels.hpp" +#include "strata/kernels/native_mmvq.hpp" + +#include + +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + const std::string dir = argc > 1 ? argv[1] : "logs/iq_fixture"; + const char* names[] = {"IQ2_XXS", "IQ2_XS", "IQ2_S", "IQ3_XXS", "IQ3_S", "IQ1_M", "IQ4_NL", "IQ4_XS", "Q2_0", "Q3_K"}; + int failures = 0; + cudaStream_t s; + cudaStreamCreate(&s); + for (const char* nm : names) { + std::FILE* f = std::fopen((dir + "/" + nm + ".bin").c_str(), "rb"); + std::FILE* g = std::fopen((dir + "/" + nm + ".f32").c_str(), "rb"); + if (!f || !g) { std::printf("%-8s missing fixture\n", nm); ++failures; continue; } + int hdr[3]; + std::fread(hdr, 4, 3, f); + const int type = hdr[0], rows = hdr[1], cols = hdr[2]; + std::vector raw; + { uint8_t buf[1 << 16]; size_t n; while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) raw.insert(raw.end(), buf, buf + n); } + std::vector ref((size_t) rows * cols); + std::fread(ref.data(), 4, ref.size(), g); + std::fclose(f); + std::fclose(g); + void* dw = nullptr; + float* dq = nullptr; + cudaMalloc(&dw, raw.size()); + cudaMalloc(&dq, ref.size() * 4); + cudaMemcpy(dw, raw.data(), raw.size(), cudaMemcpyHostToDevice); + double dq_err = 0.0; + if (strata::kernels::iq_supported(type) && ((size_t) rows * cols) % 256 == 0) { + strata::kernels::iq_dequant_f32(type, dw, (int64_t) rows * cols, dq, s); + std::vector got(ref.size()); + cudaMemcpy(got.data(), dq, got.size() * 4, cudaMemcpyDeviceToHost); + double num = 0, den = 0; + for (size_t i = 0; i < ref.size(); ++i) { num += std::fabs(got[i] - ref[i]); den += std::fabs(ref[i]); } + dq_err = num / (den + 1e-30); + } + // the dot, two columns + std::mt19937 rng(7); + std::normal_distribution nd(0.f, 1.f); + std::vector x((size_t) 2 * cols); + for (auto& v : x) v = nd(rng); + float* dx = nullptr; + void* xq = nullptr; + float* dy = nullptr; + cudaMalloc(&dx, x.size() * 4); + cudaMalloc(&xq, (size_t) 2 * cols / 32 * 36); + cudaMalloc(&dy, (size_t) 2 * rows * 4); + cudaMemcpy(dx, x.data(), x.size() * 4, cudaMemcpyHostToDevice); + strata::kernels::quantize_q8_1_rows(dx, 2, cols, xq, s); + try { + strata::kernels::native_mmvq(type, dw, xq, dy, cols, rows, 2, s); + } catch (const std::exception& e) { std::printf("%-8s mmvq: %s\n", nm, e.what()); ++failures; continue; } + std::vector y((size_t) 2 * rows); + cudaStreamSynchronize(s); + cudaMemcpy(y.data(), dy, y.size() * 4, cudaMemcpyDeviceToHost); + double num = 0, den = 0; + for (int c = 0; c < 2; ++c) + for (int r = 0; r < rows; ++r) { + double acc = 0; + for (int k = 0; k < cols; ++k) acc += (double) ref[(size_t) r * cols + k] * x[(size_t) c * cols + k]; + num += std::fabs(y[(size_t) c * rows + r] - acc); + den += std::fabs(acc); + } + const double mm_err = num / (den + 1e-30); + const bool ok = dq_err < 1e-6 && mm_err < 2e-2; + std::printf("%-8s type %2d %4d x %5d dequant rel %.2e mmvq rel %.2e %s\n", nm, type, rows, cols, dq_err, mm_err, + ok ? "ok" : "FAIL"); + if (!ok) ++failures; + cudaFree(dw); cudaFree(dq); cudaFree(dx); cudaFree(xq); cudaFree(dy); + } + std::printf("iq_parity: %d failures\n", failures); + return failures ? 1 : 0; +} diff --git a/src/kernels/kv_q8_parity.cpp b/src/kernels/kv_q8_parity.cpp new file mode 100644 index 00000000..733d7c7f --- /dev/null +++ b/src/kernels/kv_q8_parity.cpp @@ -0,0 +1,141 @@ +// src/kernels/kv_q8_parity.cpp - plan v0.3 P7: INT8 KV append/gather against a host reference (GPU, no model). +// +// Appends random K/V cells at scattered positions through a non-identity page table into both the INT8 pools +// (kv_append_q8_step) and the FP16 pools (kv_append_step), gathers random cell sets from both, and checks: +// 1. INT8 codes and FP16 scales are BITWISE equal to the host reference (same f16_from_f32 rounding); +// 2. the INT8 gather is BITWISE equal to the host dequantization of those codes; +// 3. against the FP16 path, the INT8 values differ by at most half a quantization step of their group plus the +// fp16 rounding of both sides (0.624 steps). +#include "strata/kernels/f16_bits.hpp" +#include "strata/kernels/kv_q8.hpp" +#include "strata/kernels/qsa.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace k = strata::kernels; + +namespace { +int g_fail = 0; +void ck(cudaError_t e, const char* w) { + if (e != cudaSuccess) { std::fprintf(stderr, "%s: %s\n", w, cudaGetErrorString(e)); std::exit(2); } +} +template T* dalloc(size_t n) { T* p = nullptr; ck(cudaMalloc(&p, n * sizeof(T) + 64), "malloc"); ck(cudaMemset(p, 0, n * sizeof(T) + 64), "memset"); return p; } +} // namespace + +int main() { + k::QsaShapes s = k::qsa_real_shapes(); + s.page_size = 64; // several pages in a small pool + const int H = (int) s.n_head_kv, D = (int) s.head_dim, P = (int) s.page_size, G = D / k::KV_Q8_GROUP; + const int pages = 8, cells = pages * P; + std::mt19937 rng(17); + std::vector table(pages); + for (int i = 0; i < pages; ++i) table[i] = (i * 5 + 3) % pages; // a non-identity permutation + int32_t* d_table = dalloc(pages); + ck(cudaMemcpy(d_table, table.data(), pages * 4, cudaMemcpyHostToDevice), "table"); + int8_t *kq = dalloc((size_t) cells * H * D), *vq = dalloc((size_t) cells * H * D); + uint16_t *ks = dalloc((size_t) cells * H * G), *vs = dalloc((size_t) cells * H * G); + uint16_t *kp = dalloc((size_t) cells * H * D), *vp = dalloc((size_t) cells * H * D); + float *kcur = dalloc(H * D), *vcur = dalloc(H * D); + int32_t* step = dalloc(k::kStepCount); + std::vector> hk(cells), hv(cells); + std::normal_distribution nd(0.f, 1.f); + std::vector positions(cells); + for (int i = 0; i < cells; ++i) positions[i] = i; + std::shuffle(positions.begin(), positions.end(), rng); + const int n_fill = cells - 37; // leave some cells empty + for (int n = 0; n < n_fill; ++n) { + const int pos = positions[n]; + std::vector kv(H * D), vv(H * D); + const float scale = (n % 7 == 0) ? 1e-3f : (n % 11 == 0 ? 40.f : 1.f); // small and large groups + for (auto& x : kv) x = nd(rng) * scale; + for (auto& x : vv) x = nd(rng) * scale; + if (n % 13 == 0) std::fill(kv.begin(), kv.begin() + 64, 0.f); // an all-zero group + hk[pos] = kv; hv[pos] = vv; + int32_t hstep[k::kStepCount] = {pos, pos + 1, 0, 0}; + ck(cudaMemcpy(step, hstep, sizeof hstep, cudaMemcpyHostToDevice), "step"); + ck(cudaMemcpy(kcur, kv.data(), kv.size() * 4, cudaMemcpyHostToDevice), "k"); + ck(cudaMemcpy(vcur, vv.data(), vv.size() * 4, cudaMemcpyHostToDevice), "v"); + k::kv_append_q8_step(kq, vq, ks, vs, d_table, step, kcur, vcur, s, nullptr); + k::kv_append_step(kp, vp, d_table, step, kcur, vcur, s, nullptr); + ck(cudaDeviceSynchronize(), "append"); + } + // 1. codes and scales bitwise vs the host reference + std::vector hkq((size_t) cells * H * D), hvq(hkq.size()); + std::vector hks((size_t) cells * H * G), hvs(hks.size()); + ck(cudaMemcpy(hkq.data(), kq, hkq.size(), cudaMemcpyDeviceToHost), "d2h"); + ck(cudaMemcpy(hvq.data(), vq, hvq.size(), cudaMemcpyDeviceToHost), "d2h"); + ck(cudaMemcpy(hks.data(), ks, hks.size() * 2, cudaMemcpyDeviceToHost), "d2h"); + ck(cudaMemcpy(hvs.data(), vs, hvs.size() * 2, cudaMemcpyDeviceToHost), "d2h"); + long bad_codes = 0; + for (int n = 0; n < n_fill; ++n) { + const int pos = positions[n]; + for (int kvsel = 0; kvsel < 2; ++kvsel) + for (int h = 0; h < H; ++h) + for (int g = 0; g < G; ++g) { + const float* x = (kvsel ? hv[pos] : hk[pos]).data() + h * D + g * 64; + float amax = 0.f; + for (int t = 0; t < 64; ++t) amax = std::fmax(amax, std::fabs(x[t])); + const uint16_t sb = k::f16_from_f32(amax / 127.0f); + const float sf = k::f32_from_f16(sb); + const long long row = ((long long) table[pos / P] * H + h) * P + pos % P; + if ((kvsel ? hvs : hks)[row * G + g] != sb) ++bad_codes; + for (int t = 0; t < 64; ++t) { + int q = sf > 0.f ? (int) std::nearbyint(x[t] / sf) : 0; + q = std::clamp(q, -127, 127); + if ((kvsel ? hvq : hkq)[row * D + g * 64 + t] != (int8_t) q) ++bad_codes; + } + } + } + if (bad_codes) { std::fprintf(stderr, "FAIL: %ld codes/scales differ from the host reference\n", bad_codes); ++g_fail; } + // 2 + 3. gather random selections from both paths + const int max_ids = 200; + int32_t* d_ids = dalloc(max_ids); + uint16_t *k8 = dalloc((size_t) max_ids * H * D), *v8 = dalloc((size_t) max_ids * H * D); + uint16_t *k16 = dalloc((size_t) max_ids * H * D), *v16 = dalloc((size_t) max_ids * H * D); + double worst = 0.0; + for (int trial = 0; trial < 20; ++trial) { + const int n_ids = 1 + (int) (rng() % max_ids); + std::vector ids(n_ids); + for (auto& id : ids) id = positions[rng() % n_fill]; + ck(cudaMemcpy(d_ids, ids.data(), n_ids * 4, cudaMemcpyHostToDevice), "ids"); + int32_t hstep[k::kStepCount] = {0, 0, 0, n_ids}; + ck(cudaMemcpy(step, hstep, sizeof hstep, cudaMemcpyHostToDevice), "step"); + k::kv_gather_q8_step(kq, vq, ks, vs, d_table, d_ids, step, max_ids, s, k8, v8, nullptr); + k::kv_gather_step(kp, vp, d_table, d_ids, step, max_ids, s, k16, v16, nullptr); + ck(cudaDeviceSynchronize(), "gather"); + std::vector a8((size_t) n_ids * H * D), b8(a8.size()), a16(a8.size()), b16(a8.size()); + ck(cudaMemcpy(a8.data(), k8, a8.size() * 2, cudaMemcpyDeviceToHost), "d2h"); + ck(cudaMemcpy(b8.data(), v8, b8.size() * 2, cudaMemcpyDeviceToHost), "d2h"); + ck(cudaMemcpy(a16.data(), k16, a16.size() * 2, cudaMemcpyDeviceToHost), "d2h"); + ck(cudaMemcpy(b16.data(), v16, b16.size() * 2, cudaMemcpyDeviceToHost), "d2h"); + for (int j = 0; j < n_ids; ++j) + for (int h = 0; h < H; ++h) { + const long long row = ((long long) table[ids[j] / P] * H + h) * P + ids[j] % P; + for (int d = 0; d < D; ++d) { + const size_t o = ((size_t) j * H + h) * D + d; + for (int kvsel = 0; kvsel < 2; ++kvsel) { + const float sf = k::f32_from_f16((kvsel ? hvs : hks)[row * G + d / 64]); + const int8_t q = (kvsel ? hvq : hkq)[row * D + d]; + const uint16_t want = k::f16_from_f32((float) q * sf); + const uint16_t got = (kvsel ? b8 : a8)[o]; + if (got != want) { if (g_fail < 5) std::fprintf(stderr, "FAIL: gather id %d h %d d %d\n", j, h, d); ++g_fail; } + const float ref = k::f32_from_f16((kvsel ? b16 : a16)[o]); + const double err = std::fabs(k::f32_from_f16(got) - ref) / (sf > 0 ? sf : 1.0); + worst = std::max(worst, err); + } + } + } + } + // |x - q*sf| <= sf/2 (the stored scale keeps |x|/sf <= 127.06, so no clamping), plus the fp16 rounding of each + // side: at most 2^-11 relative of a value up to 127*sf, i.e. 127/2048 = 0.062 steps each -> 0.5 + 0.124 + if (!(worst <= 0.5 + 2.0 * 127.0 / 2048.0 + 1e-3)) { std::fprintf(stderr, "FAIL: INT8 vs FP16 error %.3f steps\n", worst); ++g_fail; } + std::printf("kv_q8_parity: %s (worst INT8-vs-FP16 error %.3f quantization steps)\n", g_fail ? "FAILED" : "OK", worst); + return g_fail ? 1 : 0; +} diff --git a/src/kernels/native_expert_parity.cpp b/src/kernels/native_expert_parity.cpp new file mode 100644 index 00000000..9d37dfe3 --- /dev/null +++ b/src/kernels/native_expert_parity.cpp @@ -0,0 +1,204 @@ +// src/kernels/native_expert_parity.cpp - plan v0.3 P6: one native expert three ways, on real GGUF rows. +// +// build/native_expert_parity [layer ...] +// +// (a) float reference: ggml's own dequantizer (`to_float`) and a float SwiGLU expert, (b) the CPU path +// (ggml-cpu vec_dot with its quantized activations), (c) the GPU path (`native_expert_grouped`, q8_1 +// activations). (b) and (c) each differ from (a) by their activation rounding only (a few 1e-3 relative). +#include "strata/artifact/gguf_reader.hpp" +#include "strata/kernels/cpu/native_expert.hpp" +#include "strata/kernels/cpu/expert.hpp" +#include "strata/kernels/cpu/iq_avx512.hpp" +#include "ggml-cpu.h" +#include "strata/kernels/iq_kernels.hpp" + +#include "ggml.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace cpu = strata::kernels::cpu; + +static double rel(const std::vector& a, const std::vector& b) { + double n = 0, d = 0; + for (size_t i = 0; i < a.size(); ++i) { n += std::fabs((double) a[i] - b[i]); d += std::fabs((double) b[i]); } + return n / (d + 1e-30); +} + +int main(int argc, char** argv) { + if (argc < 2) { std::fprintf(stderr, "usage: native_expert_parity [layer ...]\n"); return 2; } + strata::GgufFile gguf(argv[1]); + std::vector layers; + for (int i = 2; i < argc; ++i) layers.push_back(std::atoi(argv[i])); + if (layers.empty()) layers = {0, 1, 2, 3, 20, 47}; + const int NT = 3, E = 7; + const int64_t H = 2560, FF = 640; + int failures = 0; + cudaStream_t s; + cudaStreamCreate(&s); + for (int l : layers) { + const strata::TensorInfo* t[3] = {}; + const char* roles[3] = {"gate", "up", "down"}; + for (const auto& ti : gguf.tensors()) + for (int r = 0; r < 3; ++r) + if (ti.name == "blk." + std::to_string(l) + ".ffn_" + roles[r] + "_exps.weight") t[r] = &ti; + if (!t[0] || !t[1] || !t[2]) { std::printf("layer %d: no expert tensors\n", l); ++failures; continue; } + cpu::NativeFmt f; + std::string err; + if (!cpu::native_fmt((int) t[0]->type, (int) t[2]->type, H, FF, f, err)) { + std::printf("layer %d: %s\n", l, err.c_str()); ++failures; continue; + } + std::vector blob(f.bytes); + std::memcpy(blob.data(), gguf.tensor_data(*t[0]) + (size_t) E * f.up_off, f.up_off); + std::memcpy(blob.data() + f.up_off, gguf.tensor_data(*t[1]) + (size_t) E * f.up_off, f.up_off); + std::memcpy(blob.data() + f.down_off, gguf.tensor_data(*t[2]) + (size_t) E * (f.bytes - f.down_off), + f.bytes - f.down_off); + // (a) the float reference + const auto* tg = ggml_get_type_traits((ggml_type) f.gu_type); + const auto* td = ggml_get_type_traits((ggml_type) f.d_type); + std::vector G((size_t) FF * H), U((size_t) FF * H), D((size_t) H * FF); + for (int64_t r = 0; r < FF; ++r) { + tg->to_float(blob.data() + r * f.gu_row, G.data() + r * H, H); + tg->to_float(blob.data() + f.up_off + r * f.gu_row, U.data() + r * H, H); + } + for (int64_t r = 0; r < H; ++r) td->to_float(blob.data() + f.down_off + r * f.d_row, D.data() + r * FF, FF); + std::mt19937 rng(11 + l); + std::normal_distribution nd(0.f, 1.f); + std::vector x((size_t) NT * H); + for (auto& v : x) v = nd(rng); + std::vector ref((size_t) NT * H), got_c((size_t) NT * H), got_g((size_t) NT * H); + for (int k = 0; k < NT; ++k) { + std::vector h(FF); + for (int64_t r = 0; r < FF; ++r) { + double g = 0, u = 0; + for (int64_t i = 0; i < H; ++i) { g += (double) G[r * H + i] * x[k * H + i]; u += (double) U[r * H + i] * x[k * H + i]; } + h[r] = (float) (g / (1.0 + std::exp(-g)) * u); + } + for (int64_t r = 0; r < H; ++r) { + double o = 0; + for (int64_t i = 0; i < FF; ++i) o += (double) D[r * FF + i] * h[i]; + ref[k * H + r] = (float) o; + } + } + // (b) the CPU + { + std::vector> act(NT, std::vector(cpu::kNativeActBytes)); + std::vector> hq(NT, std::vector(cpu::kNativeHBytes)); + std::vector> ff(NT, std::vector(FF)); + const void* a[NT]; + float* ffp[NT]; + const void* hp[NT]; + float* op[NT]; + for (int k = 0; k < NT; ++k) { + cpu::native_quant_act(f, x.data() + k * H, act[k].data()); + a[k] = act[k].data(); + ffp[k] = ff[k].data(); + } + cpu::native_gu_rows(f, blob.data(), a, NT, ffp, 0, (int) FF); + if (cpu::iq512_supported(f.gu_type)) { + // the AVX-512 rows against ggml's own vec_dot, same Q8_K activations: float-order differences only + std::vector g512((size_t) NT * FF), gref((size_t) NT * FF); + float* gp[NT]; + for (int k = 0; k < NT; ++k) gp[k] = g512.data() + k * FF; + cpu::iq512_rows(f.gu_type, blob.data(), f.gu_row, (int) H, a, NT, gp, 0, (int) FF); + const auto* tc = ggml_get_type_traits_cpu((ggml_type) f.gu_type); + for (int k = 0; k < NT; ++k) + for (int64_t r = 0; r < FF; ++r) + tc->vec_dot((int) H, &gref[k * FF + r], 0, blob.data() + r * f.gu_row, 0, a[k], 0, 1); + { // single-thread throughput, the weights cache-resident (compute bound) + float* f1[1] = {ffp[0]}; + const int it = 50; + auto t0 = std::chrono::steady_clock::now(); + for (int i = 0; i < it; ++i) cpu::iq512_gu_rows(f.gu_type, blob.data(), f.gu_row, f.up_off, (int) H, a, 1, f1, 0, (int) FF); + auto t1 = std::chrono::steady_clock::now(); + for (int i = 0; i < it; ++i) + for (int64_t r = 0; r < FF; ++r) { + float gg, uu; + tc->vec_dot((int) H, &gg, 0, blob.data() + r * f.gu_row, 0, a[0], 0, 1); + tc->vec_dot((int) H, &uu, 0, blob.data() + f.up_off + r * f.gu_row, 0, a[0], 0, 1); + } + auto t2 = std::chrono::steady_clock::now(); + const double us512 = std::chrono::duration(t1 - t0).count() / it; + const double usg = std::chrono::duration(t2 - t1).count() / it; + std::printf(" gate+up one token one thread: avx512 %.0f us (%.2f GB/s), ggml %.0f us (%.2f GB/s)\n", + us512, 2.0 * f.up_off / us512 / 1e3, usg, 2.0 * f.up_off / usg / 1e3); + auto t3 = std::chrono::steady_clock::now(); + for (int i = 0; i < it; ++i) cpu::iq512_gu_rows(f.gu_type, blob.data(), f.gu_row, f.up_off, (int) H, a, NT, ffp, 0, (int) FF); + auto t4 = std::chrono::steady_clock::now(); + std::printf(" gate+up %d tokens one thread: avx512 %.0f us vs ggml %d x %.0f us\n", NT, + std::chrono::duration(t4 - t3).count() / it, NT, usg); + } + std::printf(" %s AVX-512 gate rows vs ggml vec_dot: rel %.2e\n", + ggml_type_name((ggml_type) f.gu_type), rel(g512, gref)); + } + for (int k = 0; k < NT; ++k) { + cpu::native_quant_h(f, ff[k].data(), hq[k].data()); + hp[k] = hq[k].data(); + op[k] = got_c.data() + k * H; + } + cpu::native_down_rows(f, blob.data(), hp, NT, op, 0, (int) H); + if (f.d_type == 42) { + // (b2) the AVX-512 GGUF-layout Q2_0 kernel the pool uses for Q2_0 down projections + std::vector a2(NT); + const cpu::ActQ* ap[NT]; + std::vector alt((size_t) NT * H); + float* altp[NT]; + for (int k = 0; k < NT; ++k) { + cpu::act_quant_q8_1(ff[k].data(), (int) FF, a2[k]); + ap[k] = &a2[k]; + altp[k] = alt.data() + k * H; + } + cpu::q2_0_gguf_rows_multi(blob.data() + f.down_off, f.d_row, (int) (FF / 64), ap, NT, altp, 0, (int) H); + std::printf(" q2_0 AVX-512 down vs ggml down: rel %.2e\n", rel(alt, got_c)); + } + } + // (c) the GPU: one group holding the NT entries + { + const auto L = strata::kernels::native_expert_layout(f.gu_type, f.d_type, H, FF); + void *dblob, *dx, *dxq, *dscr; + float* dout; + unsigned long long* dptr; + int32_t *dstart, *dn, *ddst, *dtok; + cudaMalloc(&dblob, blob.size()); + cudaMalloc(&dx, x.size() * 4); + cudaMalloc(&dxq, (size_t) NT * H / 32 * 36); + cudaMalloc(&dscr, strata::kernels::native_expert_scratch_bytes(NT, FF)); + cudaMalloc((void**) &dout, (size_t) NT * H * 4); + cudaMalloc((void**) &dptr, 8); + cudaMalloc((void**) &dstart, 8); + cudaMalloc((void**) &dn, 4); + cudaMalloc((void**) &ddst, NT * 4); + cudaMalloc((void**) &dtok, NT * 4); + cudaMemcpy(dblob, blob.data(), blob.size(), cudaMemcpyHostToDevice); + cudaMemcpy(dx, x.data(), x.size() * 4, cudaMemcpyHostToDevice); + const unsigned long long p = (unsigned long long) dblob; + const int32_t st[2] = {0, NT}, one = 1, idx[NT] = {0, 1, 2}; + cudaMemcpy(dptr, &p, 8, cudaMemcpyHostToDevice); + cudaMemcpy(dstart, st, 8, cudaMemcpyHostToDevice); + cudaMemcpy(dn, &one, 4, cudaMemcpyHostToDevice); + cudaMemcpy(ddst, idx, NT * 4, cudaMemcpyHostToDevice); + cudaMemcpy(dtok, idx, NT * 4, cudaMemcpyHostToDevice); + strata::kernels::quantize_q8_1_rows((const float*) dx, NT, H, dxq, s); + strata::kernels::native_expert_grouped(L, dptr, dstart, dn, ddst, dtok, 1, NT, dxq, dscr, dout, s); + cudaStreamSynchronize(s); + cudaMemcpy(got_g.data(), dout, got_g.size() * 4, cudaMemcpyDeviceToHost); + cudaFree(dblob); cudaFree(dx); cudaFree(dxq); cudaFree(dscr); cudaFree(dout); cudaFree(dptr); + cudaFree(dstart); cudaFree(dn); cudaFree(ddst); cudaFree(dtok); + } + const double ec = rel(got_c, ref), eg = rel(got_g, ref), ecg = rel(got_c, got_g); + const bool ok = ec < 3e-2 && eg < 3e-2 && std::isfinite(ec) && std::isfinite(eg); + std::printf("layer %2d %-8s/%-7s blob %8zu cpu rel %.2e gpu rel %.2e cpu-gpu %.2e %s\n", l, + ggml_type_name((ggml_type) f.gu_type), ggml_type_name((ggml_type) f.d_type), f.bytes, ec, eg, ecg, + ok ? "ok" : "FAIL"); + if (!ok) ++failures; + } + std::printf("native_expert_parity: %d failures\n", failures); + return failures ? 1 : 0; +} diff --git a/src/kernels/ngram.cpp b/src/kernels/ngram.cpp new file mode 100644 index 00000000..705ced66 --- /dev/null +++ b/src/kernels/ngram.cpp @@ -0,0 +1,348 @@ +// src/kernels/ngram.cpp - P2.S4: the PLE n-gram hash and the IQ4_NL table read. +// +// See include/strata/kernels/ngram.hpp for the semantics, the rival readings, and the note on MADV_RANDOM. +#include "strata/kernels/ngram.hpp" +#include "strata/artifact/gguf_reader.hpp" +#include "strata/kernels/f16_bits.hpp" +#include "strata/ngram/ple_reader.hpp" + +#include +#include +#include +#include + +#if defined(_WIN32) +// `PrefetchVirtualMemory` (memoryapi.h, Windows 8+) is the whole point of the change in `gather` below. +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#endif + +namespace strata::kernels { + +namespace { +/// The A/B arm. Host-token-path only, so it needs no atomics; see the note on `ple_prefetch_enable`. +bool g_ple_prefetch = true; +} // namespace + +void ple_prefetch_enable(bool on) { g_ple_prefetch = on; } +bool ple_prefetch_enabled() { return g_ple_prefetch; } + +PleConsts ple_artifact_consts() { + // docs/gguf-dump-shard1.txt, verbatim. Written once here rather than derived, because `head_offsets` is + // ALSO in the metadata as its own array - and deriving one from the other would hide a mismatch between + // them instead of surfacing it. The parity test checks the two agree. + PleConsts c{}; + c.mult[0] = 23703573157769ull; + c.mult[1] = 20109073645365ull; + c.mult[2] = 8052911324071ull; + const uint64_t vocab[PLE_N_HEADS] = { + 20000003, 20000023, 20000033, 20000047, 20000059, 20000063, 20000069, 20000077, + 20000081, 20000093, 20000107, 20000147, 20000153, 20000159, 20000161, 20000171}; + const uint64_t offset[PLE_N_HEADS] = { + 0, 20000003, 40000026, 60000059, 80000106, 100000165, 120000228, 140000297, + 160000374, 180000455, 200000548, 220000655, 240000802, 260000955, 280001114, 300001275}; + for (int i = 0; i < PLE_N_HEADS; ++i) { + c.vocab[i] = vocab[i]; + c.offset[i] = offset[i]; + } + return c; +} + +uint64_t ngram_mixed(const int64_t* ctx, const uint64_t* mult, int n) { + // The first term is an ASSIGNMENT and the rest are XORed into it, which is how the source writes it + // (`uint64_t mixed = ctx[0]*m[0]; for j=1.. mixed ^= ctx[j]*m[j];`). Every product wraps mod 2^64, + // which is what the `(uint64_t)` casts in the source make explicit. + uint64_t mixed = (uint64_t) ctx[0] * mult[0]; + for (int j = 1; j < n; ++j) mixed ^= (uint64_t) ctx[j] * mult[j]; + return mixed; +} + +void ngram_rows(const int32_t* tokens, const int32_t* prev, int n_tokens, const PleConsts& c, uint32_t* out) { + const int n_prev = NGRAM_SIZE - 1; + for (int i = 0; i < n_tokens; ++i) { + int64_t ctx[NGRAM_SIZE]; + ctx[0] = tokens[i]; + bool cut = false; + for (int s = 1; s < NGRAM_SIZE; ++s) { + // `prev` is OLDEST FIRST, so predecessor `s` positions back is entry (n_prev - s): s=1 reads the + // NEWEST. Reading index (s-1) instead walks the window backwards, which still produces indices + // in range and so cannot be caught by a range check - only by an oracle. + const int32_t t = cut ? TOKEN_NULL : prev[i * n_prev + (n_prev - s)]; + // The cut is evaluated BEFORE the value is stored, so the position whose predecessor was EOS is + // itself EOS. Storing first and then cutting would leave position s holding the real token while + // position s+1 became EOS - one token of history too much. + cut = cut || t < 0 || t == PLE_EOS_TOKEN_ID; + ctx[s] = cut ? PLE_EOS_TOKEN_ID : t; + } + for (int n = 2; n <= NGRAM_SIZE; ++n) { + const uint64_t mixed = ngram_mixed(ctx, c.mult, n); + const int base = (n - 2) * HEADS_PER_NGRAM; + for (int g = 0; g < HEADS_PER_NGRAM; ++g) { + const int h = base + g; + out[i * PLE_N_HEADS + h] = (uint32_t) (mixed % c.vocab[h] + c.offset[h]); + } + } + } +} + +namespace { +const int8_t kIq4Nl[16] = {-127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113}; +} + +int iq4nl_code(int code) { return kIq4Nl[code & 15]; } + +void iq4nl_dequant_row(const uint8_t* row, float* out160) { + for (int b = 0; b < PLE_HEAD_DIM / 32; ++b) { + const uint8_t* blk = row + (size_t) b * 18; + uint16_t dbits; + std::memcpy(&dbits, blk, 2); + const float d = f32_from_f16(dbits); + const uint8_t* qs = blk + 2; + // SPLIT HALVES: qs[j] holds elements j and j+16, not 2j and 2j+1. + for (int j = 0; j < 16; ++j) { + out160[b * 32 + j] = d * (float) kIq4Nl[qs[j] & 0x0F]; + out160[b * 32 + j + 16] = d * (float) kIq4Nl[qs[j] >> 4]; + } + } +} + +// --------------------------------------------------------------------------------------------------- +struct PleTable::Impl { + GgufFile* file = nullptr; + const uint8_t* data = nullptr; + uint64_t n_rows = 0; + mutable uint64_t bytes_read = 0; + // Direct mode (plan v0.3 P2): the mapping above is released after the header parse and every row comes + // from an unbuffered SSD read into `raw`. + PleIo mode = PleIo::Mmap; + strata::ngram::PleReader reader; + strata::ngram::PleReader::Ticket ticket; + bool pending = false; + uint32_t rows[PLE_N_HEADS] = {}; + uint8_t raw[PLE_N_HEADS * PLE_ROW_BYTES] = {}; +}; + +PleTable::PleTable() : impl_(new Impl) {} +PleTable::~PleTable() { close(); delete impl_; } + +bool PleTable::open(const std::string& gguf_path, std::string& err) { + return open(gguf_path, err, PleIoOptions{}); +} + +bool PleTable::open(const std::string& gguf_path, std::string& err, const PleIoOptions& io) { + close(); + try { + impl_->file = new GgufFile(gguf_path); + } catch (const std::exception& e) { + err = e.what(); + return false; + } + const TensorInfo* t = impl_->file->find("per_layer_token_embd.weight"); + if (t == nullptr) { + err = "per_layer_token_embd.weight is not in " + gguf_path; + close(); + return false; + } + // [160, 320001536]: ne0 = 160 is the FAST axis, so the ROW index is shape[1] and a row is contiguous. + if (t->shape.size() != 2 || t->shape[0] != (uint64_t) PLE_HEAD_DIM) { + err = "per_layer_token_embd.weight has an unexpected shape"; + close(); + return false; + } + if (std::strcmp(t->type_name(), "IQ4_NL") != 0) { + err = std::string("per_layer_token_embd.weight is ") + t->type_name() + ", not IQ4_NL"; + close(); + return false; + } + impl_->n_rows = t->shape[1]; + impl_->data = impl_->file->tensor_data(*t); + + // THE CHECK THAT MAKES THE OFFSET FALSIFIABLE. The manifest's `shard2_tensor.offset` is 0, but that is + // the offset within the GGUF's DATA SECTION: the file's first 192 bytes are a header, and reading at 0 + // would decode the header plus 192 bytes of shifted rows - still plausible IQ4_NL, and wrong for every + // row. `GgufFile` parses the header, so `tensor_data` is already correct; this asserts the tensor + // exactly fills the file from there, which is what makes the whole arrangement checkable rather than + // assumed. A wrong data offset would leave a different remainder. + const uint64_t need = impl_->n_rows * (uint64_t) PLE_ROW_BYTES; + const uint64_t have = impl_->file->file_size() - impl_->file->data_start(); + if (need != have) { + char buf[256]; + std::snprintf(buf, sizeof buf, + "PLE table size mismatch: %llu rows x %d B = %llu, but the file holds %llu from " + "data_start %llu", + (unsigned long long) impl_->n_rows, PLE_ROW_BYTES, (unsigned long long) need, + (unsigned long long) have, (unsigned long long) impl_->file->data_start()); + err = buf; + close(); + return false; + } + if (io.mode == PleIo::Direct) { + // The parse above is the validated source of the offset; the mapping itself is not kept, so no page of + // the table can enter this process's working set or the file cache through it. + const uint64_t table_offset = impl_->file->data_start() + t->offset; + const uint64_t n_rows = impl_->n_rows; + delete impl_->file; + impl_->file = nullptr; + impl_->data = nullptr; + if (!impl_->reader.open(gguf_path, table_offset, n_rows, io.max_inflight, io.cache_rows, err, io.io_thread)) { + close(); + return false; + } + impl_->n_rows = n_rows; + } + impl_->mode = io.mode; + return true; +} + +void PleTable::close() { + impl_->reader.close(); + impl_->pending = false; + impl_->mode = PleIo::Mmap; + delete impl_->file; + impl_->file = nullptr; + impl_->data = nullptr; + impl_->n_rows = 0; +} + +bool PleTable::is_open() const { return impl_->data != nullptr || impl_->reader.is_open(); } +PleIo PleTable::mode() const { return impl_->mode; } +uint64_t PleTable::rows() const { return impl_->n_rows; } +uint64_t PleTable::bytes_read() const { return impl_->bytes_read; } + +void PleTable::read_row(uint32_t row, float* out160) const { + if (impl_->mode == PleIo::Direct && impl_->reader.is_open()) { + uint8_t raw[PLE_ROW_BYTES]; + std::string err; + const auto t = impl_->reader.issue(&row, 1, raw); + if (!impl_->reader.collect(t, err)) { + std::memset(out160, 0, (size_t) PLE_HEAD_DIM * sizeof(float)); + return; + } + iq4nl_dequant_row(raw, out160); + impl_->bytes_read += PLE_ROW_BYTES; + return; + } + if (impl_->data == nullptr || row >= impl_->n_rows) { + std::memset(out160, 0, (size_t) PLE_HEAD_DIM * sizeof(float)); + return; + } + iq4nl_dequant_row(impl_->data + (size_t) row * PLE_ROW_BYTES, out160); + impl_->bytes_read += PLE_ROW_BYTES; +} + +bool PleTable::issue(const uint32_t* rows16) { + std::memcpy(impl_->rows, rows16, sizeof impl_->rows); + if (impl_->mode == PleIo::Direct) { + if (impl_->pending) return false; // one token in flight per table + impl_->ticket = impl_->reader.issue(impl_->rows, PLE_N_HEADS, impl_->raw); + impl_->pending = true; + return true; + } +#if defined(_WIN32) + if (impl_->data != nullptr && g_ple_prefetch) { + WIN32_MEMORY_RANGE_ENTRY ranges[PLE_N_HEADS]; + ULONG_PTR n = 0; + for (int h = 0; h < PLE_N_HEADS; ++h) { + if (rows16[h] >= impl_->n_rows) continue; + ranges[n].VirtualAddress = (PVOID) (impl_->data + (size_t) rows16[h] * PLE_ROW_BYTES); + ranges[n].NumberOfBytes = PLE_ROW_BYTES; + ++n; + } + if (n > 0) (void) PrefetchVirtualMemory(GetCurrentProcess(), n, ranges, 0); + } +#endif + impl_->pending = true; + return true; +} + +bool PleTable::collect(float* out2560, std::string& err) { + if (!impl_->pending) { err = "PleTable::collect without issue"; return false; } + impl_->pending = false; + if (impl_->mode == PleIo::Direct) { + if (!impl_->reader.collect(impl_->ticket, err)) return false; + for (int h = 0; h < PLE_N_HEADS; ++h) + iq4nl_dequant_row(impl_->raw + (size_t) h * PLE_ROW_BYTES, out2560 + (size_t) h * PLE_HEAD_DIM); + impl_->bytes_read += (uint64_t) PLE_N_HEADS * PLE_ROW_BYTES; + return true; + } + for (int h = 0; h < PLE_N_HEADS; ++h) read_row(impl_->rows[h], out2560 + (size_t) h * PLE_HEAD_DIM); + return true; +} + +bool PleTable::gather_batch(const uint32_t* rows, size_t n_tokens, float* out, std::string& err) { + if (impl_->pending) { err = "PleTable::gather_batch while a token is in flight"; return false; } + const size_t n = n_tokens * (size_t) PLE_N_HEADS; + if (impl_->mode == PleIo::Direct) { + std::vector raw(n * PLE_ROW_BYTES); + const auto ticket = impl_->reader.issue(rows, n, raw.data()); + if (!impl_->reader.collect(ticket, err)) return false; + for (size_t i = 0; i < n; ++i) iq4nl_dequant_row(raw.data() + i * PLE_ROW_BYTES, out + i * PLE_HEAD_DIM); + impl_->bytes_read += (uint64_t) n * PLE_ROW_BYTES; + return true; + } + for (size_t i = 0; i < n; ++i) read_row(rows[i], out + i * PLE_HEAD_DIM); + return true; +} + +void PleTable::set_injected_delay_us(double us) { impl_->reader.set_injected_delay_us(us); } + +std::string PleTable::io_report() const { + if (impl_->mode != PleIo::Direct || !impl_->reader.is_open()) return {}; + const strata::ngram::ReaderStats& s = impl_->reader.stats(); + char buf[320]; + std::snprintf(buf, sizeof buf, + "ple io: %llu rows, %.1f%% row-cache hits, %llu SSD reads (%.1f MB), read p50 %.0f us p99 %.0f us, " + "blocked %.3f ms total (submit %.3f ms), cache %llu/%llu rows", + (unsigned long long) s.requests, s.requests ? 100.0 * (double) s.cache_hits / (double) s.requests : 0.0, + (unsigned long long) s.reads, (double) s.bytes / 1e6, s.percentile(0.5), s.percentile(0.99), + s.wait_us / 1000.0, s.submit_us / 1000.0, (unsigned long long) impl_->reader.cache_size(), + (unsigned long long) impl_->reader.cache_capacity()); + return buf; +} + +void PleTable::gather(const uint32_t* rows16, float* out2560) const { + if (impl_->mode == PleIo::Direct) { + // `gather` stays const for its existing callers; the reader's state is the table's I/O state. + PleTable* self = const_cast(this); + std::string err; + if (!self->issue(rows16) || !self->collect(out2560, err)) { + std::fprintf(stderr, "PleTable::gather: %s\n", err.empty() ? "a token is already in flight" : err.c_str()); + std::memset(out2560, 0, (size_t) NG_N_EMBD * sizeof(float)); + } + return; + } + // ================================ SIXTEEN SERIAL PAGE FAULTS, MEASURED ================================ + // + // **THIS COST 2.10-2.61 ms PER TOKEN AND HAD NEVER BEEN IN THE PLAN'S BUDGET AT ALL.** The round-309 + // `token host phases` line put it second behind the layer loop among avoidable terms, and the arithmetic + // says why: the table is 320,001,536 rows of `PLE_ROW_BYTES` = 90 B in a 26.8 GB mapping, so the sixteen + // rows a token needs are 1,440 B - **0.5 MB/s**. That is not bandwidth, it is latency: sixteen reads into + // sixteen different 4 KB pages scattered across 26.8 GB, taken ONE AT A TIME, and on this machine the PLE + // shard is 26.8 GB against 63 GB of RAM that the 31.6 GB expert arena is also competing for, so they are + // not in the OS cache. Sixteen serial NVMe reads at ~150 us is 2.4 ms, which is the measurement. + // + // `PrefetchVirtualMemory` issues all sixteen in ONE call and lets them complete in parallel. It is a hint + // and cannot change the answer - a range it does not fetch is simply faulted in by the read that follows - + // so the only risk is that it does nothing. +#if defined(_WIN32) + if (impl_->data != nullptr && g_ple_prefetch) { + WIN32_MEMORY_RANGE_ENTRY ranges[PLE_N_HEADS]; + ULONG_PTR n = 0; + for (int h = 0; h < PLE_N_HEADS; ++h) { + // Out-of-range rows are handled by `read_row` as zeros and have no address to prefetch. + if (rows16[h] >= impl_->n_rows) continue; + ranges[n].VirtualAddress = (PVOID) (impl_->data + (size_t) rows16[h] * PLE_ROW_BYTES); + ranges[n].NumberOfBytes = PLE_ROW_BYTES; + ++n; + } + if (n > 0) (void) PrefetchVirtualMemory(GetCurrentProcess(), n, ranges, 0); + } +#endif + // HEAD-SLOWEST, which is what `ggml_get_rows` does and what the source's own comment says: head h's 160 + // values occupy [h*160, (h+1)*160). A head-fastest layout would put element (d, h) at d*16 + h and needs + // a real transpose - a reshape of the same flat buffer compares equal and would make the check vacuous. + for (int h = 0; h < PLE_N_HEADS; ++h) read_row(rows16[h], out2560 + (size_t) h * PLE_HEAD_DIM); +} + +} // namespace strata::kernels diff --git a/src/kernels/ple_oracle_vectors.inc b/src/kernels/ple_oracle_vectors.inc new file mode 100644 index 00000000..60c53b32 --- /dev/null +++ b/src/kernels/ple_oracle_vectors.inc @@ -0,0 +1,105 @@ +// GENERATED by tests/python/gen_ple_oracle.py - DO NOT EDIT BY HAND. +// +// Sources, none of them this project's own code: +// hash ref/ngram.py::ngram_rows (qwen4exp.cpp L1092-1124, validated bit-exact in round 101) +// table the ORIGINAL GGUF's bytes, read at the offset the validated gguf_reader reports, +// dequantized with numpy +// offsets pack/full/manifest.json + +#pragma once +#include + +namespace strata::kernels::ple_oracle { + +struct HashCase { const char* name; int n_tokens; const int32_t* tokens; + const int32_t* prev; const uint32_t* rows; }; + +// window direction +inline const int32_t case0_tokens[4] = {11, 22, 33, 44}; +inline const int32_t case0_prev[8] = {-1, -1, 11, -1, 22, 11, 33, 22}; +inline const uint32_t case0_rows[64] = { + 13555483, 24877159, 54278900, 63631058, 98394898, 117447587, 136774765, 157274238, 173487882, 192109366, 213364329, 236577563, 258758149, 262164274, 283572184, 312653920, + 3278367, 29880471, 46922633, 62971672, 94904487, 113013549, 120925297, 159537733, 161636484, 186269036, 207870452, 237789268, 242975733, 269388091, 285131318, 305889870, + 2485282, 23858032, 54544880, 69506925, 82331991, 106607168, 133019998, 141570514, 173894108, 180423171, 211374553, 239810912, 253076960, 266343146, 290765399, 312876099, + 12721672, 27113445, 44309803, 60385209, 97021817, 115900810, 134219364, 151977589, 161183764, 181257609, 201344488, 221596875, 241635416, 261674107, 281687144, 301751935, +}; +// EOS one back cuts both +inline const int32_t case1_tokens[3] = {7, 8, 9}; +inline const int32_t case1_prev[6] = {-1, -1, 7, 248044, 248044, 7}; +inline const uint32_t case1_rows[48] = { + 2927653, 34980843, 54748278, 66612378, 97814964, 109013870, 126560393, 151352333, 167888935, 182235580, 215170017, 237467519, 247510681, 278779700, 296141806, 304994522, + 7508793, 29969020, 54939975, 74089003, 91535754, 104816064, 125484693, 154439441, 164805529, 181662903, 214193203, 238193346, 259492009, 262016223, 296463606, 310742083, + 5471414, 32902469, 56618078, 65819961, 82278798, 107765152, 125994638, 156967341, 179716672, 187881262, 219224344, 222418510, 250276020, 278493068, 281311927, 316005603, +}; +// NULL reads as EOS +inline const int32_t case2_tokens[2] = {100, 101}; +inline const int32_t case2_prev[4] = {-1, -1, -1, -1}; +inline const uint32_t case2_rows[32] = { + 5727835, 21884476, 43702108, 66434789, 84094509, 104112171, 134886528, 157314943, 162363667, 189466439, 200618315, 232121522, 258547276, 266199001, 289022207, 305180972, + 14526383, 36975477, 51939184, 65076202, 94510971, 111786935, 138448715, 155393638, 167579692, 181767276, 211184823, 226303266, 246271192, 267465224, 281469089, 313531602, +}; +// token id 0 is NOT missing +inline const int32_t case3_tokens[2] = {100, 101}; +inline const int32_t case3_prev[4] = {0, 0, 100, 0}; +inline const uint32_t case3_rows[32] = { + 223356, 29869136, 44693816, 65450370, 83243562, 109175069, 138072664, 149936704, 175869089, 193667153, 214433731, 233779437, 242682975, 271587098, 294555343, 309396483, + 9825591, 35767834, 58740777, 62904867, 86476006, 107666813, 129453358, 151836075, 160726459, 190511348, 208596315, 234569026, 259467109, 264365567, 292665383, 314164387, +}; +// own EOS does not cut itself +inline const int32_t case4_tokens[3] = {248044, 5, 248044}; +inline const int32_t case4_prev[6] = {-1, -1, 248044, -1, 5, 248044}; +inline const uint32_t case4_rows[48] = { + 9663979, 26558231, 56120240, 74755659, 80459717, 109265651, 132697467, 151022725, 170054832, 192967038, 200687722, 225763581, 259275737, 272983544, 297596484, 300986548, + 15389869, 39778609, 55713969, 62213332, 88817728, 118483999, 133731511, 155458159, 179763390, 197956758, 205378969, 220499474, 242466248, 265658744, 293662119, 315720898, + 9663979, 26558231, 56120240, 74755659, 80459717, 109265651, 132697467, 151022725, 170054832, 192967038, 200687722, 225763581, 259275737, 272983544, 297596484, 300986548, +}; +// real vocabulary ids +inline const int32_t case5_tokens[5] = {248043, 12345, 248043, 7, 200000}; +inline const int32_t case5_prev[10] = {-1, -1, 248043, -1, 12345, 248043, 248043, 12345, 7, 248043}; +inline const uint32_t case5_rows[80] = { + 5224855, 30284709, 43929396, 72280505, 94883662, 109322550, 131203836, 140794991, 166459826, 181131035, 209237474, 226844350, 246236135, 265823742, 299063341, 305586418, + 11270970, 23825822, 53627731, 75297762, 86108900, 117131242, 124369521, 148669731, 173053968, 186938961, 219076364, 237563026, 246838602, 277288915, 294366759, 301712965, + 9040325, 23423119, 51729309, 72606490, 88803423, 101106903, 139785146, 145105493, 165075797, 186774126, 216848848, 228384799, 252197080, 276291559, 284385762, 305326936, + 4918924, 35785322, 51404601, 69479989, 96595301, 119006811, 132661231, 157603286, 167367331, 194295259, 207336314, 231383366, 248222822, 265383752, 291175661, 300670030, + 19540326, 36155593, 54730668, 61035256, 83860210, 111525643, 123077188, 158579165, 171421264, 181503411, 210257194, 220050508, 255765827, 271545193, 290152687, 303296503, +}; + +inline const HashCase kHashCases[] = { + {"window direction", 4, case0_tokens, case0_prev, case0_rows}, + {"EOS one back cuts both", 3, case1_tokens, case1_prev, case1_rows}, + {"NULL reads as EOS", 2, case2_tokens, case2_prev, case2_rows}, + {"token id 0 is NOT missing", 2, case3_tokens, case3_prev, case3_rows}, + {"own EOS does not cut itself", 3, case4_tokens, case4_prev, case4_rows}, + {"real vocabulary ids", 5, case5_tokens, case5_prev, case5_rows}, +}; +inline constexpr int kHashCaseCount = 6; + +// The table: sampled values of two rows, plus the whole-row L1 sums so a wrong codebook or a +// wrong scale cannot hide between the samples. Row 0 and the LAST row. +inline constexpr uint64_t kTableRows = 320001536ull; +inline constexpr uint64_t kTableDataStart = 192ull; +inline constexpr uint64_t kTableFileSize = 28800138432ull; +inline const uint32_t kProbeRows[] = {0, 12345, 320001535}; +inline constexpr int kProbeCount = 3; +inline const float kProbeHead[][8] = { + {-1.001119614e-02f, 1.290917397e-02f, -1.817822456e-02f, 1.290917397e-02f, -6.586313248e-03f, 5.795955658e-03f, -6.586313248e-03f, 5.795955658e-03f}, + {5.563795567e-03f, -4.314780235e-03f, 3.974139690e-03f, -1.476109028e-03f, -1.135468483e-04f, 1.135468483e-03f, -1.010566950e-02f, -6.017982960e-03f}, + {0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f}, +}; +inline const float kProbeTail[][8] = { + {2.571296692e-02f, 1.607060432e-02f, 3.139948845e-02f, 5.439281464e-03f, -1.705956459e-02f, -3.214120865e-03f, -2.200436592e-02f, 2.472400665e-03f}, + {-1.942408085e-02f, 5.811929703e-03f, -9.941458702e-03f, 3.823637962e-03f, -5.353093147e-03f, -1.529455185e-03f, -1.529455185e-03f, -5.353093147e-03f}, + {0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f, 0.000000000e+00f}, +}; +inline const double kProbeSum[] = {2.011959553e+00, 9.968549609e-01, 0.000000000e+00}; +inline constexpr float kInterleavedSeparation = 1.253766603e+00f; + +// pack/full/dense.bin regions for the two quantized PLE weights +inline constexpr uint64_t kKeyCodesOffset = 1017723776ull; +inline constexpr uint64_t kKeyCodesBytes = 6553600ull; +inline constexpr uint64_t kKeyScalesOffset = 1024277376ull; +inline constexpr uint64_t kKeyScalesBytes = 819200ull; +inline constexpr uint64_t kValueOffset = 1025219456ull; +inline constexpr uint64_t kValueBytes = 26214400ull; + +} // namespace strata::kernels::ple_oracle diff --git a/src/kernels/ple_parity.cpp b/src/kernels/ple_parity.cpp new file mode 100644 index 00000000..20da0220 --- /dev/null +++ b/src/kernels/ple_parity.cpp @@ -0,0 +1,1184 @@ +// src/kernels/ple_parity.cpp - P2.S4's test: the n-gram hash, the IQ4_NL table read, and the PLE block. +// +// THREE PARTS, THREE DIFFERENT ORACLES, and none of them is this project's own code: +// +// A. THE HASH against `ref/ngram.py::ngram_rows`, via `ple_oracle_vectors.inc` (generated). The properties +// are asserted OBSERVABLE first - XOR vs sum, `%` vs `&`, the cut DIRECTION, the NULL sentinel - because +// each rival reading produces a perfectly valid index in range. +// B. THE TABLE against numpy reading the ORIGINAL GGUF at the offset the validated `gguf_reader` reports. +// C. THE BLOCK against ggml's own graph, captured in `bench/micro/ple_in.bin` / `ple_out.bin` by +// `ple_layer_xcheck.cpp`. That file records EVERY intermediate (key, value, gate, gated, normalized, +// conv_out, result), so a mismatch can be attributed to a stage instead of guessed at - and the weights +// in it were checked to be the artifact's real ones before this test was written. +#include "strata/kernels/ple.hpp" +#include "strata/kernels/ngram.hpp" +#include "strata/kernels/f16_bits.hpp" +#include "strata/kernels/native_mmvq.hpp" +#include "ple_oracle_vectors.inc" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace k = strata::kernels; +namespace o = strata::kernels::ple_oracle; + +namespace { + +void ck(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +/// TOLERANCE CHECKS MUST BE NaN-SAFE, and `x <= t` is not. +/// +/// `x <= t` AND `x > t` are BOTH false for NaN, so a check written either way can silently pass - round 197 +/// found `shared_expert_parity` printing a perfect score for an entirely-NaN fixture because the accumulator +/// was `if (rel > worst) worst = rel;` and NaN is never greater. Every comparison in this file goes through +/// `le`/`gt`, which require the value to be FINITE first, and every compared pair is counted for non-finite +/// entries and the count printed. A NaN fixture is then a loud failure rather than a green line. +bool le(double x, double t) { return std::isfinite(x) && x <= t; } +bool gt(double x, double t) { return std::isfinite(x) && x > t; } + +/// How many entries of `a` are not finite. Printed next to every comparison. +long long nonfinite(const float* a, size_t n) { + long long k = 0; + for (size_t i = 0; i < n; ++i) + if (!std::isfinite(a[i])) ++k; + return k; +} + +/// Normalised L1 with the reference magnitude returned. For the STAGE comparisons a plain |ref| denominator +/// is right: these are dense vectors of O(1) values with no cancellation between them. +/// +/// Returns NaN if EITHER side has a non-finite entry, so a caller that forgets `le`/`gt` still cannot read a +/// finite-looking number out of a broken fixture. +double rel_l1(const float* a, const float* b, size_t n, double* mag_out = nullptr, + long long* nf_out = nullptr) { + double d = 0, m = 0; + long long nf = 0; + for (size_t i = 0; i < n; ++i) { + if (!std::isfinite(a[i]) || !std::isfinite(b[i])) { ++nf; continue; } + d += std::fabs((double) a[i] - (double) b[i]); + m += std::fabs((double) a[i]); + } + if (mag_out) *mag_out = m / (double) (n ? n : 1); + if (nf_out) *nf_out = nf; + if (nf) return std::nan(""); + return d / (m > 1e-30 ? m : 1e-30); +} + +/// The file's size, WITHOUT reading it. +/// +/// The first version of this test called `read_file(gguf)` - which reads the WHOLE file into a vector - three +/// times, on a 28.8 GB GGUF. That is 27 GB of RSS, three full passes over the file, and a 132-second ctest +/// entry in a suite where every other test is under a second. A test that slow stops being run, which is how +/// a gate stops being a gate. Everything below now reads only the bytes it actually compares. +long long file_size(const char* path) { + std::FILE* f = std::fopen(path, "rb"); + if (!f) return -1; +#if defined(_MSC_VER) + _fseeki64(f, 0, SEEK_END); + const long long n = _ftelli64(f); +#else + std::fseek(f, 0, SEEK_END); + const long long n = std::ftell(f); +#endif + std::fclose(f); + return n; +} + +/// `n` bytes at `off`, or an empty vector. +std::vector read_at(const char* path, long long off, size_t n) { + std::vector v(n); + std::FILE* f = std::fopen(path, "rb"); + if (!f) return {}; +#if defined(_MSC_VER) + if (_fseeki64(f, off, SEEK_SET) != 0) { std::fclose(f); return {}; } +#else + if (std::fseek(f, (long) off, SEEK_SET) != 0) { std::fclose(f); return {}; } +#endif + const size_t got = std::fread(v.data(), 1, n, f); + std::fclose(f); + if (got != n) return {}; + return v; +} + +/// Only used for the pack, which is 5.4 GB rather than 28.8 GB - and even then the caller says which region. +std::vector read_path(const char* path) { + const long long n = file_size(path); + if (n <= 0) return {}; + return read_at(path, 0, (size_t) n); +} + +/// Dequantize one IQ4_NL row with an INTERLEAVED nibble order, to show the correct split-half order is +/// observable. Deliberately shares nothing with the kernel's decoder. +void deq_interleaved(const uint8_t* row, float* out160) { + for (int b = 0; b < k::PLE_HEAD_DIM / 32; ++b) { + const uint8_t* blk = row + (size_t) b * 18; + uint16_t db; + std::memcpy(&db, blk, 2); + const float d = k::f32_from_f16(db); + for (int j = 0; j < 16; ++j) { + out160[b * 32 + 2 * j] = d * (float) k::iq4nl_code(blk[2 + j] & 0x0F); + out160[b * 32 + 2 * j + 1] = d * (float) k::iq4nl_code(blk[2 + j] >> 4); + } + } +} + +struct PleCapture { + int n_embd = 0, hc = 0, nt = 0, kern = 0, dil = 0; + float eps = 0.0f; + std::vector emb, hidden, w_key, w_value, w_nk, w_nq, w_nc, w_conv, hist; + // oracle outputs + std::vector key, value, gate, gated, normalized, conv_out, result; +}; + +bool load_capture(const char* in_path, const char* out_path, PleCapture& c) { + std::FILE* fi = std::fopen(in_path, "rb"); + if (!fi) return false; + int32_t h[5] = {0}; + if (std::fread(h, 4, 5, fi) != 5) { std::fclose(fi); return false; } + c.n_embd = h[0]; c.hc = h[1]; c.nt = h[2]; c.kern = h[3]; c.dil = h[4]; + if (std::fread(&c.eps, 4, 1, fi) != 1) { std::fclose(fi); return false; } + // Validate before deriving sizes or allocating. This bounded diagnostic + // format is for this model's geometry and at most 64 captured tokens. + if (c.n_embd != k::NG_N_EMBD || c.hc != k::NG_HC || c.nt <= 0 || c.nt > 64 || + c.kern != k::PLE_CONV_KERNEL || c.dil != k::NGRAM_SIZE || c.eps != k::NG_RMS_EPS) { + std::fclose(fi); return false; + } + const long long nd = c.n_embd, hcd = (long long) c.hc * c.n_embd, nt = c.nt, + hist = (long long) (c.kern - 1) * c.dil; + const long long in_bytes = 24 + 4 * (nt * nd + nt * hcd + hcd * nd + nd * nd + + 3 * hcd + hcd * c.kern + hist * hcd); + const long long out_bytes = 12 + 4 * nt * (5 * hcd + nd + c.hc); + if (file_size(in_path) != in_bytes || file_size(out_path) != out_bytes) { + std::fclose(fi); return false; + } + auto rd = [](std::FILE* file, std::vector& v, long long n) { + v.resize((size_t) n); + return n == 0 || std::fread(v.data(), 4, (size_t) n, file) == (size_t) n; + }; + bool ok = rd(fi, c.emb, nt * nd) && rd(fi, c.hidden, nt * hcd) && rd(fi, c.w_key, hcd * nd) && + rd(fi, c.w_value, nd * nd) && rd(fi, c.w_nk, hcd) && rd(fi, c.w_nq, hcd) && rd(fi, c.w_nc, hcd) && + rd(fi, c.w_conv, hcd * c.kern) && rd(fi, c.hist, hist * hcd); + std::fclose(fi); + if (!ok) return false; + + std::FILE* fo = std::fopen(out_path, "rb"); + if (!fo) return false; + int32_t oh[3] = {0}; + if (std::fread(oh, 4, 3, fo) != 3) { std::fclose(fo); return false; } + if (oh[0] != c.n_embd || oh[1] != hcd || oh[2] != c.nt) { std::fclose(fo); return false; } + ok = rd(fo, c.key, nt * hcd) && rd(fo, c.value, nt * nd) && rd(fo, c.gate, nt * c.hc) && rd(fo, c.gated, nt * hcd) && + rd(fo, c.normalized, nt * hcd) && rd(fo, c.conv_out, nt * hcd) && rd(fo, c.result, nt * hcd); + std::fclose(fo); + return ok; +} + +int history_advance_regression() { + const size_t channels = (size_t) k::NG_HC_DIM, rows = (size_t) k::NG_HIST, guard = 16; + const size_t count = channels * rows; + std::vector expected(count), normalized(channels), actual(count); + for (size_t c = 0; c < channels; ++c) + for (size_t r = 0; r < rows; ++r) expected[c * rows + r] = -float(c * 16 + r + 1); + float *history_storage = nullptr, *norm_storage = nullptr; + ck(cudaMalloc(&history_storage, (count + 2 * guard) * sizeof(float)), "history regression allocation"); + ck(cudaMalloc(&norm_storage, (channels + 2 * guard) * sizeof(float)), "history norm allocation"); + ck(cudaMemset(history_storage, 0xa5, (count + 2 * guard) * sizeof(float)), "history guard init"); + ck(cudaMemset(norm_storage, 0xa5, (channels + 2 * guard) * sizeof(float)), "history norm guard init"); + float* history = history_storage + guard; + float* norm = norm_storage + guard; + ck(cudaMemcpy(history, expected.data(), count * sizeof(float), cudaMemcpyHostToDevice), "history initial values"); + cudaStream_t stream; + cudaGraph_t graph; + cudaGraphExec_t executable; + ck(cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking), "history regression stream"); + ck(cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal), "history regression capture"); + k::ple_history_advance(history, norm, stream); + ck(cudaStreamEndCapture(stream, &graph), "history regression capture end"); + ck(cudaGraphInstantiate(&executable, graph, nullptr, nullptr, 0), "history regression instantiate"); + int bad = 0; + for (int token = 0; token < 12; ++token) { + for (size_t c = 0; c < channels; ++c) { + normalized[c] = float((token + 1) * 1000000 + c); // distinct exactly represented integers + for (size_t r = 0; r + 1 < rows; ++r) expected[c * rows + r] = expected[c * rows + r + 1]; + expected[c * rows + rows - 1] = normalized[c]; + } + ck(cudaMemcpyAsync(norm, normalized.data(), channels * sizeof(float), cudaMemcpyHostToDevice, stream), "history new normalized row"); + ck(cudaGraphLaunch(executable, stream), "history captured advance"); + ck(cudaStreamSynchronize(stream), "history captured advance sync"); + ck(cudaMemcpy(actual.data(), history, count * sizeof(float), cudaMemcpyDeviceToHost), "history readback"); + if (std::memcmp(actual.data(), expected.data(), count * sizeof(float)) != 0) { + std::printf(" history advance mismatch after token %d\n", token); + ++bad; + } + } + bool overlap_refused = false, null_refused = false; + try { k::ple_history_advance(history, history + 1, stream); } + catch (const std::invalid_argument&) { overlap_refused = true; } + try { k::ple_history_advance(history, nullptr, stream); } + catch (const std::invalid_argument&) { null_refused = true; } + if (!overlap_refused || !null_refused) ++bad; + auto guard_ok = [&](float* storage, size_t payload) { + uint8_t before[64], after[64]; + ck(cudaMemcpy(before, storage, sizeof(before), cudaMemcpyDeviceToHost), "history prefix guard"); + ck(cudaMemcpy(after, storage + guard + payload, sizeof(after), cudaMemcpyDeviceToHost), "history suffix guard"); + return std::all_of(before, before + 64, [](uint8_t b) { return b == 0xa5; }) && + std::all_of(after, after + 64, [](uint8_t b) { return b == 0xa5; }); + }; + if (!guard_ok(history_storage, count) || !guard_ok(norm_storage, channels)) ++bad; + std::printf(" PLE history 12 captured steps, row-fastest state and guards: %s\n", bad == 0 ? "pass" : "FAIL"); + ck(cudaGraphExecDestroy(executable), "history graph exec destroy"); + ck(cudaGraphDestroy(graph), "history graph destroy"); + ck(cudaStreamDestroy(stream), "history stream destroy"); + cudaFree(history_storage); cudaFree(norm_storage); + return bad; +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + bool check_fixtures = false; + std::string pack = "pack/full"; + // --gguf, else $STRATA_PLE_GGUF, else the development layout (run from the engine root) + std::string gguf = std::getenv("STRATA_PLE_GGUF") ? std::getenv("STRATA_PLE_GGUF") + : "../../Q2_0/Qwen3.8-Flash-Next-GSQ-RCO-Q2_0-00002-of-00002.gguf"; + std::string in_bin = "bench/micro/ple_in.bin", out_bin = "bench/micro/ple_out.bin"; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--selftest") selftest = true; + else if (a == "--check-fixtures") check_fixtures = true; + else if (a == "--pack" && i + 1 < argc) pack = argv[++i]; + else if (a == "--gguf" && i + 1 < argc) gguf = argv[++i]; + else if (a == "--in" && i + 1 < argc) in_bin = argv[++i]; + else if (a == "--out" && i + 1 < argc) out_bin = argv[++i]; + else { std::fprintf(stderr, "usage: ple_parity [--selftest] [--check-fixtures] [--pack D] [--gguf F] [--in F] [--out F]\n"); + return 2; } + } + if (selftest && check_fixtures) { + std::fprintf(stderr, "ple_parity: --check-fixtures is CPU-only; use it separately from --selftest\n"); + return 2; + } + // Required self-test fixtures are checked before the first CUDA call, so + // missing files cannot become an apparent pass or a misleading GPU error. + k::PleTable table; + std::string err; + PleCapture cap; + bool preflight_done = false; + if (selftest || check_fixtures) { + if (!table.open(gguf, err) || table.rows() != o::kTableRows || + file_size(gguf.c_str()) != (long long) o::kTableDataStart + (long long) o::kTableRows * k::PLE_ROW_BYTES) { + std::fprintf(stderr, "ple_parity: required PLE table is missing or incompatible: %s (%s)\n", gguf.c_str(), err.c_str()); + return 2; + } + const uint64_t pack_needed = std::max({o::kKeyCodesOffset + o::kKeyCodesBytes, + o::kKeyScalesOffset + o::kKeyScalesBytes, o::kValueOffset + o::kValueBytes}); + const long long pack_size = file_size((pack + "/dense.bin").c_str()); + if (pack_size < 0 || (uint64_t) pack_size < pack_needed) { + std::fprintf(stderr, "ple_parity: required dense pack is missing or truncated: %s/dense.bin\n", pack.c_str()); + return 2; + } + if (!load_capture(in_bin.c_str(), out_bin.c_str(), cap)) { + std::fprintf(stderr, "ple_parity: required block fixtures are missing, truncated or incompatible: %s / %s\n", in_bin.c_str(), out_bin.c_str()); + return 2; + } + preflight_done = true; + if (check_fixtures) { + std::puts("PASS required PLE fixture structure on CPU; no GPU numerical checks were run"); + return 0; + } + if (!k::ple_block_available()) { + std::fprintf(stderr, "ple_parity: --selftest requires a CUDA device for block checks\n"); + return 2; + } + } + int bad = history_advance_regression(); + const k::PleConsts C = k::ple_artifact_consts(); + + // ============================ A. THE HASH ============================ + std::printf("A. the n-gram hash, against ref/ngram.py with the artifact's own constants\n"); + + // ---- the constants must be internally consistent BEFORE they are used: `head_offsets` is its own array + // in the metadata, so deriving it from `vocab_sizes` would hide a disagreement between them. + { + uint64_t run = 0; + bool ok = true; + for (int h = 0; h < k::PLE_N_HEADS; ++h) { + if (C.offset[h] != run) ok = false; + run += C.vocab[h]; + } + const uint64_t total = run; + const bool fits = total <= k::PLE_TABLE_ROWS; + std::printf(" %-46s %s (sum %llu <= %llu rows; %llu spare)\n", + "head_offsets == the running sum of vocab_sizes", ok && fits ? "yes" : "*** NO ***", + (unsigned long long) total, (unsigned long long) k::PLE_TABLE_ROWS, + (unsigned long long) (k::PLE_TABLE_ROWS - total)); + if (!ok || !fits) ++bad; + } + + for (int ci = 0; ci < o::kHashCaseCount; ++ci) { + const o::HashCase& hc = o::kHashCases[ci]; + std::vector got((size_t) hc.n_tokens * k::PLE_N_HEADS); + k::ngram_rows(hc.tokens, hc.prev, hc.n_tokens, C, got.data()); + long long diff = 0; + for (size_t i = 0; i < got.size(); ++i) + if (got[i] != hc.rows[i]) ++diff; + std::printf(" %-46s %s (%lld of %zu differ)\n", hc.name, diff ? "*** WRONG ***" : "matches", diff, + got.size()); + if (diff) { + for (int t = 0; t < hc.n_tokens && diff; ++t) { + for (int h = 0; h < k::PLE_N_HEADS; ++h) { + const uint32_t g = got[(size_t) t * 16 + h], w = hc.rows[(size_t) t * 16 + h]; + if (g != w) { + std::printf(" first: t=%d h=%d got %u want %u\n", t, h, g, w); + t = hc.n_tokens; + break; + } + } + } + ++bad; + } + } + + // ---- the rival readings, each computed and required to DIFFER from the oracle ------------------- + // + // Every one of these produces a 16-element vector of valid indices. That is the whole reason the hash is + // transcribed property by property: no range check, no shape check and no summing test can see any of + // them, only an oracle can - and only a DELIBERATELY-WRONG fixture can show that the oracle comparison + // has the power to catch them. + { + const o::HashCase& hc = o::kHashCases[0]; // the window-direction case, 4 tokens + const int T = hc.n_tokens, np = k::NGRAM_SIZE - 1, n_heads = k::PLE_N_HEADS; + std::vector xor_v((size_t) T * n_heads), sum_v((size_t) T * n_heads), + mask_v((size_t) T * n_heads), back_v((size_t) T * n_heads), zero_v((size_t) T * n_heads); + for (int i = 0; i < T; ++i) { + int64_t ctx[k::NGRAM_SIZE]; + ctx[0] = hc.tokens[i]; + for (int s = 1; s < k::NGRAM_SIZE; ++s) ctx[s] = hc.prev[i * np + (np - s)]; + for (int n = 2; n <= k::NGRAM_SIZE; ++n) { + // (1) SUM instead of XOR + uint64_t sum = 0; + for (int j = 0; j < n; ++j) sum += (uint64_t) ctx[j] * C.mult[j]; + // (2) mask instead of modulo: the vocab sizes are NOT powers of two + uint64_t xr = k::ngram_mixed(ctx, C.mult, n); + // (4) token 0 treated as "missing" the way -1 is + int64_t ctx0[k::NGRAM_SIZE]; + for (int j = 0; j < k::NGRAM_SIZE; ++j) + ctx0[j] = (ctx[j] == k::TOKEN_NULL || ctx[j] == 0) ? k::PLE_EOS_TOKEN_ID : ctx[j]; + uint64_t xr0 = k::ngram_mixed(ctx0, C.mult, n); + // (3) the window read NEWEST-first instead of oldest-first: prev index (s-1) + int64_t ctxb[k::NGRAM_SIZE]; + ctxb[0] = hc.tokens[i]; + for (int s = 1; s < k::NGRAM_SIZE; ++s) ctxb[s] = hc.prev[i * np + (s - 1)]; + uint64_t xrb = k::ngram_mixed(ctxb, C.mult, n); + const int base = (n - 2) * k::HEADS_PER_NGRAM; + for (int g = 0; g < k::HEADS_PER_NGRAM; ++g) { + const int h = base + g; + xor_v[(size_t) i * n_heads + h] = (uint32_t) (xr % C.vocab[h] + C.offset[h]); + sum_v[(size_t) i * n_heads + h] = (uint32_t) (sum % C.vocab[h] + C.offset[h]); + mask_v[(size_t) i * n_heads + h] = + (uint32_t) ((xr & (C.vocab[h] - 1)) + C.offset[h]); + back_v[(size_t) i * n_heads + h] = (uint32_t) (xrb % C.vocab[h] + C.offset[h]); + zero_v[(size_t) i * n_heads + h] = (uint32_t) (xr0 % C.vocab[h] + C.offset[h]); + } + } + } + struct Rival { const char* name; const std::vector* v; }; + const Rival rivals[] = {{"XOR vs SUM", &sum_v}, + {"% vs & (vocab sizes are not powers of two)", &mask_v}, + {"window read newest-first, not oldest-first", &back_v}, + {"token id 0 treated as the NULL sentinel", &zero_v}}; + for (const Rival& r : rivals) { + // A rival is observable if it disagrees with the ORACLE, which is what the test above compares. + long long d = 0; + for (size_t i = 0; i < r.v->size(); ++i) + if ((*r.v)[i] != hc.rows[i]) ++d; + const bool visible = d > 0; + std::printf(" %-46s %s (%lld of %zu rows differ from the oracle)\n", r.name, + visible ? "yes" : "*** NO - the fixture cannot see this ***", d, r.v->size()); + if (!visible) ++bad; + } + (void) xor_v; + } + + // ---- the EOS cut DIRECTION, which needs a case where the two directions disagree ---------------- + { + const o::HashCase& hc = o::kHashCases[1]; // [[N,N],[7,EOS],[EOS,7]] + const int T = hc.n_tokens, np = k::NGRAM_SIZE - 1, n_heads = k::PLE_N_HEADS; + std::vector fwd((size_t) T * n_heads), bwd((size_t) T * n_heads); + for (int i = 0; i < T; ++i) { + int64_t f[k::NGRAM_SIZE], b[k::NGRAM_SIZE]; + f[0] = b[0] = hc.tokens[i]; + bool cf = false, cb = false; + for (int s = 1; s < k::NGRAM_SIZE; ++s) { + const int32_t fv = hc.prev[i * np + (np - s)]; + cf = cf || fv < 0 || fv == k::PLE_EOS_TOKEN_ID; + f[s] = cf ? k::PLE_EOS_TOKEN_ID : fv; + // the BACKWARD reading: only the position that IS EOS becomes EOS + const int32_t bv = hc.prev[i * np + (np - s)]; + b[s] = (bv < 0 || bv == k::PLE_EOS_TOKEN_ID) ? k::PLE_EOS_TOKEN_ID : bv; + (void) cb; + } + for (int n = 2; n <= k::NGRAM_SIZE; ++n) { + const uint64_t mf = k::ngram_mixed(f, C.mult, n), mb = k::ngram_mixed(b, C.mult, n); + const int base = (n - 2) * k::HEADS_PER_NGRAM; + for (int g = 0; g < k::HEADS_PER_NGRAM; ++g) { + const int h = base + g; + fwd[(size_t) i * n_heads + h] = (uint32_t) (mf % C.vocab[h] + C.offset[h]); + bwd[(size_t) i * n_heads + h] = (uint32_t) (mb % C.vocab[h] + C.offset[h]); + } + } + } + long long d = 0; + for (size_t i = 0; i < fwd.size(); ++i) + if (fwd[i] != bwd[i]) ++d; + std::printf(" %-46s %s (%lld of %zu rows differ)\n", + "cut propagates FORWARD vs only at the EOS position", + d > 0 ? "yes" : "*** NO ***", d, fwd.size()); + if (!d) ++bad; + } + + // ============================ B. THE TABLE ============================ + std::printf("\nB. the IQ4_NL table, against numpy reading the original GGUF\n"); + if (!preflight_done && !table.open(gguf, err)) { + std::printf(" cannot open the PLE table: %s\n", err.c_str()); + std::printf("\nple_parity: %d failures, TABLE AND BLOCK SKIPPED; partial diagnostic only\n", bad); + return selftest || bad ? 1 : 0; + } + std::printf(" %-46s %llu (expected %llu)\n", "rows", (unsigned long long) table.rows(), + (unsigned long long) o::kTableRows); + if (table.rows() != o::kTableRows) ++bad; + + // The size identity is what makes the data offset FALSIFIABLE: the tensor must exactly fill the file from + // data_start. Deriving the row count from the file size instead gives 320001538 rows and data_start 12, + // which is self-consistent and wrong. + { + const long long sz = file_size(gguf.c_str()); + const long long need = (long long) o::kTableDataStart + (long long) table.rows() * k::PLE_ROW_BYTES; + const bool ok = sz == need; + std::printf(" %-46s %s (%lld vs %lld; data_start %llu)\n", + "data_start + rows*90 == file size", ok ? "yes" : "*** NO ***", sz, need, + (unsigned long long) o::kTableDataStart); + if (!ok) ++bad; + } + + // Read the raw bytes through the mapped table by comparing against numpy's decode of the SAME offsets. + // 90 BYTES AT A TIME, not the whole file - see `file_size`'s note. + { + int probe_bad = 0; + for (int p = 0; p < o::kProbeCount; ++p) { + const uint32_t row = o::kProbeRows[p]; + std::vector got(k::PLE_HEAD_DIM); + table.read_row(row, got.data()); + const std::vector raw = + read_at(gguf.c_str(), (long long) o::kTableDataStart + (long long) row * k::PLE_ROW_BYTES, + (size_t) k::PLE_ROW_BYTES); + if (raw.size() != (size_t) k::PLE_ROW_BYTES) { std::printf(" short read at row %u\n", row); ++probe_bad; continue; } + std::vector ref(k::PLE_HEAD_DIM); + k::iq4nl_dequant_row(raw.data(), ref.data()); + // the sampled values from the generator (numpy), which is the oracle proper. + // NOT `fmax`: it DISCARDS NaN, so a NaN element would leave these at 0 and the row would report + // a perfect match. A plain comparison propagates the NaN into `ok`. + double head = 0, tail = 0, sum = 0; + for (int i = 0; i < 8; ++i) { + const double dh = std::fabs((double) got[i] - (double) o::kProbeHead[p][i]); + const double dt = std::fabs((double) got[k::PLE_HEAD_DIM - 8 + i] - + (double) o::kProbeTail[p][i]); + if (p == 0 && i == 0) { head = dh; tail = dt; } + else { head = (dh > head) ? dh : head; tail = (dt > tail) ? dt : tail; } + } + for (int i = 0; i < k::PLE_HEAD_DIM; ++i) sum += std::fabs((double) got[i]); + const long long nfg = nonfinite(got.data(), k::PLE_HEAD_DIM); + const bool ok = le(head, 0.0) && le(tail, 0.0) && le(std::fabs(sum - o::kProbeSum[p]), 1e-9) && + nfg == 0; + std::printf(" %-46s %s (row %u: head %.3e tail %.3e sum d %.3e, non-finite %lld)\n", + p == 0 ? "row matches numpy's decode of the same bytes" : " (same, another row)", + ok ? "yes" : "*** NO ***", row, head, tail, std::fabs(sum - o::kProbeSum[p]), nfg); + if (!ok) ++probe_bad; + // and the two decoders must agree with each other bit for bit + for (int i = 0; i < k::PLE_HEAD_DIM; ++i) + if (got[i] != ref[i]) { std::printf(" byte-level decode differs at %d\n", i); ++probe_bad; break; } + } + bad += probe_bad; + } + + // ---- the split-half nibble order must be observable --------------------------------------------- + { + const std::vector raw = + read_at(gguf.c_str(), (long long) o::kTableDataStart + + (long long) o::kProbeRows[0] * k::PLE_ROW_BYTES, + (size_t) k::PLE_ROW_BYTES); + std::vector want(k::PLE_HEAD_DIM), inter(k::PLE_HEAD_DIM); + k::iq4nl_dequant_row(raw.data(), want.data()); + deq_interleaved(raw.data(), inter.data()); + const double rel = rel_l1(want.data(), inter.data(), k::PLE_HEAD_DIM); + const bool visible = gt(rel, 0.05); + std::printf(" %-46s %s (%.2f%% apart; generator says %.4f)\n", + "split-half vs INTERLEAVED nibbles observable", + visible ? "yes" : "*** NO ***", rel * 100, (double) o::kInterleavedSeparation); + if (!visible) ++bad; + } + + // ---- and reading at file offset 0 instead of data_start must be observable ---------------------- + { + const std::vector at_ds = read_at(gguf.c_str(), (long long) o::kTableDataStart, + (size_t) k::PLE_ROW_BYTES); + const std::vector at_0 = read_at(gguf.c_str(), 0, (size_t) k::PLE_ROW_BYTES); + std::vector ok_v(k::PLE_HEAD_DIM), at0(k::PLE_HEAD_DIM); + k::iq4nl_dequant_row(at_ds.data(), ok_v.data()); + k::iq4nl_dequant_row(at_0.data(), at0.data()); + const double rel = rel_l1(ok_v.data(), at0.data(), k::PLE_HEAD_DIM); + const bool visible = gt(rel, 0.05); + std::printf(" %-46s %s (%.2f%% apart)\n", + "data_start 192 vs file offset 0 observable", visible ? "yes" : "*** NO ***", rel * 100); + if (!visible) ++bad; + } + + // ---- head-slowest flatten ---------------------------------------------------------------------- + { + const uint32_t rows16[16] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + std::vector emb(k::NG_N_EMBD), one(k::PLE_HEAD_DIM); + table.gather(rows16, emb.data()); + bool blockwise = true; + for (int h = 0; h < k::PLE_N_HEADS && blockwise; ++h) { + table.read_row((uint32_t) h, one.data()); + for (int d = 0; d < k::PLE_HEAD_DIM; ++d) + if (emb[h * k::PLE_HEAD_DIM + d] != one[d]) { blockwise = false; break; } + } + // the rival: head-fastest, element (d,h) at d*16+h - a real transpose, not a reshape + std::vector fast(k::NG_N_EMBD); + for (int d = 0; d < k::PLE_HEAD_DIM; ++d) + for (int h = 0; h < k::PLE_N_HEADS; ++h) fast[d * k::PLE_N_HEADS + h] = emb[h * k::PLE_HEAD_DIM + d]; + long long diff = 0; + for (int i = 0; i < k::NG_N_EMBD; ++i) + if (fast[i] != emb[i]) ++diff; + std::printf(" %-46s %s (head h occupies [h*160,(h+1)*160))\n", "gather is head-slowest", + blockwise ? "yes" : "*** NO ***"); + std::printf(" %-46s %s (%lld of %d elements differ)\n", "head-fastest would differ", + diff > 0 ? "yes" : "*** NO ***", diff, k::NG_N_EMBD); + if (!blockwise || !diff) ++bad; + } + + // ============================ C. THE BLOCK ============================ + std::printf("\nC. the PLE block, against ggml's own graph (ple_in.bin / ple_out.bin)\n"); + if (!preflight_done && !load_capture(in_bin.c_str(), out_bin.c_str(), cap)) { + std::printf(" cannot load %s / %s\n", in_bin.c_str(), out_bin.c_str()); + std::printf("\nple_parity: %d failures, BLOCK SKIPPED; partial diagnostic only\n", bad); + return selftest || bad ? 1 : 0; + } + std::printf(" capture: n_embd %d, hc %d, nt %d, kern %d, dil %d, eps %g\n", cap.n_embd, cap.hc, cap.nt, + cap.kern, cap.dil, (double) cap.eps); + if (cap.n_embd != k::NG_N_EMBD || cap.hc != k::NG_HC || cap.kern != k::PLE_CONV_KERNEL || + cap.dil != k::NGRAM_SIZE) { + std::printf(" *** the capture's geometry is not the artifact's ***\n"); + return 1; + } + + if (!k::ple_block_available()) { + std::printf(" no CUDA device; the block SKIPPED, not passed.\n"); + std::printf("\nple_parity: %d failures, BLOCK SKIPPED\n", bad); + return selftest || bad ? 1 : 0; + } + + // ---- the two quantized weights, from the PACK, not from the capture. + // The capture holds `w_key` already dequantized to f32; the kernel needs the CODES AND SCALES, and + // re-quantizing the f32 would be a different tensor. Reading the pack is right and it was checked: + // the pack's decode of row 0 reproduces the capture's w_key row 0 exactly (see the round entry). + // Only the two regions are read, not all 5.4 GB of `dense.bin`. + std::vector key_codes = + read_at((pack + "/dense.bin").c_str(), (long long) o::kKeyCodesOffset, (size_t) o::kKeyCodesBytes); + const std::vector raw_scales = + read_at((pack + "/dense.bin").c_str(), (long long) o::kKeyScalesOffset, (size_t) o::kKeyScalesBytes); + const std::vector raw_value = + read_at((pack + "/dense.bin").c_str(), (long long) o::kValueOffset, (size_t) o::kValueBytes); + if (key_codes.empty() || raw_scales.empty() || raw_value.empty()) { + std::printf(" cannot read the ple_key/ple_value regions from %s/dense.bin\n", pack.c_str()); + return 2; + } + // fp16 -> f32 for the scales, which is what every S-form kernel in this project expects + const size_t n_scales = raw_scales.size() / 2; + std::vector key_scales(n_scales); + for (size_t i = 0; i < n_scales; ++i) { + uint16_t h; + std::memcpy(&h, raw_scales.data() + i * 2, 2); + key_scales[i] = k::f32_from_f16(h); + } + // ple_value is BF16 promoted to 32 bits in the pack; take the high half, which is exact + std::vector value_bf16(raw_value.size() / 4); + for (size_t i = 0; i < value_bf16.size(); ++i) { + uint32_t u; + std::memcpy(&u, raw_value.data() + i * 4, 4); + value_bf16[i] = (uint16_t) (u >> 16); + } + // ple_conv1d is F16 in the pack; the capture holds it as f32 in ggml order, and the kernel wants F16 + std::vector conv1d_f16(cap.w_conv.size()); + for (size_t i = 0; i < conv1d_f16.size(); ++i) conv1d_f16[i] = k::f16_from_f32(cap.w_conv[i]); + + // ---- the check that the two sources describe the SAME weights --------------------------------- + { + // dequantize key row 0 with the canonical rule and compare to the capture's w_key row 0. + // NOT `fmax`, for the reason above: it discards NaN, so a NaN would leave `worst` at 0 and report a + // perfect match. + double worst = 0; + bool first = true; + long long nf = 0; + for (int d = 0; d < k::NG_N_EMBD; ++d) { + const uint8_t byte = key_codes[d / 4]; + const int code = (byte >> ((d % 4) * 2)) & 3; + const float v = (float) (code + (-1)) * key_scales[d / 64]; + if (!std::isfinite(v) || !std::isfinite(cap.w_key[d])) { ++nf; continue; } + const double diff = std::fabs((double) v - (double) cap.w_key[d]); + if (first) { worst = diff; first = false; } + else worst = (diff > worst) ? diff : worst; + } + std::printf(" %-46s %s (worst |d| %.3e, non-finite %lld)\n", + "pack ple_key row 0 == the capture's w_key row 0", + (le(worst, 0.0) && nf == 0) ? "yes" : "*** NO ***", worst, nf); + if (!le(worst, 0.0) || nf) ++bad; + } + + // ---- run the block for both tokens of the capture --------------------------------------------- + const size_t hcd = (size_t) k::NG_HC_DIM, nd = (size_t) k::NG_N_EMBD; + std::vector dev_key(hcd), dev_value(nd), dev_gate(k::NG_HC), dev_gated(hcd), dev_norm(hcd), + dev_conv(hcd), dev_res(hcd); + float *d_emb = nullptr, *d_hid = nullptr, *d_hist = nullptr, *d_nk = nullptr, *d_nq = nullptr, + *d_nc = nullptr, *d_ck = nullptr, *d_cv = nullptr, *d_cn = nullptr, *d_cr = nullptr; + uint8_t* d_kc = nullptr; + uint16_t *d_vb = nullptr, *d_c1 = nullptr; + float *d_g = nullptr, *d_v = nullptr, *d_gd = nullptr, *d_nm = nullptr, *d_co = nullptr; + ck(cudaMalloc(&d_emb, nd * 4), "emb"); + ck(cudaMalloc(&d_hid, hcd * 4), "hid"); + ck(cudaMalloc(&d_hist, (size_t) k::NG_HIST * hcd * 4), "hist"); + ck(cudaMalloc(&d_nk, hcd * 4), "nk"); + ck(cudaMalloc(&d_nq, hcd * 4), "nq"); + ck(cudaMalloc(&d_nc, hcd * 4), "nc"); + ck(cudaMalloc(&d_kc, key_codes.size()), "kc"); + ck(cudaMalloc(&d_vb, value_bf16.size() * 2), "vb"); + ck(cudaMalloc(&d_c1, conv1d_f16.size() * 2), "c1"); + ck(cudaMalloc(&d_g, k::NG_HC * 4), "g"); + ck(cudaMalloc(&d_v, nd * 4), "v"); + ck(cudaMalloc(&d_gd, hcd * 4), "gd"); + ck(cudaMalloc(&d_nm, hcd * 4), "nm"); + ck(cudaMalloc(&d_co, hcd * 4), "co"); + ck(cudaMalloc(&d_ck, hcd * 4), "ck"); + ck(cudaMalloc(&d_cv, nd * 4), "cv"); + ck(cudaMalloc(&d_cn, hcd * 4), "cn"); + ck(cudaMalloc(&d_cr, hcd * 4), "cr"); + ck(cudaMemcpy(d_nk, cap.w_nk.data(), hcd * 4, cudaMemcpyHostToDevice), "cnk"); + ck(cudaMemcpy(d_nq, cap.w_nq.data(), hcd * 4, cudaMemcpyHostToDevice), "cnq"); + ck(cudaMemcpy(d_nc, cap.w_nc.data(), hcd * 4, cudaMemcpyHostToDevice), "cnc"); + ck(cudaMemcpy(d_kc, key_codes.data(), key_codes.size(), cudaMemcpyHostToDevice), "ckc"); + ck(cudaMemcpy(d_vb, value_bf16.data(), value_bf16.size() * 2, cudaMemcpyHostToDevice), "cvb"); + ck(cudaMemcpy(d_c1, conv1d_f16.data(), conv1d_f16.size() * 2, cudaMemcpyHostToDevice), "cc1"); + + k::PleWeights w{}; + w.key_codes = d_kc; + w.key_scales = key_scales.data(); // uploaded per call below so the pointer is a device one + w.value_bf16 = d_vb; + w.norm_key = d_nk; + w.norm_query = d_nq; + w.norm_conv = d_nc; + w.conv1d_f16 = d_c1; + float* d_ks = nullptr; + ck(cudaMalloc(&d_ks, key_scales.size() * 4), "ks"); + ck(cudaMemcpy(d_ks, key_scales.data(), key_scales.size() * 4, cudaMemcpyHostToDevice), "cks"); + w.key_scales = d_ks; + + // Per-stage comparison. Each stage the oracle records is a separate line, so a mismatch says WHICH part + // of the block is wrong rather than only that the sum is. The oracle writes each array token-major, so + // every slice is an explicit (offset, count) pair - deriving the offset from the stage's NAME, which the + // first version of this did, is a fixture that breaks the moment a name changes. + struct Stage { const char* name; const std::vector* got; const std::vector* all; + size_t offset; size_t n; }; + double worst_all = 0; + + // THE CONV HISTORY ADVANCES BETWEEN TOKENS, and getting this wrong is why token 1 compared badly at + // first. The capture is a TWO-TOKEN ubatch: ggml pads `[hist(9) | normalized(0..nt-1)]` ONCE and the conv + // reads rows `t+0, t+3, t+6, t+9` for output position t. So token 1's window is rows 1,4,7 of the SAME + // history plus row 10 - which is token 1's own normalized - i.e. the state slides by one NORMALIZED row + // per token. Feeding both tokens the original history compares my block's t=0 against ggml's t=1, and + // both my kernel and my host reference made the same mistake, so they agreed with each other and only the + // oracle could see it. + // + // ggml layout for the state is `ne=(hist, hc_dim)`: flat = row + NG_HIST*channel. + std::vector hist_state = cap.hist; + for (int t = 0; t < cap.nt; ++t) { + ck(cudaMemcpy(d_emb, cap.emb.data() + (size_t) t * nd, nd * 4, cudaMemcpyHostToDevice), "cemb"); + ck(cudaMemcpy(d_hid, cap.hidden.data() + (size_t) t * hcd, hcd * 4, cudaMemcpyHostToDevice), "chid"); + ck(cudaMemcpy(d_hist, hist_state.data(), (size_t) k::NG_HIST * hcd * 4, cudaMemcpyHostToDevice), + "chist"); + k::PleOut out{}; + out.key = d_ck; out.value = d_cv; out.gate = d_g; out.gated = d_gd; + out.normalized = d_nm; out.conv = d_co; out.result = d_cr; + // the workspace is the caller's, and the sync the block used to do is now the caller's too + void* ple_ws = nullptr; + ck(cudaMalloc(&ple_ws, k::ple_block_scratch_bytes()), "ple_block scratch"); + if (t == 0) { + const size_t bytes = (size_t) k::ple_block_scratch_bytes(); + ck(cudaMemset(ple_ws, 0xa5, bytes), "PLE prelaunch guard sentinel"); + const struct AliasCase { float* k::PleOut::* field; size_t offset; } cases[] = { + {&k::PleOut::key, 0}, {&k::PleOut::value, hcd}, {&k::PleOut::gate, hcd + nd}, + {&k::PleOut::gated, hcd + nd + (size_t) k::NG_HC}, + {&k::PleOut::normalized, 2 * hcd + nd + (size_t) k::NG_HC}, + {&k::PleOut::conv, 3 * hcd + nd + (size_t) k::NG_HC}, {&k::PleOut::result, 0} + }; + int refused = 0; + for (const auto& item : cases) { + k::PleOut invalid = out; + invalid.*(item.field) = static_cast(ple_ws) + item.offset; + try { k::ple_block(d_emb, d_hid, d_hist, w, invalid, ple_ws, nullptr); } + catch (const std::invalid_argument&) { ++refused; } + } + ck(cudaDeviceSynchronize(), "PLE guard no-launch sync"); + std::vector sentinel(bytes); + ck(cudaMemcpy(sentinel.data(), ple_ws, bytes, cudaMemcpyDeviceToHost), "PLE guard sentinel readback"); + const bool unchanged = std::all_of(sentinel.begin(), sentinel.end(), [](uint8_t b) { return b == 0xa5; }); + const bool rejected = refused == 7 && unchanged; + std::printf(" PLE old caller output offsets rejected before launch: %s (%d/7, scratch %s)\n", + rejected ? "pass" : "FAIL", refused, unchanged ? "unchanged" : "CORRUPTED"); + if (!rejected) ++bad; + } + k::ple_block(d_emb, d_hid, d_hist, w, out, ple_ws, nullptr); + ck(cudaDeviceSynchronize(), "ple_block sync"); + ck(cudaMemcpy(dev_key.data(), d_ck, hcd * 4, cudaMemcpyDeviceToHost), "rck"); + ck(cudaMemcpy(dev_value.data(), d_cv, nd * 4, cudaMemcpyDeviceToHost), "rcv"); + ck(cudaMemcpy(dev_gate.data(), d_g, k::NG_HC * 4, cudaMemcpyDeviceToHost), "rg"); + ck(cudaMemcpy(dev_gated.data(), d_gd, hcd * 4, cudaMemcpyDeviceToHost), "rgd"); + ck(cudaMemcpy(dev_norm.data(), d_nm, hcd * 4, cudaMemcpyDeviceToHost), "rnm"); + ck(cudaMemcpy(dev_conv.data(), d_co, hcd * 4, cudaMemcpyDeviceToHost), "rco"); + ck(cudaMemcpy(dev_res.data(), d_cr, hcd * 4, cudaMemcpyDeviceToHost), "rcr"); + + const size_t ok_ = (size_t) t * hcd, ov = (size_t) t * nd, og = (size_t) t * (size_t) k::NG_HC; + const Stage stages[] = { + {"key (grouped_norm of ple_key @ emb)", &dev_key, &cap.key, ok_, hcd}, + {"value (ple_value @ emb, BF16)", &dev_value, &cap.value, ov, nd}, + {"gate (signed sqrt, sigmoid)", &dev_gate, &cap.gate, og, (size_t) k::NG_HC}, + {"gated (value broadcast * gate)", &dev_gated, &cap.gated, ok_, hcd}, + {"normalized (grouped_norm(gated, ple_norm_conv))", &dev_norm, &cap.normalized, ok_, hcd}, + {"conv_out (dilated conv, then SiLU)", &dev_conv, &cap.conv_out, ok_, hcd}, + {"result (hidden + gated + conv)", &dev_res, &cap.result, ok_, hcd}, + }; + for (const Stage& s : stages) { + std::vector want(s.all->begin() + (long long) s.offset, + s.all->begin() + (long long) (s.offset + s.n)); + double mag = 0; + long long nf = 0; + const double rel = rel_l1(want.data(), s.got->data(), s.n, &mag, &nf); + // NaN-SAFE: `rel > worst_all` is false for NaN, so `fmax` here would silently report a perfect + // score for a fixture full of NaNs - round 197's exact failure. + worst_all = (rel > worst_all) ? rel : worst_all; + if (nf) worst_all = std::nan(""); + const bool last = (std::strncmp(s.name, "result", 6) == 0); + std::printf(" token %d %-46s rel %.3e (mean |ref| %.4f, non-finite %lld)%s\n", t, s.name, rel, + mag, nf, last && !le(rel, 1e-2) ? " *** over 1e-2 ***" : ""); + // TOLERANCE, and what sets it: this comparison is the GPU against a capture whose weights are + // F32, so ggml performed NO activation conversion while the kernel applies the CONTRACT (Q8_0 + // for the Q2_0 weight, BF16 for the BF16 one). The contract's per-element cost is 2^-9 = 1.95e-3 + // for BF16 and ~0.4% for Q8_0, so the OUTPUT difference is ~2e-3 wherever there is no + // cancellation and larger where there is - `conv_out` cancels by ~77x and is the one stage that + // exceeds it. `result` is the quantity that matters and 1e-2 is five times the contract's own + // per-element size, so it is a bound on "the contract and nothing else", not a judgement call. + // The HOST reference below uses the capture's own F32 weights and is asserted at 1e-4, which is + // what proves the difference here IS the contract. + if (last && !le(rel, 1e-2)) ++bad; + } + + if (t == 0) { + // Deliberate BF16 halfway values make the value projection's activation precision visible. + // The scalar reference uses the actual packed BF16 weight bits and unrounded F32 inputs. + std::vector witness(nd), reference_value(nd), native_value(nd), native_result(hcd), replay(hcd); + for (size_t i = 0; i < nd; ++i) witness[i] = i % 7 == 0 ? 1.00390625f : -0.501953125f; + for (size_t row = 0; row < nd; ++row) { + double sum = 0.0; + for (size_t col = 0; col < nd; ++col) { + const uint32_t bits = uint32_t(value_bf16[row * nd + col]) << 16; + float weight; + std::memcpy(&weight, &bits, sizeof(weight)); + sum += double(weight) * witness[col]; + } + reference_value[row] = float(sum); + } + ck(cudaMemcpy(d_emb, witness.data(), nd * sizeof(float), cudaMemcpyHostToDevice), "native PLE witness"); + k::ple_block(d_emb, d_hid, d_hist, w, out, ple_ws, nullptr); + ck(cudaDeviceSynchronize(), "legacy PLE witness sync"); + std::vector legacy_value(nd); + ck(cudaMemcpy(legacy_value.data(), d_cv, nd * sizeof(float), cudaMemcpyDeviceToHost), "legacy PLE witness value"); + cudaStream_t stream; + ck(cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking), "native PLE stream"); + k::ple_set_native_bf16(true); + k::ple_block(d_emb, d_hid, d_hist, w, out, ple_ws, stream); + ck(cudaStreamSynchronize(stream), "native PLE sync"); + ck(cudaMemcpy(native_value.data(), d_cv, nd * sizeof(float), cudaMemcpyDeviceToHost), "native PLE value"); + ck(cudaMemcpy(native_result.data(), d_cr, hcd * sizeof(float), cudaMemcpyDeviceToHost), "native PLE result"); + long long nf = 0; + const double rel = rel_l1(reference_value.data(), native_value.data(), nd, nullptr, &nf); + const double separation = rel_l1(native_value.data(), legacy_value.data(), nd); + const bool correct = le(rel, 2e-6) && nf == 0 && gt(separation, 1e-5); + std::printf(" native PLE value: %s (ref rel %.3e, BF16 separation %.3e)\n", + correct ? "pass" : "FAIL", rel, separation); + if (!correct) ++bad; + cudaGraph_t graph; + cudaGraphExec_t executable; + ck(cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal), "native PLE capture"); + k::ple_block(d_emb, d_hid, d_hist, w, out, ple_ws, stream); + ck(cudaStreamEndCapture(stream, &graph), "native PLE capture end"); + ck(cudaGraphInstantiate(&executable, graph, nullptr, nullptr, 0), "native PLE instantiate"); + k::ple_set_native_bf16(false); + ck(cudaGraphLaunch(executable, stream), "native PLE replay"); + ck(cudaStreamSynchronize(stream), "native PLE replay sync"); + ck(cudaMemcpy(replay.data(), d_cr, hcd * sizeof(float), cudaMemcpyDeviceToHost), "native PLE replay result"); + const bool captured = std::memcmp(native_result.data(), replay.data(), hcd * sizeof(float)) == 0; + std::printf(" native PLE captured selection: %s\n", captured ? "byte-identical" : "FAIL"); + if (!captured) ++bad; + ck(cudaGraphExecDestroy(executable), "native PLE graph exec destroy"); + ck(cudaGraphDestroy(graph), "native PLE graph destroy"); + ck(cudaStreamDestroy(stream), "native PLE stream destroy"); + ck(cudaMemcpy(d_emb, cap.emb.data(), nd * sizeof(float), cudaMemcpyHostToDevice), "restore PLE embedding"); + k::ple_block(d_emb, d_hid, d_hist, w, out, ple_ws, nullptr); + ck(cudaDeviceSynchronize(), "restored PLE sync"); + ck(cudaMemcpy(replay.data(), d_cr, hcd * sizeof(float), cudaMemcpyDeviceToHost), "restored PLE result"); + const bool restored = std::memcmp(dev_res.data(), replay.data(), hcd * sizeof(float)) == 0; + std::printf(" restored PLE default: %s\n", restored ? "byte-identical" : "FAIL"); + if (!restored) ++bad; + } + + if (t == 0) { + // This checks native-projection wiring and graph lifetime, independently + // of the legacy CPU/F32 structural capture. Actual Q2_0 arithmetic is + // checked separately by native_mmvq_parity against the pinned CUDA DLL. + const size_t blocks = n_scales, native_bytes = blocks * 18, guard = 64; + const size_t qbytes = k::native_q8_1_bytes(k::NG_N_EMBD); + std::vector native_key(native_bytes); + for (size_t b = 0; b < blocks; ++b) { + std::memcpy(native_key.data() + b * 18, raw_scales.data() + b * 2, 2); + std::memcpy(native_key.data() + b * 18 + 2, key_codes.data() + b * 16, 16); + } + void *native_storage = nullptr, *q_storage = nullptr; + float* raw_projection = nullptr; + ck(cudaMalloc(&native_storage, native_bytes + 2 * guard), "native PLE weights"); + ck(cudaMalloc(&q_storage, qbytes + 2 * guard), "native PLE q8 scratch"); + ck(cudaMalloc(&raw_projection, hcd * 4), "native PLE raw projection"); + ck(cudaMemset(native_storage, 0xa5, native_bytes + 2 * guard), "native PLE weight guards"); + ck(cudaMemset(q_storage, 0xa5, qbytes + 2 * guard), "native PLE q8 guards"); + void* native_data = static_cast(native_storage) + guard; + void* native_q = static_cast(q_storage) + guard; + ck(cudaMemcpy(native_data, native_key.data(), native_bytes, cudaMemcpyHostToDevice), "native PLE weights upload"); + k::PleWeights nw = w; + nw.key_native_data = native_data; nw.key_native_type = 42; nw.key_native_q8_1 = native_q; + cudaStream_t stream; + ck(cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking), "native PLE key stream"); + const size_t workspace_bytes = (size_t) k::ple_block_scratch_bytes(); + ck(cudaMemset(ple_ws, 0xa5, workspace_bytes), "native PLE no-launch sentinel"); + int refused = 0; + for (int c = 0; c < 9; ++c) { + auto invalid = nw; + if (c == 0) invalid.key_native_type = 11; + if (c == 1) invalid.key_native_q8_1 = nullptr; + if (c == 3) invalid.key_native_q8_1 = ple_ws; + if (c == 4) invalid.key_native_q8_1 = d_emb; + if (c == 5) invalid.key_native_q8_1 = out.key; + if (c == 6) invalid.key_native_q8_1 = static_cast(native_q) + 1; + if (c == 7) invalid.key_native_data = static_cast(native_data) + 1; + if (c == 8) invalid.key_native_q8_1 = native_data; + try { k::ple_block(d_emb, d_hid, d_hist, invalid, out, ple_ws, c == 2 ? nullptr : stream); } + catch (const std::invalid_argument&) { ++refused; } + } + ck(cudaStreamSynchronize(stream), "native PLE refusal sync"); + std::vector sentinel(workspace_bytes); + ck(cudaMemcpy(sentinel.data(), ple_ws, workspace_bytes, cudaMemcpyDeviceToHost), "native PLE refusal sentinel"); + const bool untouched = std::all_of(sentinel.begin(), sentinel.end(), [](uint8_t x) { return x == 0xa5; }); + std::printf(" native PLE key prelaunch guards: %s (%d/9, workspace %s)\n", + refused == 9 && untouched ? "pass" : "FAIL", refused, untouched ? "unchanged" : "changed"); + if (refused != 9 || !untouched) ++bad; + cudaGraph_t graph = nullptr; cudaGraphExec_t executable = nullptr; + std::vector projected(hcd), normalized(hcd), actual_key(hcd), actual_result(hcd), replay(hcd); + for (int p = 0; p < std::min(cap.nt, 2); ++p) { + ck(cudaMemcpy(d_emb, cap.emb.data() + (size_t) p * nd, nd * 4, cudaMemcpyHostToDevice), "native PLE key input"); + k::native_q2_0_f32(native_data, d_emb, native_q, raw_projection, k::NG_N_EMBD, k::NG_HC_DIM, 1, stream); + ck(cudaStreamSynchronize(stream), "native PLE raw key sync"); + ck(cudaMemcpy(projected.data(), raw_projection, hcd * 4, cudaMemcpyDeviceToHost), "native PLE raw key read"); + for (int c = 0; c < k::NG_HC; ++c) { + double sum = 0; + for (size_t j = 0; j < nd; ++j) { const float v = projected[(size_t) c * nd + j]; sum += double(v * v); } + const float scale = 1.0f / std::sqrt(float(sum / double(nd)) + k::NG_RMS_EPS); + for (size_t j = 0; j < nd; ++j) { const size_t i = (size_t) c * nd + j; normalized[i] = projected[i] * scale * cap.w_nk[i]; } + } + k::ple_block(d_emb, d_hid, d_hist, nw, out, ple_ws, stream); + ck(cudaStreamSynchronize(stream), "native PLE key direct sync"); + ck(cudaMemcpy(actual_key.data(), d_ck, hcd * 4, cudaMemcpyDeviceToHost), "native PLE key read"); + ck(cudaMemcpy(actual_result.data(), d_cr, hcd * 4, cudaMemcpyDeviceToHost), "native PLE result read"); + const double rel = rel_l1(normalized.data(), actual_key.data(), hcd); + const bool wired = le(rel, 2e-6) && nonfinite(actual_result.data(), hcd) == 0; + std::printf(" native PLE key projection->existing norm input %d: %s (rel %.3e)\n", p, wired ? "pass" : "FAIL", rel); + if (!wired) ++bad; + if (p == 0) { + ck(cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal), "native PLE key capture"); + k::ple_block(d_emb, d_hid, d_hist, nw, out, ple_ws, stream); + ck(cudaStreamEndCapture(stream, &graph), "native PLE key capture end"); + ck(cudaGraphInstantiate(&executable, graph, nullptr, nullptr, 0), "native PLE key instantiate"); + } + nw.key_native_data = nullptr; // graph selection must survive descriptor changes + for (int repeat = 0; repeat < 2; ++repeat) { + ck(cudaGraphLaunch(executable, stream), "native PLE key replay"); + ck(cudaStreamSynchronize(stream), "native PLE key replay sync"); + ck(cudaMemcpy(replay.data(), d_cr, hcd * 4, cudaMemcpyDeviceToHost), "native PLE key replay read"); + if (std::memcmp(actual_result.data(), replay.data(), hcd * 4) != 0) ++bad; + } + nw.key_native_data = native_data; + } + ck(cudaGraphExecDestroy(executable), "native PLE key executable destroy"); + ck(cudaGraphDestroy(graph), "native PLE key graph destroy"); + for (int b = 0; b < 2; ++b) { + const void* storage = b == 0 ? native_storage : q_storage; + const size_t payload = b == 0 ? native_bytes : qbytes; + std::vector ends(2 * guard); + ck(cudaMemcpy(ends.data(), storage, guard, cudaMemcpyDeviceToHost), "native PLE prefix guard"); + ck(cudaMemcpy(ends.data() + guard, static_cast(storage) + guard + payload, guard, cudaMemcpyDeviceToHost), "native PLE suffix guard"); + if (!std::all_of(ends.begin(), ends.end(), [](uint8_t x) { return x == 0xa5; })) ++bad; + } + ck(cudaStreamDestroy(stream), "native PLE key stream destroy"); + cudaFree(raw_projection); cudaFree(q_storage); cudaFree(native_storage); + ck(cudaMemcpy(d_emb, cap.emb.data(), nd * 4, cudaMemcpyHostToDevice), "native PLE restore input"); + k::ple_block(d_emb, d_hid, d_hist, w, out, ple_ws, nullptr); + ck(cudaDeviceSynchronize(), "native PLE default restore sync"); + ck(cudaMemcpy(replay.data(), d_cr, hcd * 4, cudaMemcpyDeviceToHost), "native PLE default restore read"); + const bool restored = std::memcmp(dev_res.data(), replay.data(), hcd * 4) == 0; + std::printf(" native PLE key graph/repeat/buffer guards checked; restored default: %s\n", restored ? "byte-identical" : "FAIL"); + if (!restored) ++bad; + } + + if (t == 0) { + // The engine only needs result and normalized. Export normalized after the complete private + // workspace; result may overwrite hidden once its original values are no longer needed. + const size_t workspace_bytes = (size_t) k::ple_block_scratch_bytes(); + void* compact_workspace = nullptr; + ck(cudaMalloc(&compact_workspace, workspace_bytes + hcd * sizeof(float)), "compact PLE workspace"); + k::PleOut compact{}; + compact.normalized = reinterpret_cast(static_cast(compact_workspace) + workspace_bytes); + compact.result = d_hid; + k::ple_block(d_emb, d_hid, d_hist, w, compact, compact_workspace, nullptr); + ck(cudaDeviceSynchronize(), "compact PLE sync"); + std::vector compact_norm(hcd), compact_result(hcd); + ck(cudaMemcpy(compact_norm.data(), compact.normalized, hcd * sizeof(float), cudaMemcpyDeviceToHost), "compact PLE normalized"); + ck(cudaMemcpy(compact_result.data(), d_hid, hcd * sizeof(float), cudaMemcpyDeviceToHost), "compact PLE result"); + const bool equal = std::memcmp(compact_norm.data(), dev_norm.data(), hcd * sizeof(float)) == 0 && + std::memcmp(compact_result.data(), dev_res.data(), hcd * sizeof(float)) == 0; + std::printf(" PLE separate exports and in-place hidden result: %s\n", equal ? "byte-identical" : "FAIL"); + if (!equal) ++bad; + ck(cudaMemcpy(d_hid, cap.hidden.data(), hcd * sizeof(float), cudaMemcpyHostToDevice), "restore PLE hidden input"); + cudaFree(compact_workspace); + } + + // slide the conv state by one NORMALIZED row, which is what the next token's window needs + for (size_t c = 0; c < hcd; ++c) + for (size_t r = 0; r + 1 < (size_t) k::NG_HIST; ++r) + hist_state[r + (size_t) k::NG_HIST * c] = hist_state[(r + 1) + (size_t) k::NG_HIST * c]; + for (size_t c = 0; c < hcd; ++c) + hist_state[((size_t) k::NG_HIST - 1) + (size_t) k::NG_HIST * c] = dev_norm[c]; + cudaFree(ple_ws); + } + + // ---- the trap that matters most: gated vs normalized as the conv input -------------------------- + // `ref/ngram.py`'s docstring calls `terms` "the padded gated values"; the source pads `normalized`. + // Feeding the conv the gated values instead is a one-word change with every shape intact, so it is + // computed here and required to differ. + { + const double rel_norm_gated = rel_l1(cap.gated.data(), cap.normalized.data(), hcd); + std::printf(" %-52s %s (%.2f%% apart)\n", "normalized vs gated as the conv input is observable", + rel_norm_gated > 0.05 ? "yes" : "*** NO ***", rel_norm_gated * 100); + if (!(rel_norm_gated > 0.05)) ++bad; + } + + // ---- the conv TAP ORDER: tap 0 reads the furthest back, not the current row --------------------- + { + // Recompute the conv on the host with the taps reversed, from the ORACLE's own `normalized`, and + // require the result to differ from the oracle's conv_out. + const size_t hcd2 = hcd; + std::vector rev(hcd2, 0.0f); + for (size_t c = 0; c < hcd2; ++c) { + float acc = 0; + for (int kk = 0; kk < k::PLE_CONV_KERNEL; ++kk) { + // THE RIVAL READING OF THE TAP CONVENTION. ggml's is `t - (K-1-k)*d`, i.e. tap 0 reads the + // FURTHEST back; the natural misreading is `t - k*d`, i.e. tap 0 reads the CURRENT row. + // + // The first version of this trap wrote `row = kk*dil` for the rival - which is ALGEBRAICALLY + // THE SAME as the correct `hist - (K-1-kk)*dil` whenever `hist == (K-1)*dil`, because + // `9-(3-kk)*3 == 3kk` identically. It reported "0.00% apart" and was measuring nothing. + // The rival has to REVERSE the row order: `(K-1-kk)*dil` gives 9,6,3,0 against the correct + // 0,3,6,9, so tap 0 pairs the kernel's first weight with the NEWEST row instead of the oldest. + const int row = (k::PLE_CONV_KERNEL - 1 - kk) * k::NGRAM_SIZE; + // ROW-FASTEST: the capture's history is ggml's `ne=(hist, hc_dim)`, flat = row + hist*channel + const float v = (row == k::NG_HIST) ? cap.normalized[c] + : cap.hist[(size_t) row + (size_t) k::NG_HIST * c]; + // GGML-NATIVE: `w_conv[k + kern*c]`, because the capture's tensor is ne=(kern, hc_dim) with + // ne0 = kern fast. `w_conv[kk*hc_dim + c]` is the TRANSPOSE and is what this reference had + // first - it reported 115% on conv_out while the GPU kernel, which carries the layout note, + // was within 4.6e-02. The oracle caught the reference, not the kernel, which is the whole + // reason for having one. + acc += cap.w_conv[(size_t) kk + (size_t) k::PLE_CONV_KERNEL * c] * v; + } + rev[c] = acc / (1.0f + std::exp(-acc)); + } + const double rel = rel_l1(cap.conv_out.data(), rev.data(), hcd2); + std::printf(" %-52s %s (%.2f%% apart)\n", "reversed conv tap order is observable", + gt(rel, 0.05) ? "yes" : "*** NO ***", rel * 100); + if (!gt(rel, 0.05)) ++bad; + } + + // ---- THE HOST f32 REFERENCE, which is what makes the gaps above ATTRIBUTABLE rather than mysterious. + // + // `ple_layer_xcheck.cpp` builds ggml's graph with the weights as **F32 tensors** (`ggml_new_tensor_2d(..., + // GGML_TYPE_F32, ...)`). `ggml_mul_mat` converts src1 to src0's `vec_dot_type`, so with an F32 weight + // ggml performs NO activation conversion at all - while the real `ple_key` is Q2_0 (contract Q8_0) and the + // real `ple_value` is BF16 (contract BF16). + // + // **The capture is therefore a valid oracle for the STRUCTURE and not for the ACTIVATION CONTRACT.** This + // reference reproduces it exactly, in f32, using the capture's own weights - which proves every LAYOUT in + // the block (the conv kernel's `k + kern*c`, the row-fastest history, per-stream grouped norms, the + // head-slowest gather) independently of the GPU. Whatever gap remains between the GPU and the capture is + // then exactly the contract, and nothing else. + // Hoisted out of the reference block below: the term-magnitude metric needs the state the LAST token + // used, and that block has already closed by the time it runs. + std::vector hist_last; + { + const size_t H = hcd, N = nd; + std::vector k_ref(H), q_ref(H), v_ref(N), gt_ref(k::NG_HC), gd_ref(H), nm_ref(H), cv_ref(H), + rs_ref(H); + auto gnorm = [&](const float* x, const float* w, float* y, int n) { + for (int c = 0; c < k::NG_HC; ++c) { + double sum = 0.0; + for (int d = 0; d < k::NG_N_EMBD; ++d) { + const float xv = x[c * k::NG_N_EMBD + d]; + sum += (double) (xv * xv); // f32 product, widened - as ggml does + } + const float mean = (float) (sum / k::NG_N_EMBD); + const float scale = 1.0f / std::sqrt(mean + cap.eps); + for (int d = 0; d < k::NG_N_EMBD; ++d) + y[c * k::NG_N_EMBD + d] = x[c * k::NG_N_EMBD + d] * scale * w[c * k::NG_N_EMBD + d]; + } + (void) n; + }; + // The SAME history advance as the GPU loop, so the two are compared on identical inputs. + std::vector hist_ref = cap.hist; + for (int t = 0; t < cap.nt; ++t) { + const float* emb = cap.emb.data() + (size_t) t * N; + const float* hid = cap.hidden.data() + (size_t) t * H; + // key = w_key @ emb, with the capture's F32 weight and NO activation conversion + for (size_t o = 0; o < H; ++o) { + double a = 0; + for (size_t i = 0; i < N; ++i) a += (double) cap.w_key[o * N + i] * (double) emb[i]; + k_ref[o] = (float) a; + } + gnorm(k_ref.data(), cap.w_nk.data(), k_ref.data(), k::NG_N_EMBD); + gnorm(hid, cap.w_nq.data(), q_ref.data(), k::NG_N_EMBD); + for (size_t o = 0; o < N; ++o) { + double a = 0; + for (size_t i = 0; i < N; ++i) a += (double) cap.w_value[o * N + i] * (double) emb[i]; + v_ref[o] = (float) a; + } + for (int c = 0; c < k::NG_HC; ++c) { + double a = 0; + for (int d = 0; d < k::NG_N_EMBD; ++d) + a += (double) k_ref[c * k::NG_N_EMBD + d] * (double) q_ref[c * k::NG_N_EMBD + d]; + const float s = (float) (a / (double) 1.0) / std::sqrt((float) k::NG_N_EMBD); + const float mag = std::sqrt(std::fmax(std::fabs(s), 1e-6f)); + const float sgn = (s > 0) ? 1.0f : ((s < 0) ? -1.0f : 0.0f); + gt_ref[c] = 1.0f / (1.0f + std::exp(-(sgn * mag))); + } + for (size_t i = 0; i < H; ++i) + gd_ref[i] = v_ref[i % N] * gt_ref[i / N]; + gnorm(gd_ref.data(), cap.w_nc.data(), nm_ref.data(), k::NG_N_EMBD); + for (size_t c = 0; c < H; ++c) { + float acc = 0; + for (int kk = 0; kk < k::PLE_CONV_KERNEL; ++kk) { + const int row = k::NG_HIST - (k::PLE_CONV_KERNEL - 1 - kk) * k::NGRAM_SIZE; + // capture history is ggml `ne=(hist, hc_dim)`: flat = row + hist*channel. `hist_ref` is + // the ADVANCED state, so this reference sees exactly what the GPU call saw. + const float vv = (row == k::NG_HIST) ? nm_ref[c] + : hist_ref[(size_t) row + (size_t) k::NG_HIST * c]; + acc += cap.w_conv[(size_t) kk + (size_t) k::PLE_CONV_KERNEL * c] * vv; + } + cv_ref[c] = acc / (1.0f + std::exp(-acc)); + } + for (size_t i = 0; i < H; ++i) rs_ref[i] = hid[i] + gd_ref[i] + cv_ref[i]; + + const size_t ok_ = (size_t) t * H, ov = (size_t) t * N, og = (size_t) t * (size_t) k::NG_HC; + struct HCmp { const char* name; const std::vector* got; const std::vector* all; + size_t off; size_t n; }; + const HCmp cmps[] = { + {"key", &k_ref, &cap.key, ok_, H}, {"value", &v_ref, &cap.value, ov, N}, + {"gate", >_ref, &cap.gate, og, (size_t) k::NG_HC}, + {"gated", &gd_ref, &cap.gated, ok_, H}, {"normalized", &nm_ref, &cap.normalized, ok_, H}, + {"conv_out", &cv_ref, &cap.conv_out, ok_, H}, + {"result", &rs_ref, &cap.result, ok_, H}, + }; + for (const HCmp& s : cmps) { + std::vector want(s.all->begin() + (long long) s.off, + s.all->begin() + (long long) (s.off + s.n)); + long long nf = 0; + const double rel = rel_l1(want.data(), s.got->data(), s.n, nullptr, &nf); + const bool ok = le(rel, 1e-4) && nf == 0; + std::printf(" host f32 reference, token %d %-12s rel %.3e (non-finite %lld)%s\n", t, s.name, + rel, nf, ok ? "" : " *** FAIL ***"); + if (!ok) ++bad; + } + + // save the state this token used, then slide it - identical to the GPU-side advance + if (t == cap.nt - 1) hist_last = hist_ref; + for (size_t c = 0; c < hcd; ++c) + for (size_t r = 0; r + 1 < (size_t) k::NG_HIST; ++r) + hist_ref[r + (size_t) k::NG_HIST * c] = hist_ref[(r + 1) + (size_t) k::NG_HIST * c]; + for (size_t c = 0; c < hcd; ++c) + hist_ref[((size_t) k::NG_HIST - 1) + (size_t) k::NG_HIST * c] = nm_ref[c]; + } + // TOLERANCE: 1e-4. Both sides are f32 (or f64-accumulated) sums of 2560 products, so the only + // difference is summation ORDER, which is ~n*eps = 2560*6e-8 = 1.5e-4 worst case and far less in + // practice. A STRUCTURAL error - a transposed conv kernel, a channel-slow history, a head-fastest + // gather - is O(1), so the two are three orders apart and the bound is not a judgement call. + } + + // ---- `conv_out`'s error must be measured against the TERMS, not the result ---------------------- + // + // The dilated conv sums four terms and the sum CANCELS: `normalized` has mean |.| around 0.70 and the + // result's is around 0.009, a factor of ~77. Dividing by the result therefore reports the CONDITION + // NUMBER and not the arithmetic - the same metric mistake this project has made four times (rounds 169, + // 189, 194, 196), and it is what made `conv_out` look like the worst stage at 4.55e-02. + { + // `dev_conv` holds the LAST token after the loop above, and the capture is token-major. + const float* oc = cap.conv_out.data() + (size_t) (cap.nt - 1) * hcd; + double num = 0, terms = 0, res = 0; + for (size_t c = 0; c < hcd; ++c) { + double t_sum = 0; + for (int kk = 0; kk < k::PLE_CONV_KERNEL; ++kk) { + const int row = k::NG_HIST - (k::PLE_CONV_KERNEL - 1 - kk) * k::NGRAM_SIZE; + const float v = (row == k::NG_HIST) + ? cap.normalized[(size_t) (cap.nt - 1) * hcd + c] + : hist_last[(size_t) row + (size_t) k::NG_HIST * c]; + t_sum += std::fabs((double) cap.w_conv[(size_t) kk + (size_t) k::PLE_CONV_KERNEL * c] * + (double) v); + } + terms += t_sum; + res += std::fabs((double) oc[c]); + num += std::fabs((double) oc[c] - (double) dev_conv[c]); + } + std::printf("\n conv_out (token %d) against the TERM magnitude, not the result: rel %.3e" + " (|result|/|terms| = %.4f)\n", + cap.nt - 1, num / terms, res / terms); + } + + std::printf("\nple_parity: %d failures (worst stage rel %.3e)\n", bad, worst_all); + if (bad) return 1; + if (selftest) std::printf("ple_parity OK\n"); + return 0; +} diff --git a/src/kernels/qsa_oracle_vectors.inc b/src/kernels/qsa_oracle_vectors.inc new file mode 100644 index 00000000..0ceec330 --- /dev/null +++ b/src/kernels/qsa_oracle_vectors.inc @@ -0,0 +1,134 @@ +// GENERATED by scripts/_gen_f16_oracle.py - do not edit. +// {f32 bits, np.float16(np.float32(x)) bits} interleaved, flat, for qsa_parity.cpp. + 0x00000000u, 0x0000u, 0x80000000u, 0x8000u, 0x3F800000u, 0x3C00u, + 0xBF800000u, 0xBC00u, 0x3F000000u, 0x3800u, 0x477FE000u, 0x7BFFu, + 0x477FEF00u, 0x7BFFu, 0x477FF000u, 0x7C00u, 0x47800000u, 0x7C00u, + 0x7149F2CAu, 0x7C00u, 0xF149F2CAu, 0xFC00u, 0x38800000u, 0x0400u, + 0x33800000u, 0x0001u, 0x33000000u, 0x0000u, 0x3380D959u, 0x0001u, + 0xB380D959u, 0x8001u, 0x337FFAE5u, 0x0001u, 0x322BCC77u, 0x0000u, + 0x00000001u, 0x0000u, 0x3EAAAAABu, 0x3555u, 0x39000000u, 0x0800u, + 0x47000000u, 0x7800u, 0x3727C5ACu, 0x00A8u, 0x38800800u, 0x0400u, + 0x38801000u, 0x0400u, 0x38801800u, 0x0401u, 0x39000800u, 0x0800u, + 0x39001000u, 0x0800u, 0x39001800u, 0x0801u, 0x39800000u, 0x0C00u, + 0x39800800u, 0x0C00u, 0x39801000u, 0x0C00u, 0x39801800u, 0x0C01u, + 0x3A000000u, 0x1000u, 0x3A000800u, 0x1000u, 0x3A001000u, 0x1000u, + 0x3A001800u, 0x1001u, 0x3A800000u, 0x1400u, 0x3A800800u, 0x1400u, + 0x3A801000u, 0x1400u, 0x3A801800u, 0x1401u, 0x3B000000u, 0x1800u, + 0x3B000800u, 0x1800u, 0x3B001000u, 0x1800u, 0x3B001800u, 0x1801u, + 0x3B800000u, 0x1C00u, 0x3B800800u, 0x1C00u, 0x3B801000u, 0x1C00u, + 0x3B801800u, 0x1C01u, 0x3C000000u, 0x2000u, 0x3C000800u, 0x2000u, + 0x3C001000u, 0x2000u, 0x3C001800u, 0x2001u, 0x3C800000u, 0x2400u, + 0x3C800800u, 0x2400u, 0x3C801000u, 0x2400u, 0x3C801800u, 0x2401u, + 0x3D000000u, 0x2800u, 0x3D000800u, 0x2800u, 0x3D001000u, 0x2800u, + 0x3D001800u, 0x2801u, 0x3D800000u, 0x2C00u, 0x3D800800u, 0x2C00u, + 0x3D801000u, 0x2C00u, 0x3D801800u, 0x2C01u, 0x3E000000u, 0x3000u, + 0x3E000800u, 0x3000u, 0x3E001000u, 0x3000u, 0x3E001800u, 0x3001u, + 0x3E800000u, 0x3400u, 0x3E800800u, 0x3400u, 0x3E801000u, 0x3400u, + 0x3E801800u, 0x3401u, 0x3F000800u, 0x3800u, 0x3F001000u, 0x3800u, + 0x3F001800u, 0x3801u, 0x3F800800u, 0x3C00u, 0x3F801000u, 0x3C00u, + 0x3F801800u, 0x3C01u, 0x40000000u, 0x4000u, 0x40000800u, 0x4000u, + 0x40001000u, 0x4000u, 0x40001800u, 0x4001u, 0x40800000u, 0x4400u, + 0x40800800u, 0x4400u, 0x40801000u, 0x4400u, 0x40801800u, 0x4401u, + 0x41000000u, 0x4800u, 0x41000800u, 0x4800u, 0x41001000u, 0x4800u, + 0x41001800u, 0x4801u, 0x41800000u, 0x4C00u, 0x41800800u, 0x4C00u, + 0x41801000u, 0x4C00u, 0x41801800u, 0x4C01u, 0x42000000u, 0x5000u, + 0x42000800u, 0x5000u, 0x42001000u, 0x5000u, 0x42001800u, 0x5001u, + 0x42800000u, 0x5400u, 0x42800800u, 0x5400u, 0x42801000u, 0x5400u, + 0x42801800u, 0x5401u, 0x43000000u, 0x5800u, 0x43000800u, 0x5800u, + 0x43001000u, 0x5800u, 0x43001800u, 0x5801u, 0x43800000u, 0x5C00u, + 0x43800800u, 0x5C00u, 0x43801000u, 0x5C00u, 0x43801800u, 0x5C01u, + 0x44000000u, 0x6000u, 0x44000800u, 0x6000u, 0x44001000u, 0x6000u, + 0x44001800u, 0x6001u, 0x44800000u, 0x6400u, 0x44800800u, 0x6400u, + 0x44801000u, 0x6400u, 0x44801800u, 0x6401u, 0x45000000u, 0x6800u, + 0x45000800u, 0x6800u, 0x45001000u, 0x6800u, 0x45001800u, 0x6801u, + 0x45800000u, 0x6C00u, 0x45800800u, 0x6C00u, 0x45801000u, 0x6C00u, + 0x45801800u, 0x6C01u, 0x46000000u, 0x7000u, 0x46000800u, 0x7000u, + 0x46001000u, 0x7000u, 0x46001800u, 0x7001u, 0x46800000u, 0x7400u, + 0x46800800u, 0x7400u, 0x46801000u, 0x7400u, 0x46801800u, 0x7401u, + 0x47000800u, 0x7800u, 0x47001000u, 0x7800u, 0x47001800u, 0x7801u, + 0x33C00000u, 0x0002u, 0x34000000u, 0x0002u, 0x34200000u, 0x0002u, + 0x34400000u, 0x0003u, 0x34600000u, 0x0004u, 0x34800000u, 0x0004u, + 0x34900000u, 0x0004u, 0x34A00000u, 0x0005u, 0x34B00000u, 0x0006u, + 0x34C00000u, 0x0006u, 0x34D00000u, 0x0006u, 0x34E00000u, 0x0007u, + 0x34F00000u, 0x0008u, 0x35000000u, 0x0008u, 0x35080000u, 0x0008u, + 0x35100000u, 0x0009u, 0x35180000u, 0x000Au, 0x35200000u, 0x000Au, + 0x35280000u, 0x000Au, 0x35300000u, 0x000Bu, 0x35380000u, 0x000Cu, + 0x35400000u, 0x000Cu, 0x35480000u, 0x000Cu, 0x35500000u, 0x000Du, + 0x35580000u, 0x000Eu, 0x35600000u, 0x000Eu, 0x35680000u, 0x000Eu, + 0x35700000u, 0x000Fu, 0x35780000u, 0x0010u, 0x35800000u, 0x0010u, + 0x35840000u, 0x0010u, 0x35880000u, 0x0011u, 0x358C0000u, 0x0012u, + 0x35900000u, 0x0012u, 0x35940000u, 0x0012u, 0x35980000u, 0x0013u, + 0x359C0000u, 0x0014u, 0x35A00000u, 0x0014u, 0x35A40000u, 0x0014u, + 0x35A80000u, 0x0015u, 0x35AC0000u, 0x0016u, 0x35B00000u, 0x0016u, + 0x35B40000u, 0x0016u, 0x35B80000u, 0x0017u, 0x35BC0000u, 0x0018u, + 0x35C00000u, 0x0018u, 0x35C40000u, 0x0018u, 0x35C80000u, 0x0019u, + 0x35CC0000u, 0x001Au, 0x35D00000u, 0x001Au, 0x35D40000u, 0x001Au, + 0x35D80000u, 0x001Bu, 0x35DC0000u, 0x001Cu, 0x35E00000u, 0x001Cu, + 0x35E40000u, 0x001Cu, 0x35E80000u, 0x001Du, 0x35EC0000u, 0x001Eu, + 0x35F00000u, 0x001Eu, 0x35F40000u, 0x001Eu, 0x35F80000u, 0x001Fu, + 0x35FC0000u, 0x0020u, 0x36000000u, 0x0020u, 0x36020000u, 0x0020u, + 0x36040000u, 0x0021u, 0x36060000u, 0x0022u, 0x36080000u, 0x0022u, + 0x360A0000u, 0x0022u, 0x360C0000u, 0x0023u, 0x360E0000u, 0x0024u, + 0x36100000u, 0x0024u, 0x36120000u, 0x0024u, 0x36140000u, 0x0025u, + 0x36160000u, 0x0026u, 0x36180000u, 0x0026u, 0x361A0000u, 0x0026u, + 0x361C0000u, 0x0027u, 0x361E0000u, 0x0028u, 0xB70C380Bu, 0x808Cu, + 0xC62CBAD6u, 0xF166u, 0xC6167C83u, 0xF0B4u, 0xC5E87D75u, 0xEF44u, + 0xB411536Du, 0x8002u, 0x422B1CD9u, 0x5159u, 0x3BAC7D0Au, 0x1D64u, + 0x3E777C7Eu, 0x33BCu, 0xC34DA8EFu, 0xDA6Du, 0xC7F7B602u, 0xFC00u, + 0xB58914D2u, 0x8011u, 0x40727A6Fu, 0x4394u, 0x3946E5AAu, 0x0A37u, + 0x4671C2D0u, 0x738Eu, 0x3565BDBDu, 0x000Eu, 0x3BEFF6A4u, 0x1F80u, + 0xB136ED95u, 0x8000u, 0xBB940CC6u, 0x9CA0u, 0xBD5ADDB8u, 0xAAD7u, + 0x42954B1Eu, 0x54AAu, 0x35F55F4Cu, 0x001Fu, 0x33ABC544u, 0x0001u, + 0x42DE8A1Bu, 0x56F4u, 0x445C4B5Eu, 0x62E2u, 0xC26C283Au, 0xD361u, + 0x34959C9Du, 0x0005u, 0x3D035DE5u, 0x281Bu, 0xBD5F8FB9u, 0xAAFCu, + 0x42A6E954u, 0x5537u, 0x3978C191u, 0x0BC6u, 0xB4FC63E2u, 0x8008u, + 0x40B270AEu, 0x4594u, 0x46CBF834u, 0x7660u, 0x363DC03Fu, 0x002Fu, + 0xB1B0AAD8u, 0x8000u, 0x477E0850u, 0x7BF0u, 0x3357EDFFu, 0x0001u, + 0xBEF7D490u, 0xB7BFu, 0xB230011Bu, 0x8000u, 0xC41F8C42u, 0xE0FCu, + 0x40EE6FA8u, 0x4773u, 0xC020D7F5u, 0xC107u, 0xBA17B5A6u, 0x90BEu, + 0x39F609F8u, 0x0FB0u, 0xC0169BEBu, 0xC0B5u, 0xC34D25A0u, 0xDA69u, + 0xC23263C4u, 0xD193u, 0xC05BA352u, 0xC2DDu, 0xC7355027u, 0xF9ABu, + 0x335A4A8Du, 0x0001u, 0x4196D58Bu, 0x4CB7u, 0xB2F09C6Au, 0x8000u, + 0xC6A7EA97u, 0xF53Fu, 0x308ED1F1u, 0x0000u, 0x33A60BD6u, 0x0001u, + 0xB486B455u, 0x8004u, 0x3F4A08F6u, 0x3A50u, 0x363B9A5Bu, 0x002Fu, + 0xC48BA363u, 0xE45Du, 0x3EFBCC45u, 0x37DEu, 0xBDF8FE27u, 0xAFC8u, + 0xC55797A8u, 0xEABDu, 0xC008C482u, 0xC046u, 0xC3ED6604u, 0xDF6Bu, + 0xBF0D00C6u, 0xB868u, 0xC3AC7327u, 0xDD64u, 0x3B6245DCu, 0x1B12u, + 0xC4AD660Bu, 0xE56Bu, 0x404E99CEu, 0x4275u, 0xB09DE09Du, 0x8000u, + 0x3AEFA82Eu, 0x177Du, 0x4797A354u, 0x7C00u, 0xBC662E46u, 0xA331u, + 0x3EBF9AF7u, 0x35FDu, 0xBF8B651Cu, 0xBC5Bu, 0x3DC3DF55u, 0x2E1Fu, + 0x3420659Fu, 0x0003u, 0xB5BF3014u, 0x8018u, 0x3C229714u, 0x2115u, + 0xBE7A7284u, 0xB3D4u, 0xC544A929u, 0xEA25u, 0x34711195u, 0x0004u, + 0x365D12F9u, 0x0037u, 0xC71DE14Du, 0xF8EFu, 0xBBB6EE2Au, 0x9DB7u, + 0x4285DC79u, 0x542Fu, 0xC1479433u, 0xCA3Du, 0x443A29D6u, 0x61D1u, + 0xC6870A39u, 0xF438u, 0x470B6E9Cu, 0x785Bu, 0x449AF78Au, 0x64D8u, + 0x34098DE2u, 0x0002u, 0xBBFC11E5u, 0x9FE1u, 0x42922E1Bu, 0x5491u, + 0x3DBFB02Eu, 0x2DFEu, 0xB5A516FCu, 0x8015u, 0x447B2862u, 0x63D9u, + 0xC085C952u, 0xC42Eu, 0x442CC939u, 0x6166u, 0xB126F155u, 0x8000u, + 0x3F982F47u, 0x3CC1u, 0xC36C38DFu, 0xDB62u, 0xC1E21DBCu, 0xCF11u, + 0xC42DA5DDu, 0xE16Du, 0x3AD3CEC3u, 0x169Eu, 0x35319027u, 0x000Bu, + 0x34F38AABu, 0x0008u, 0xBBB4902Du, 0x9DA5u, 0x42A8B660u, 0x5546u, + 0xBE02F143u, 0xB018u, 0xB84BE09Au, 0x8330u, 0xBF215C12u, 0xB90Bu, + 0xBA674F20u, 0x933Au, 0xC0EBE307u, 0xC75Fu, 0x3AC119A5u, 0x1609u, + 0x344B18AFu, 0x0003u, 0xC183EB4Bu, 0xCC1Fu, 0xB96414D1u, 0x8B21u, + 0xB74DB17Du, 0x80CEu, 0xC3AF2570u, 0xDD79u, 0xB570C5F3u, 0x800Fu, + 0xB568C27Cu, 0x800Fu, 0xBE03F2E7u, 0xB020u, 0xBF216402u, 0xB90Bu, + 0xC16D1B4Au, 0xCB69u, 0x35897C73u, 0x0011u, 0xB771D56Bu, 0x80F2u, + 0xB5BB317Bu, 0x8017u, 0x367F3A0Bu, 0x0040u, 0xB6EE0B0Du, 0x8077u, + 0x416379C7u, 0x4B1Cu, 0xBA821968u, 0x9411u, 0x3D07874Du, 0x283Cu, + 0xBB951706u, 0x9CA9u, 0x402BDEC0u, 0x415Fu, 0x37F183A2u, 0x01E3u, + 0x3C0CE282u, 0x2067u, 0xBD40215Fu, 0xAA01u, 0x3BE1404Eu, 0x1F0Au, + 0x429C6774u, 0x54E3u, 0xC0E2E84Eu, 0xC717u, 0xC118375Du, 0xC8C2u, + 0x338E0178u, 0x0001u, 0xBEADFB76u, 0xB570u, 0xC2CDE39Bu, 0xD66Fu, + 0x3505E06Fu, 0x0008u, 0xC0278755u, 0xC13Cu, 0x3A727A53u, 0x1394u, + 0x3F954D3Fu, 0x3CAAu, 0xBD8BB0B5u, 0xAC5Eu, 0x3F5C2F06u, 0x3AE1u, + 0x43140960u, 0x58A0u, 0x3849FB8Au, 0x0328u, 0x3A18FD84u, 0x10C8u, + 0xBE975FD3u, 0xB4BBu, 0xC0BF694Au, 0xC5FBu, 0x3D56DA35u, 0x2AB7u, + 0xC28C14ADu, 0xD461u, 0xB6BA702Au, 0x805Du, 0xC20FB679u, 0xD07Eu, + 0x412B1285u, 0x4959u, 0x3823D6E4u, 0x028Fu, 0xBC274EF6u, 0xA13Au, + 0x3ABACE39u, 0x15D6u, 0xB39C87AEu, 0x8001u, 0x3D9CCD85u, 0x2CE6u, + 0xBAC131E0u, 0x960Au, 0x33BC76EAu, 0x0001u, 0x38BBFB64u, 0x05E0u, + 0x3A718DF9u, 0x138Cu, 0x3BCD8BA9u, 0x1E6Cu, 0x4245E77Du, 0x522Fu, + 0xB8E90C7Bu, 0x8748u, 0xBA9F4029u, 0x94FAu, 0xBAA46DE7u, 0x9523u, + 0x3E984F19u, 0x34C2u, 0x380D4F2Eu, 0x0235u, 0x427ADE3Eu, 0x53D7u, + 0xB772280Fu, 0x80F2u, 0x371B1E4Eu, 0x009Bu, diff --git a/src/kernels/qsa_parity.cpp b/src/kernels/qsa_parity.cpp new file mode 100644 index 00000000..5a8186dd --- /dev/null +++ b/src/kernels/qsa_parity.cpp @@ -0,0 +1,1383 @@ +// src/kernels/qsa_parity.cpp - P2.S2's test for the QSA cache, indexer, selection and attention. +// +// `ref/qsa.py` carries eleven PROPERTY checks and `ref/model.py::_qsa` is the layer as the model runs it. The +// readings a prose transcription gets WRONG, each of which produces a well-formed result of the right shape, +// are computed here the wrong way round AND required to differ MATERIALLY before the kernel is judged against +// the right one - a test that only does the second half passes against either reading: +// +// 1. `pooled[b]` is rotated at the block's FIRST cell. The reference's PROPERTY 4 pins it against the +// block's LAST cell; both give a unit-norm vector and the same downstream shape. +// 2. Pool THEN norm (`rms_norm(mean(x))`) and not norm THEN pool (`mean(rms_norm(x))`). `ref/model.py` L358 +// is explicit, and the two differ whenever the four cells have different magnitudes. +// 3. The spare slot's key is `rms_norm(raw[0])` - cell ZERO - and not the last raw key, and the tail cells +// map to THAT slot and not to the last complete block. A three-row raw tail cannot produce it unless +// cell 0's key is kept, which is what makes this a real design constraint rather than a detail. +// 4. The indexer's Relu is PER HEAD, summed afterwards. `relu(sum_h dot_h)` is the rival reading and +// differs whenever any head's dot is negative. +// 5. The q -> kv head map is `q / 12` (integer division), NOT `q % 2`. `ref/qsa.py` PROPERTY 7 tests the +// same thing; with 24 and 2 the two give [0 x12, 1 x12] and [0,1,0,1,...]. +// 6. The attention reads ONLY the selected cells. The source writes -inf into the mask and zeros at the +// top-k indices, so an unselected cell has weight exactly zero even when it would score highest - +// PROPERTY 8's fixture, reproduced here with its own numbers. +// 7. `attn *= sigmoid(gate)` with the gate taken from the SECOND half of each head's 2*head_dim block, and +// the rounding to fp16 happening AFTER the multiply (one rounding, not two). +// 8. The KV cache is PAGED and the page table is not assumed to be the identity: the pool is written and +// read back at page_size 1, 4 and 512 with a REVERSED table, and the kernel's own reading of the layout +// is compared against two rivals (linear, i.e. the table ignored; and cell-major inside the page). +// +// THE METRIC. Both sums here - the indexer's sum of four relu'd dots and the attention's weighted sum of +// value rows - cancel, so a plain |want-got|/|want| reports the condition number instead of the arithmetic. +// The project has made that mistake four times (rounds 169, 189, 194, 196); where a sum is compared below, the +// denominator is the magnitude of the TERMS. +// +// WHAT SETS EACH TOLERANCE, because a tolerance nobody can explain is one nobody can tighten: +// * the fp16 converter, the pool contents, the gathered scratch and the spare indexer key: EXACT. The only +// arithmetic is a conversion, and section 0 checks that conversion against numpy's float16 over 395 +// vectors covering ties in both directions, subnormals and overflow - an oracle sharing no code with it - +// plus all 65,536 fp16 patterns in the reverse direction. +// * the pooled block keys: 1e-6. The pooling and the norm are double on both sides and are bit-exact +// BEFORE the rotation; the residual is `rope_neox_apply` (f32, reused rather than re-implemented) against +// the reference's float64 rotation, the class round 189 measured at ~1e-7. +// * the indexer scores: 1e-6 against Sum|dot_h| + |bias|, set by the fp32 storage of the pooled keys - +// 2^-24 per element over a 128-term dot. +// * the selected ids: EXACT. `docs/capture-format.md` L127 gives `indexer_ids` no tolerance. +// * the attention against a reference reading the SAME fp16 cache: 1e-5, set by f32 accumulation over +// 256-term dots plus `expf`'s 2 ulp. This is the KERNEL's number. +// * the attention against a reference reading the f32 keys: REPORTED and then checked against the phase's +// 1e-3 for FP16 paths. That difference is the FP16 CACHE's cost, not the kernel's, and it is kept +// separate so a kernel bug cannot hide inside it. +#include "strata/kernels/qsa.hpp" + +#include "strata/kernels/f16_bits.hpp" +#include "strata/kernels/rope.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using strata::kernels::f16_from_f32; +using strata::kernels::f32_from_f16; + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +/// A device buffer with the boilerplate folded in. A test that allocates twenty buffers by hand spends its +/// attention on memcpy arguments instead of on the kernel. +template +struct Dev { + T* p = nullptr; + Dev() = default; + explicit Dev(size_t n) { alloc(n); } + ~Dev() { if (p) cudaFree(p); } + Dev(const Dev&) = delete; + Dev& operator=(const Dev&) = delete; + void alloc(size_t n) { + if (p) { cudaFree(p); p = nullptr; } + if (n) check(cudaMalloc(&p, n * sizeof(T)), "cudaMalloc"); + } + void put(const std::vector& v) { + if (!p) alloc(v.size()); + check(cudaMemcpy(p, v.data(), v.size() * sizeof(T), cudaMemcpyHostToDevice), "H2D"); + } + std::vector get(size_t n) const { + std::vector v(n); + check(cudaMemcpy(v.data(), p, n * sizeof(T), cudaMemcpyDeviceToHost), "D2H"); + return v; + } +}; + +std::vector as_d(const std::vector& v) { return std::vector(v.begin(), v.end()); } +std::vector as_f(const std::vector& v) { + std::vector o(v.size()); + for (size_t i = 0; i < v.size(); ++i) o[i] = (float) v[i]; + return o; +} + +/// Relative error against the magnitude of the TERMS. See the header comment. +double rel_terms(double want, double got, double terms) { + return std::fabs(want - got) / (terms > 1e-30 ? terms : 1e-30); +} + +double rel_l1(const std::vector& a, const std::vector& b) { + double d = 0, m = 0; + for (size_t i = 0; i < a.size(); ++i) { + d += std::fabs(a[i] - b[i]); + m += std::fabs(a[i]); + } + return d / (m > 1e-30 ? m : 1e-30); +} + +int g_bad = 0; +std::string pct(double rel) { + char b[32]; + std::snprintf(b, sizeof b, "%.2f%% apart", rel * 100.0); + return b; +} +void verdict(const std::string& name, bool ok, const std::string& detail = "") { + std::printf(" %-48s %-6s %s\n", name.c_str(), ok ? "yes" : "*** NO ***", detail.c_str()); + if (!ok) ++g_bad; +} +void report(const std::string& name, double v, const char* unit) { + std::printf(" %-48s %s %.3e\n", name.c_str(), unit, v); +} +void require(const std::string& name, bool ok, const std::string& detail = "") { + std::printf(" %-48s %-6s %s\n", name.c_str(), ok ? "ok" : "*** BAD ***", detail.c_str()); + if (!ok) ++g_bad; +} + +// ================= the reference: ref/qsa.py + ref/model.py, in double ================= + +/// Which of the rival readings to compute. The defaults are the SOURCE's reading. +struct Alt { + bool rotate_at_block_start = true; ///< false: rotate at the block's LAST cell (WRONG) + bool pool_then_norm = true; ///< false: mean of the per-cell norms (WRONG) + bool dead_from_first = true; ///< false: the spare key from the LAST raw key (WRONG) + bool rope_raw = false; ///< true: rotate the raw keys before pooling (WRONG) + bool relu_per_head = true; ///< false: relu(sum of the head dots) (WRONG) + bool use_relu = true; ///< false: no relu at all (WRONG) + bool use_bias = true; ///< false: drop the per-block bias (WRONG) + bool kv_divide = true; ///< false: q % n_head_kv instead of q / 12 (WRONG) + bool apply_scale = true; ///< false: no 1/sqrt(head_dim) (WRONG) + bool gate_second_half = true; ///< false: the gate from the FIRST half (WRONG) + bool gate_sigmoid = true; ///< false: silu instead of sigmoid (WRONG) +}; + +/// `x*y + z` with the product ROUNDED first, i.e. no FMA contraction. +/// +/// MSVC contracts `ss += v*v` into an FMA even under /fp:precise, so the reference was MORE accurate than +/// the kernel's explicit `__fadd_rn(__fmul_rn(v, v))` and the spare key came out one bit different in 7 of +/// 128 components. The kernel's arithmetic is what is under test, so the reference REPRODUCES it instead of +/// beating it; `volatile` is what stops the compiler re-fusing the two operations. +double mul_add_plain(double x, double y, double z) { + const volatile double p = x * y; + return z + p; +} + +/// `ref/qsa.py::rms_norm` in double, in place. `w` may be empty (w = None). +void ref_rms_norm(std::vector& x, const std::vector& w, float eps) { + double ss = 0; + for (double v : x) ss = mul_add_plain(v, v, ss); + const double inv = 1.0 / std::sqrt(ss / (double) x.size() + (double) eps); + for (size_t i = 0; i < x.size(); ++i) x[i] = x[i] * inv * (w.empty() ? 1.0 : (double) w[i]); +} + +/// `ref/qsa.py::rope_neox` in double: NEOX pairs (i, i + n_rot/2), partial over the first n_rot dims. +/// +/// `terms`, when given, receives the magnitude of each component's TERMS - `|a c| + |b s|` where the +/// rotation subtracts - so a comparison can divide by that instead of by the result, which cancels. +void ref_rope_neox(std::vector& x, double pos, int n_rot, double theta, + std::vector* terms = nullptr) { + const int half = n_rot / 2; + std::vector a(x.begin(), x.begin() + half), b(x.begin() + half, x.begin() + n_rot); + if (terms) { + terms->assign(x.size(), 0.0); + for (size_t i = (size_t) n_rot; i < x.size(); ++i) (*terms)[i] = std::fabs(x[i]); + } + for (int i = 0; i < half; ++i) { + const double inv = std::pow(theta, -2.0 * (double) i / (double) n_rot); + const double ang = pos * inv, c = std::cos(ang), s = std::sin(ang); + x[i] = a[i] * c - b[i] * s; + x[half + i] = a[i] * s + b[i] * c; + if (terms) { + (*terms)[i] = std::fabs(a[i] * c) + std::fabs(b[i] * s); + (*terms)[half + i] = std::fabs(a[i] * s) + std::fabs(b[i] * c); + } + } +} + +const int64_t REF_R = 4; ///< IDX_BLOCK, checked against the shapes at the top of main + +/// `QsaCache`, holding f64 rows - the reference never sees an fp16 key. +struct RefCache { + std::vector> k, v, raw; + std::vector pos; + int64_t size() const { return (int64_t) pos.size(); } + void append(std::vector kk, std::vector vv, std::vector rr, double p) { + k.push_back(std::move(kk)); + v.push_back(std::move(vv)); + raw.push_back(std::move(rr)); + pos.push_back(p); + } +}; + +/// `QsaCache.pooled_raw(r)` - BEFORE the norm, which is the only place the 1/r divisor is observable. +std::vector ref_pooled_raw(const RefCache& c) { + const int64_t n_bid = c.size() / REF_R, dim = (int64_t) c.raw[0].size(); + std::vector out((size_t) n_bid * dim, 0.0); + for (int64_t b = 0; b < n_bid; ++b) + for (int64_t i = 0; i < REF_R; ++i) + for (int64_t d = 0; d < dim; ++d) out[(size_t) b * dim + d] += c.raw[(size_t) (b * REF_R + i)][(size_t) d]; + for (auto& x : out) x /= (double) REF_R; + return out; +} + +/// `pooled_keys` + `dead_pooled_key`, in the readback order `indexer_scores` uses: (n_bid + 1, idx_dim). +std::vector ref_pooled(const RefCache& c, const std::vector& w_kn, float eps, int n_rot, + double theta, const Alt& o, + std::vector* terms_out = nullptr) { + const int64_t n_bid = c.size() / REF_R, dim = (int64_t) c.raw[0].size(); + std::vector pooled((size_t) (n_bid + 1) * dim, 0.0); + std::vector> rows(c.raw); + if (o.rope_raw) + for (size_t j = 0; j < rows.size(); ++j) ref_rope_neox(rows[j], c.pos[j], n_rot, theta); + + for (int64_t b = 0; b < n_bid; ++b) { + std::vector m((size_t) dim, 0.0); + if (o.pool_then_norm) { + for (int64_t i = 0; i < REF_R; ++i) + for (int64_t d = 0; d < dim; ++d) + m[(size_t) d] += rows[(size_t) (b * REF_R + i)][(size_t) d] / (double) REF_R; + ref_rms_norm(m, w_kn, eps); + } else { + for (int64_t i = 0; i < REF_R; ++i) { + std::vector one = rows[(size_t) (b * REF_R + i)]; + ref_rms_norm(one, w_kn, eps); + for (int64_t d = 0; d < dim; ++d) m[(size_t) d] += one[(size_t) d] / (double) REF_R; + } + } + std::vector terms; + ref_rope_neox(m, o.rotate_at_block_start ? c.pos[(size_t) (b * REF_R)] + : c.pos[(size_t) (b * REF_R + REF_R - 1)], + n_rot, theta, terms_out ? &terms : nullptr); + for (int64_t d = 0; d < dim; ++d) pooled[(size_t) b * dim + d] = m[(size_t) d]; + if (terms_out) { + if (b == 0) terms_out->assign((size_t) (n_bid + 1) * dim, 0.0); + for (int64_t d = 0; d < dim; ++d) (*terms_out)[(size_t) b * dim + d] = terms[(size_t) d]; + } + } + std::vector dead = rows[o.dead_from_first ? 0 : rows.size() - 1]; + ref_rms_norm(dead, w_kn, eps); + ref_rope_neox(dead, 0.0, n_rot, theta); // the identity, but computed the reference's way + for (int64_t d = 0; d < dim; ++d) pooled[(size_t) n_bid * dim + d] = dead[(size_t) d]; + return pooled; +} + +/// `ref/qsa.py::indexer_scores` - per CELL, after the `cell_block` mapping. +std::vector ref_indexer_scores(const std::vector& pooled, int64_t n_bid, + const std::vector& q_idx, int64_t idx_n_head, int64_t idx_dim, + const std::vector& bias, int64_t n_kv, const Alt& o) { + std::vector per_block((size_t) n_bid + 1, 0.0); + for (int64_t b = 0; b <= n_bid; ++b) { + double s = 0; + for (int64_t h = 0; h < idx_n_head; ++h) { + double dot = 0; + for (int64_t d = 0; d < idx_dim; ++d) + dot += pooled[(size_t) b * idx_dim + d] * q_idx[(size_t) h * idx_dim + d]; + if (!o.use_relu) s += dot; + else if (o.relu_per_head) s += dot > 0 ? dot : 0.0; + else s += dot; + } + if (o.use_relu && !o.relu_per_head) s = s > 0 ? s : 0.0; + if (o.use_bias && !bias.empty()) s += (double) bias[(size_t) b]; + if (b == n_bid && n_kv % REF_R != 0) s = (float) s + 1e9f; + per_block[(size_t) b] = s; + } + std::vector cell((size_t) n_kv, 0.0); + for (int64_t j = 0; j < n_kv; ++j) cell[(size_t) j] = per_block[(size_t) (j / REF_R < n_bid ? j / REF_R : n_bid)]; + return cell; +} + +/// `ref/qsa.py::select_cells`, spelled with a stable sort rather than the kernel's threshold search, so the +/// two are independent implementations of one rule: score DESCENDING, ties by ASCENDING index. +std::vector ref_select(const std::vector& scores, int64_t n_kv, int64_t top_k, bool dense) { + std::vector idx((size_t) n_kv); + for (int64_t j = 0; j < n_kv; ++j) idx[(size_t) j] = (int32_t) j; + if (dense) return idx; + const int64_t width = std::min(n_kv, top_k + REF_R - 1); + std::stable_sort(idx.begin(), idx.end(), [&](int32_t a, int32_t b) { + const double sa = scores[(size_t) a], sb = scores[(size_t) b]; + if (sa != sb) return sa > sb; + return a < b; + }); + idx.resize((size_t) width); + std::sort(idx.begin(), idx.end()); + return idx; +} + +/// `ref/qsa.py::qsa_attend` over an EXPLICIT selected set, reading K/V from whichever table the caller passes +/// so that one piece of code runs on the f64 keys and on the fp16 cache's exact f32 values. +std::vector ref_attend(const std::vector& q, int64_t n_head, int64_t n_head_kv, int64_t head_dim, + const std::vector& ids, const std::vector>& k, + const std::vector>& v, const Alt& o, + std::vector* weights_out) { + const double scale = o.apply_scale ? 1.0 / std::sqrt((double) head_dim) : 1.0; + std::vector out((size_t) n_head * head_dim, 0.0); + if (weights_out) weights_out->assign((size_t) n_head * ids.size(), 0.0); + for (int64_t h = 0; h < n_head; ++h) { + const int64_t kv = o.kv_divide ? h / (n_head / n_head_kv) : h % n_head_kv; + std::vector sc(ids.size()); + for (size_t j = 0; j < ids.size(); ++j) { + double dot = 0; + for (int64_t d = 0; d < head_dim; ++d) + dot += k[(size_t) ids[j]][(size_t) (kv * head_dim + d)] * q[(size_t) (h * head_dim + d)]; + sc[j] = dot * scale; + } + const double mx = *std::max_element(sc.begin(), sc.end()); + double sum = 0; + for (auto& s : sc) { s = std::exp(s - mx); sum += s; } + for (auto& s : sc) s /= sum; + if (weights_out) + for (size_t j = 0; j < ids.size(); ++j) (*weights_out)[(size_t) h * ids.size() + j] = sc[j]; + for (int64_t d = 0; d < head_dim; ++d) { + double acc = 0; + for (size_t j = 0; j < ids.size(); ++j) acc += sc[j] * v[(size_t) ids[j]][(size_t) (kv * head_dim + d)]; + out[(size_t) (h * head_dim + d)] = acc; + } + } + return out; +} + +/// `ref/model.py::_qsa`'s last two lines. +std::vector ref_gate(const std::vector& attn, const std::vector& q_full, int64_t n_head, + int64_t head_dim, const Alt& o) { + std::vector out((size_t) n_head * head_dim, 0.0); + for (int64_t h = 0; h < n_head; ++h) + for (int64_t d = 0; d < head_dim; ++d) { + const int64_t gi = o.gate_second_half ? h * 2 * head_dim + head_dim + d : h * 2 * head_dim + d; + const double g = q_full[(size_t) gi]; + const double s = o.gate_sigmoid ? 1.0 / (1.0 + std::exp(-g)) : g / (1.0 + std::exp(-g)); + out[(size_t) (h * head_dim + d)] = attn[(size_t) (h * head_dim + d)] * s; + } + return out; +} + +/// {f32 bits, the fp16 bits numpy produces for it} interleaved. Generated by +/// `scripts/_gen_f16_oracle.py` from `np.float16(np.float32(x))`: an oracle that shares no code with +/// f16_bits.hpp, which is the whole point of the table. +const uint32_t kOracle[] = { +#include "qsa_oracle_vectors.inc" +}; +constexpr int kOracleN = (int) (sizeof(kOracle) / sizeof(kOracle[0])) / 2; + +} // namespace + +int main(int argc, char** argv) { + std::setvbuf(stdout, nullptr, _IONBF, 0); // a crash must not swallow what was printed already + bool selftest = false, dense = false, bench = false; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--selftest") selftest = true; + else if (a == "--qsa-dense") dense = true; + else if (a == "--bench") bench = true; + else { + std::fprintf(stderr, "usage: qsa_parity [--selftest] [--qsa-dense] [--bench]\n" + " --qsa-dense attend to EVERY cell instead of the indexer's selection\n" + " (P1.S4's isolation switch; the sparse selection is still run\n" + " and checked, against the reference's dense/sparse property)\n" + " --bench time the kernels at 32K of context\n"); + return 2; + } + } + + const strata::kernels::QsaShapes S = strata::kernels::qsa_real_shapes(); + const int64_t NH = S.n_head, NKV = S.n_head_kv, HD = S.head_dim, IDXN = S.idx_n_head, IDXD = S.idx_dim, + R = S.idx_block, TOPK = S.idx_top_k; + const int64_t MAXC = strata::kernels::kTopkMaxCells; + const float EPS = strata::kernels::qsa_rms_eps(); + const double THETA = strata::kernels::qsa_freq_base(); + std::mt19937 rng(20260916u); + std::normal_distribution gauss(0.0, 1.0); + auto rnd = [&](double s) { return (float) (gauss(rng) * s); }; + + std::printf("qsa_parity: n_head %lld n_head_kv %lld head_dim %lld indexer %lldx%lld r=%lld " + "top_k %lld page (from the shapes) %lld [%s]\n\n", + (long long) NH, (long long) NKV, (long long) HD, (long long) IDXN, (long long) IDXD, + (long long) R, (long long) TOPK, (long long) S.page_size, dense ? "DENSE" : "sparse"); + require("the fixture's r is the artifact's IDX_BLOCK", R == REF_R, "r = " + std::to_string(R)); + + // ================= 0. the fp16 converter, against numpy ================= + { + int mism = 0, subnormal = 0; + for (int i = 0; i < kOracleN; ++i) { + float f; + const uint32_t fb = kOracle[2 * i]; + std::memcpy(&f, &fb, 4); + const uint16_t want = (uint16_t) kOracle[2 * i + 1]; + const uint16_t got = f16_from_f32(f); + if (got != want) { + if (mism < 5) std::printf(" f32 0x%08X -> got 0x%04X, numpy says 0x%04X\n", fb, got, want); + ++mism; + } + if ((want & 0x7C00u) == 0u && (want & 0x3FFu) != 0u) ++subnormal; + } + require("f32->f16 matches numpy's float16 on every vector", mism == 0, + std::to_string(kOracleN) + " vectors, " + std::to_string(subnormal) + " subnormal"); + int bad_rev = 0; + for (int h = 0; h < 65536; ++h) { + const int ex = (h >> 10) & 0x1F, man = h & 0x3FF; + if (ex == 31) continue; // inf/NaN come from the vector table above + const double mag = ex == 0 ? (double) man * std::ldexp(1.0, -24) + : (double) (1024 + man) * std::ldexp(1.0, ex - 25); + const double want = (h & 0x8000) ? -mag : mag; + if ((double) f32_from_f16((uint16_t) h) != want) { + if (bad_rev < 5) std::printf(" f16 0x%04X -> %.9g, want %.9g\n", h, (double) f32_from_f16((uint16_t) h), want); + ++bad_rev; + } + } + require("f16->f32 is exact on all 65,536 bit patterns", bad_rev == 0, + std::to_string(bad_rev) + " wrong"); + } + + // ================= 1. kv_append: the paged pool, three page granules ================= + const int64_t TCELLS = 1100; // 275 indexer blocks, and 3 pages at page_size 512 + std::vector kcur_all((size_t) TCELLS * NKV * HD), vcur_all((size_t) TCELLS * NKV * HD); + for (auto& x : kcur_all) x = rnd(1.0); + for (auto& x : vcur_all) x = rnd(1.0); + // EVERY pointer this kernel set takes is a DEVICE pointer - the append sources, the indexer weights + // and the rope tables alike. The first draft passed `&kcur_all[...]`, `w_kn.data()` and + // `cos_tab.data()` straight through, and every kernel that dereferenced one took an illegal access. + Dev dkcur_all, dvcur_all; + dkcur_all.put(kcur_all); + dvcur_all.put(vcur_all); + + struct PageCase { + int64_t page_size = 0, n_pages = 0; + std::vector table; + }; + std::vector pages; + for (int64_t ps : {(int64_t) 1, (int64_t) 4, (int64_t) 512}) { + PageCase c; + c.page_size = ps; + c.n_pages = (TCELLS + ps - 1) / ps; + c.table.resize((size_t) c.n_pages); + for (int64_t i = 0; i < c.n_pages; ++i) c.table[(size_t) i] = (int32_t) (c.n_pages - 1 - i); // REVERSED + pages.push_back(std::move(c)); + } + const std::vector ids = {0, 1, 2, 3, 5, 8, 13, 21, 34, 39, 1099, 700}; + + for (PageCase& c : pages) { + // THE PAGE GRANULE IS A PROPERTY OF THE SHAPES, and the kernel takes it from there rather than from an + // argument. The first version of this fixture left it at 512 while the table described 1-cell pages, + // so the kernel indexed a 1100-page table as if it had 3 pages and ran off the pool - which is what an + // illegal memory access reported. A test that feeds one geometry and expects another is measuring + // itself; the fix is to build the shapes per case. + strata::kernels::QsaShapes Sc = S; + Sc.page_size = c.page_size; + const size_t pool_n = (size_t) c.n_pages * NKV * c.page_size * HD; + Dev dk(pool_n), dv(pool_n); + Dev dtab; + dtab.put(c.table); + for (int64_t t = 0; t < TCELLS; ++t) + strata::kernels::kv_append(dk.p, dv.p, dtab.p, t, dkcur_all.p + (size_t) t * NKV * HD, + dvcur_all.p + (size_t) t * NKV * HD, Sc, nullptr); + const std::vector hk = dk.get(pool_n), hv = dv.get(pool_n); + + int mism = 0, mism_linear = 0, mism_head_major = 0; + const int64_t rows_avail = (int64_t) (pool_n / (size_t) HD); + // A rival layout's address is HYPOTHETICAL: where it would put the value may be outside the pool, + // and reading there is a host access violation rather than a mismatch. Off the end counts as + // "the value is not where the rival would put it", which is the claim being tested. + auto elsewhere = [&](int64_t r) { return r < 0 || r >= rows_avail; }; + for (int64_t cell = 0; cell < TCELLS; ++cell) + for (int64_t h = 0; h < NKV; ++h) + for (int64_t d = 0; d < HD; ++d) { + const size_t src = (size_t) (cell * NKV + h) * HD + d; + const uint16_t wk = f16_from_f32(kcur_all[src]), wv = f16_from_f32(vcur_all[src]); + // the DECLARED layout: [page][kv_head][page_size][head_dim] + const int64_t row = ((int64_t) c.table[(size_t) (cell / c.page_size)] * NKV + h) * c.page_size + + (cell % c.page_size); + if (hk[(size_t) row * HD + d] != wk || hv[(size_t) row * HD + d] != wv) ++mism; + // rival (a): the page table ignored, i.e. written at the logical address + const int64_t rl = (cell * NKV + h) * c.page_size + (cell % c.page_size); + if (elsewhere(rl) || hk[(size_t) rl * HD + d] != wk) ++mism_linear; + // rival (b): cell-major inside the page instead of head-major + const int64_t rh = ((int64_t) c.table[(size_t) (cell / c.page_size)] * c.page_size + + (cell % c.page_size)) * NKV + h; + if (elsewhere(rh) || hk[(size_t) rh * HD + d] != wk) ++mism_head_major; + } + const double n_tot = (double) (TCELLS * NKV * HD); + require("kv_append at page_size " + std::to_string(c.page_size) + ": the pool is BIT-EXACT", + mism == 0, std::to_string(mism) + " of " + std::to_string((long long) n_tot) + " bits wrong"); + verdict(" the page table is observable at page_size " + std::to_string(c.page_size), + (double) mism_linear / n_tot > 0.5, + pct((double) mism_linear / n_tot) + " (linear vs declared)"); + // The head-major rival is IDENTICAL to the declared layout when page_size == 1 (both reduce to + // `cell * n_head_kv + h`), so the separation is only asserted where the layouts actually differ. + if (c.page_size >= 4) { + verdict(" head-major-in-page is observable at page_size " + std::to_string(c.page_size), + (double) mism_head_major / n_tot > 0.5, + pct((double) mism_head_major / n_tot) + " (cell-major vs head-major)"); + } + + Dev dids; + dids.put(ids); + const size_t scr_n = ids.size() * NKV * HD; + Dev dks(scr_n), dvs(scr_n); + strata::kernels::kv_gather(dk.p, dv.p, dtab.p, dids.p, (int64_t) ids.size(), Sc, dks.p, dvs.p, nullptr); + const std::vector gk = dks.get(scr_n), gv = dvs.get(scr_n); + int gm = 0; + for (size_t j = 0; j < ids.size(); ++j) + for (int64_t h = 0; h < NKV; ++h) + for (int64_t d = 0; d < HD; ++d) { + const size_t src = (size_t) (ids[j] * NKV + h) * HD + d; + if (gk[(size_t) (j * NKV + h) * HD + d] != f16_from_f32(kcur_all[src])) ++gm; + if (gv[(size_t) (j * NKV + h) * HD + d] != f16_from_f32(vcur_all[src])) ++gm; + } + require(" kv_gather is BIT-EXACT at page_size " + std::to_string(c.page_size), gm == 0, + std::to_string(gm) + " wrong bits"); + } + + // ================= 2. indexer_key_append ================= + const int64_t NT = 10; // 2 complete blocks + a 2-cell tail + // The cells sit at POSITIONS 100..109, not at 0..9: `QsaCache.pos` is what the reference rotates by, + // and a kernel that assumes position == cell index is wrong for any sequence that does not start at 0 + // (prefix reuse, phase 5). With base 100 the two readings are 514% apart, which is asserted below. + const int32_t POS_BASE = 100; + RefCache rc; + std::vector raw_flat; + for (int64_t t = 0; t < NT; ++t) { + std::vector kk((size_t) (NKV * HD)), vv((size_t) (NKV * HD)), rr((size_t) IDXD); + for (int64_t i = 0; i < NKV * HD; ++i) { kk[(size_t) i] = gauss(rng); vv[(size_t) i] = gauss(rng); } + // cell MAGNITUDES 1, 4, 16, 64 - for the same reason round 196 gave its residual streams different + // scales: four i.i.d. rows of normal noise have nearly the same norm by accident, so "pool then norm" + // and "norm then pool" would agree to within the noise and the fixture could not see its own trap. + for (int64_t d = 0; d < IDXD; ++d) rr[(size_t) d] = gauss(rng) * std::pow(4.0, (double) (t % 4)); + for (int64_t d = 0; d < IDXD; ++d) raw_flat.push_back((float) rr[(size_t) d]); + // The reference gets the f32 PROMOTION of what the device gets, not the f64 original. Feeding it + // the f64 values left a 6e-8 relative difference, which is invisible in a score but crosses an f32 + // rounding boundary in one value in four - and the spare key is asserted BIT-EXACT. + for (int64_t d = 0; d < IDXD; ++d) rr[(size_t) d] = (double) raw_flat[(size_t) t * IDXD + d]; + rc.append(kk, vv, rr, (double) (100 + t)); + } + std::vector w_kn((size_t) IDXD); + for (auto& x : w_kn) x = 1.0f + 0.1f * rnd(1.0); + std::vector cos_tab((size_t) (2000 * S.n_rot / 2)), sin_tab(cos_tab.size()); + strata::kernels::build_rope_table((int) S.n_rot, THETA, 2000, cos_tab.data(), sin_tab.data()); + Dev dw_kn, dcos, dsin; + dw_kn.put(w_kn); + dcos.put(cos_tab); + dsin.put(sin_tab); + + const int64_t n_bid = NT / R; + const size_t pooled_n = (size_t) (n_bid + 1) * IDXD; + Dev dpooled(pooled_n), ddead((size_t) IDXD), dtail((size_t) (R - 1) * IDXD); + Dev dblockpos(1); // the rotation position, which only the DEVICE may read + // The POSITION is a device value now, because a host scalar would be baked into a captured graph. One + // `cudaMemcpy` per cell here; the layer keeps this buffer and updates it per token. + Dev dpos(1); + { + strata::kernels::QsaIndexerBuffers bufs{dtail.p, ddead.p, dpooled.p, dblockpos.p}; + Dev draw((size_t) IDXD); + for (int64_t t = 0; t < NT; ++t) { // one cell at a time: the tail is a ring and the spare row MOVES + check(cudaMemcpy(draw.p, &raw_flat[(size_t) t * IDXD], (size_t) IDXD * 4, cudaMemcpyHostToDevice), + "raw"); + const int32_t tpos = (int32_t) t; + check(cudaMemcpy(dpos.p, &tpos, 4, cudaMemcpyHostToDevice), "pos"); + strata::kernels::indexer_key_append(draw.p, dpos.p, POS_BASE, dw_kn.p, EPS, bufs, S, dcos.p, dsin.p, + nullptr); + } + const std::vector got = dpooled.get(pooled_n), got_dead = ddead.get((size_t) IDXD); + std::vector terms; + const std::vector want = ref_pooled(rc, w_kn, EPS, (int) S.n_rot, THETA, Alt{}, &terms); + const std::vector want_raw = ref_pooled_raw(rc); + + // (a) the spare slot: BIT-EXACT, because rope at position 0 is the identity in both (cos = 1, sin = 0 + // exactly), so the residual the pooled rows carry from the f32 rope cannot appear here. + int dead_bad = 0; + for (int64_t d = 0; d < IDXD; ++d) + if (got_dead[(size_t) d] != (float) want[(size_t) n_bid * IDXD + d]) ++dead_bad; + require("the spare slot's key (rms_norm of cell 0) is BIT-EXACT", dead_bad == 0, + std::to_string(dead_bad) + " of " + std::to_string(IDXD) + " wrong"); + require("the spare slot is at row n_bid and nowhere else", n_bid == 2, + "10 cells / r=4 -> 2 complete blocks"); + + // (b) the pooled blocks against the reference + // THE DENOMINATOR IS THE ROPE'S TERMS, not the result: `a*cos - b*sin` cancels, and the kernel's + // rotation runs in f32 over an f32 table (6e-8 per term) while the reference's runs in f64. Dividing + // by the result reports the cancellation, which is what made this read 1.067e-06 against a 1e-6 bar. + double worst = 0; + for (int64_t b = 0; b < n_bid; ++b) + for (int64_t d = 0; d < IDXD; ++d) { + const double wv = want[(size_t) b * IDXD + d]; + const double tm = terms[(size_t) b * IDXD + d]; + worst = std::max(worst, rel_terms(wv, (double) got[(size_t) b * IDXD + d], tm)); + } + report("pooled block keys vs the reference", worst, "rel/|rope terms|"); + if (!(worst <= 1e-6)) { std::printf(" *** over 1e-6 ***\n"); ++g_bad; } + + // (c) THE INCOMPLETE TAIL IS NOT POOLED: the two tail cells take the SPARE slot's score, which is cell + // 0's key - not their own mean. A rival reading that pools them would still fill every row. + { + std::vector tm((size_t) IDXD, 0.0); + for (int64_t i = n_bid * R; i < NT; ++i) + for (int64_t d = 0; d < IDXD; ++d) tm[(size_t) d] += rc.raw[(size_t) i][(size_t) d] / 2.0; + ref_rms_norm(tm, w_kn, EPS); + double num = 0, den = 0; + for (int64_t d = 0; d < IDXD; ++d) { + num += std::fabs(tm[(size_t) d] - (double) got_dead[(size_t) d]); + den += std::fabs(tm[(size_t) d]); + } + verdict("the incomplete tail is NOT pooled into the spare slot", num / den > 0.5, pct(num / den)); + } + + auto sep = [&](const std::string& name, const Alt& a) { + const std::vector alt = ref_pooled(rc, w_kn, EPS, (int) S.n_rot, THETA, a); + double num = 0, den = 0; + // Rows 0..n_bid INCLUSIVE: the spare slot is row n_bid and three of the four rivals change + // nothing else, so leaving it out made "the spare key from the LAST raw key" separate 0.00%. + for (int64_t b = 0; b <= n_bid; ++b) + for (int64_t d = 0; d < IDXD; ++d) { + num += std::fabs(alt[(size_t) b * IDXD + d] - want[(size_t) b * IDXD + d]); + den += std::fabs(want[(size_t) b * IDXD + d]); + } + verdict(name, num / den > 0.05, pct(num / den)); + }; + Alt a1; a1.rotate_at_block_start = false; + sep("rotating at the block's LAST cell is observable", a1); + Alt a2; a2.pool_then_norm = false; + sep("norm-then-pool vs pool-then-norm is observable", a2); + Alt a3; a3.dead_from_first = false; + sep("the spare key from the LAST raw key is observable", a3); + Alt a4; a4.rope_raw = true; + sep("rotating the RAW indexer keys is observable", a4); + { // THE POSITION BASE. The same reference with the cells at 0..9 instead of 100..109 - i.e. + // what a kernel that equates a cell's position with its index would compute. + RefCache flat = rc; + for (int64_t j = 0; j < NT; ++j) flat.pos[(size_t) j] = (double) j; + const std::vector alt = ref_pooled(flat, w_kn, EPS, (int) S.n_rot, THETA, Alt{}); + double num = 0, den = 0; + for (int64_t b = 0; b <= n_bid; ++b) + for (int64_t d = 0; d < IDXD; ++d) { + num += std::fabs(alt[(size_t) b * IDXD + d] - want[(size_t) b * IDXD + d]); + den += std::fabs(want[(size_t) b * IDXD + d]); + } + verdict("the rotation's POSITION BASE (pos_base) is observable", num / den > 0.05, + pct(num / den)); + } + + // (d) THE TRAP THIS FIXTURE CANNOT SEE, measured and reported rather than hidden. `rms_norm` divides + // by sqrt(mean(x^2) + eps), so the 1/r in the mean cancels EXCEPT through eps: mean and sum differ + // by ~eps/(2*mean(x^2)) relative. `ref/qsa.py` L463-465 makes the same point about its own test. + { + std::vector psum = want_raw; + for (auto& x : psum) x *= (double) R; + std::vector nm = want_raw, ns = psum; + for (int64_t b = 0; b < n_bid; ++b) { + std::vector m(nm.begin() + (size_t) b * IDXD, nm.begin() + (size_t) (b + 1) * IDXD); + std::vector s(ns.begin() + (size_t) b * IDXD, ns.begin() + (size_t) (b + 1) * IDXD); + ref_rms_norm(m, w_kn, EPS); + ref_rms_norm(s, w_kn, EPS); + for (int64_t d = 0; d < IDXD; ++d) { + nm[(size_t) b * IDXD + d] = m[(size_t) d]; + ns[(size_t) b * IDXD + d] = s[(size_t) d]; + } + } + double num = 0, den = 0; + for (size_t i = 0; i < want_raw.size(); ++i) { + num += std::fabs(psum[i] - want_raw[i]); + den += std::fabs(want_raw[i]); + } + verdict("the 1/r divisor IS observable in the RAW pooled value", num / den > 0.5, + pct(num / den) + " (sum vs mean, before any norm)"); + std::printf(" %-48s %.3e <-- below EVERY score tolerance; the divisor cannot be\n", + "the same divisor AFTER the norm", rel_l1(ns, nm)); + std::printf(" %-48s %s\n", "", "checked downstream at all, which is why the raw value is the"); + std::printf(" %-48s %s\n", "", "only place the mean/sum question is answerable."); + } + } + + // ================= 3. qsa_index ================= + std::vector q_idx((size_t) (IDXN * IDXD)); + for (auto& x : q_idx) x = rnd(1.0); + // A bias of ~0.4 against scores of ~20 separates "applies the bias" from "drops it" by 3.4%, which + // is not a fixture that can see its own trap. This is a guard for the kernel's optional additional + // bias, separate from the mandatory incomplete-tail bias; it is sized to + // be comparable with the scores so that dropping it is material. + std::vector bias((size_t) (n_bid + 1)); + for (auto& x : bias) x = rnd(8.0f); + { + const std::vector got_pooled = dpooled.get(pooled_n); + const std::vector p64 = as_d(got_pooled); + const std::vector q_idx64 = as_d(q_idx); + const int64_t n_kv = NT; + Dev dp, dqi, dbias, dsc; + dp.put(got_pooled); + dqi.put(q_idx); + dbias.put(bias); + dsc.alloc((size_t) n_kv); + strata::kernels::qsa_index(dp.p, n_bid, dqi.p, dbias.p, S, n_kv, dsc.p, nullptr); + const std::vector got = dsc.get((size_t) n_kv); + const std::vector want = + ref_indexer_scores(p64, n_bid, q_idx64, IDXN, IDXD, bias, n_kv, Alt{}); + + // The score is a SUM of four relu'd dots plus the bias, so the denominator is the magnitude of the + // TERMS - |dot| bounds a relu'd dot from above and is what is used here. + double worst = 0; + for (int64_t j = 0; j < n_kv; ++j) { + const int64_t cb = std::min(j / R, n_bid); + double terms = std::fabs((double) bias[(size_t) cb]); + if (cb == n_bid && n_kv % R != 0) terms += 1e9; + for (int64_t h = 0; h < IDXN; ++h) { + double dot = 0; + for (int64_t d = 0; d < IDXD; ++d) dot += p64[(size_t) cb * IDXD + d] * q_idx64[(size_t) h * IDXD + d]; + terms += std::fabs(dot); + } + worst = std::max(worst, rel_terms(want[(size_t) j], (double) got[(size_t) j], terms)); + } + report("indexer cell scores vs the reference", worst, "rel/Sum|terms|"); + if (!(worst <= 1e-6)) { std::printf(" *** over 1e-6 ***\n"); ++g_bad; } + + require("cells of one block share its score (0..3)", + got[0] == got[1] && got[1] == got[2] && got[2] == got[3] && got[0] != got[4]); + require("cells 8,9 (the tail) take the SPARE slot's score, not block 1's", + got[8] == got[9] && got[9] != got[7], "got[8] == got[9] != got[7]"); + + auto sep_scores = [&](const std::string& name, const Alt& a) { + const std::vector alt = ref_indexer_scores(p64, n_bid, q_idx64, IDXN, IDXD, bias, n_kv, a); + double num = 0, den = 0; + // The mandatory tail bias must not swamp the score-arithmetic fixture. + for (int64_t j = 0; j < n_bid * R; ++j) { + num += std::fabs(alt[(size_t) j] - want[(size_t) j]); + den += std::fabs(want[(size_t) j]); + } + verdict(name, num / den > 0.05, pct(num / den)); + }; + Alt a5; a5.relu_per_head = false; + sep_scores("relu PER HEAD vs relu(sum of heads) is observable", a5); + Alt a6; a6.use_relu = false; + sep_scores("dropping the relu is observable", a6); + Alt a7; a7.use_bias = false; + sep_scores("dropping the per-block bias is observable", a7); + } + + // Causal decode's incomplete tail must survive even when its dot-product score + // ties every completed block. The oracle supplies +1e9 in set_input_qsa; + // without it, ascending-index top-k drops every tail after the sparse switch. + // Replay one captured graph across block/page boundaries to catch stale bias. + { + const int64_t max_n = 20480; + const int64_t max_blocks = max_n / R + 1; + const int64_t cap = strata::kernels::qsa_selection_width(max_n, S); + Dev pooled, query, scores((size_t) max_n); + Dev ids((size_t) cap), step(strata::kernels::kStepCount); + pooled.put(std::vector((size_t) max_blocks * IDXD, 0.0f)); + query.put(std::vector((size_t) IDXN * IDXD, 0.0f)); + + cudaStream_t stream; + cudaGraph_t graph; + cudaGraphExec_t exec; + check(cudaStreamCreate(&stream), "tail stream"); + check(cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal), "tail capture begin"); + strata::kernels::qsa_index_step(pooled.p, query.p, nullptr, S, step.p, max_blocks, scores.p, stream); + strata::kernels::topk_512_step(scores.p, S, cap, step.p, ids.p, stream); + check(cudaStreamEndCapture(stream, &graph), "tail capture end"); + check(cudaGraphInstantiate(&exec, graph, 0), "tail instantiate"); + + const std::vector counts = { + 1, 2, 3, 4, 511, 512, 513, 2046, 2047, 2048, 2049, 2050, 2051, + 2052, 2053, 2054, 2055, 2056, 2057, 2058, 2059, 2060, + 4095, 4096, 4097, 8191, 8192, 8193, 20477, 20478, 20479, 20480}; + for (const int64_t n : counts) { + const int64_t width = strata::kernels::qsa_selection_width(n, S); + const int64_t tail = n % R; + std::vector want; + for (int64_t j = 0; j < width - tail; ++j) want.push_back((int32_t) j); + for (int64_t j = n - tail; j < n; ++j) want.push_back((int32_t) j); + + strata::kernels::qsa_index(pooled.p, n / R, query.p, nullptr, S, n, scores.p, nullptr); + strata::kernels::topk_512(scores.p, n, S, cap, ids.p, nullptr); + require("tail static n=" + std::to_string(n), ids.get((size_t) width) == want); + + std::vector values(strata::kernels::kStepCount); + strata::kernels::qsa_step_fill(values.data(), n - 1, S); + step.put(values); + check(cudaGraphLaunch(exec, stream), "tail replay"); + check(cudaStreamSynchronize(stream), "tail replay sync"); + require("tail captured n=" + std::to_string(n), ids.get((size_t) width) == want); + const auto actual_scores = scores.get((size_t) n); + bool exact_scores = true; + for (int64_t j = 0; j < n; ++j) { + const float expected = j >= n - tail ? 1e9f : 0.0f; + exact_scores = exact_scores && actual_scores[(size_t) j] == expected; + } + require(" only incomplete cells receive the bias", exact_scores); + } + check(cudaGraphExecDestroy(exec), "tail exec destroy"); + check(cudaGraphDestroy(graph), "tail graph destroy"); + check(cudaStreamDestroy(stream), "tail stream destroy"); + } + + // ================= 4. topk_512, against an independent sort ================= + { + const int64_t CAP = strata::kernels::qsa_selection_width(MAXC, S); + Dev dsc; + Dev dids; + dsc.alloc((size_t) MAXC); + dids.alloc((size_t) CAP); + std::vector sc((size_t) MAXC); + + auto one = [&](const std::string& name, int64_t n_kv) { + dsc.put(std::vector(sc.begin(), sc.begin() + n_kv)); + strata::kernels::topk_512(dsc.p, n_kv, S, CAP, dids.p, nullptr); + const int64_t width = strata::kernels::qsa_selection_width(n_kv, S); + const std::vector g = dids.get((size_t) width); + const std::vector want = ref_select(as_d(std::vector(sc.begin(), sc.begin() + n_kv)), + n_kv, TOPK, false); + const bool ok = g == want; + require(name, ok, std::to_string(width) + " ids, width = min(n_kv, top_k + r - 1)"); + if (!ok) { + for (size_t i = 0; i < std::min(g.size(), want.size()); ++i) + if (g[i] != want[i]) { + std::printf(" first difference at %zu: got %d, want %d\n", i, g[i], want[i]); + break; + } + } + bool asc = true; + for (size_t i = 1; i < g.size(); ++i) if (g[i] <= g[i - 1]) asc = false; + require(" ...ascending and unique", asc); + return g; + }; + + for (auto& x : sc) x = rnd(1.0); + one("topk_512 n_kv = 1", 1); + one("topk_512 n_kv = 7 (random)", 7); + one("topk_512 n_kv = 2048 (the plan's threshold)", 2048); + one("topk_512 n_kv = 2051 (the reference's own bound)", 2051); + one("topk_512 n_kv = 2052 (two past the plan's number)", 2052); + one("topk_512 n_kv = 5000 (random)", 5000); + one("topk_512 n_kv = 32768 (= kTopkMaxCells)", MAXC); + + // ALL-EQUAL scores: every cell ties, so the answer must be 0..width-1 and any other tie rule shows up + // on the first element. ggml does not specify the rule; the reference and the kernel both say + // ascending index, and this is the case that distinguishes them. + std::fill(sc.begin(), sc.begin() + 2052, 0.25f); + one("topk_512 with 2052 EQUAL scores (every cell ties)", 2052); + + // a tie BLOCK straddling the cut: 100 cells share the cut score and only some of them fit + for (int64_t j = 0; j < 5000; ++j) sc[(size_t) j] = (float) (-(double) j); + for (int64_t j = 2051 - 50; j < 2051 + 50; ++j) sc[(size_t) j] = -1000.0f; + one("topk_512 with a 100-cell tie straddling the cut", 5000); + + // THE IDENTITY BELOW THE BOUND. `select_cells` returns `arange` whenever width == n_kv, which is what + // "selection skipped when context <= 2,048" means - and it holds to 2,051, not 2,048. + for (int64_t n : {(int64_t) 2048, (int64_t) 2051}) { + for (int64_t j = 0; j < n; ++j) sc[(size_t) j] = rnd(1.0); + const std::vector g = one(std::string("topk_512 n_kv = ") + std::to_string(n) + + " selects every cell (identity)", + n); + bool ident = (int64_t) g.size() == n; + for (int64_t j = 0; j < n && ident; ++j) if (g[(size_t) j] != j) ident = false; + require(" ...and it really is 0,1,2,...", ident); + } + { // one past the bound the identity must FAIL, or the two checks above prove nothing + for (int64_t j = 0; j < 2052; ++j) sc[(size_t) j] = rnd(1.0); + const std::vector g = one("topk_512 n_kv = 2052 is NOT the identity", 2052); + std::vector kept(2052, 0); + for (int32_t id : g) kept[(size_t) id] = 1; + int64_t missing = 0; + for (int64_t j = 0; j < 2052; ++j) if (!kept[(size_t) j]) ++missing; + int64_t argmin = 0; + for (int64_t j = 1; j < 2052; ++j) if (sc[(size_t) j] < sc[(size_t) argmin]) argmin = j; + require(" ...exactly one cell is dropped, and it is the smallest score", + missing == 1 && !kept[(size_t) argmin], std::to_string(missing) + " dropped"); + } + } + + // ================= 5. qsa_attend on the loud-cell fixture ================= + // `ref/qsa.py` PROPERTY 8's own numbers: a key with 100 in its first dim against zeros, a value of 10 + // against 1, and q[:,0] = 1. The logit gap is 100/sqrt(256) = 6.25, so the loud cell takes ~99% of its + // head's weight - and exactly zero when it is not in the selected set. + { + const int64_t N = 8; + std::vector> k2, v2; + std::vector k2f, v2f; + for (int64_t j = 0; j < N; ++j) { + std::vector kk((size_t) (NKV * HD), 0.0), vv((size_t) (NKV * HD), 0.0); + for (int64_t h = 0; h < NKV; ++h) vv[(size_t) (h * HD)] = 1.0; + kk[0] = j == N - 1 ? 100.0 : 0.0; + vv[0] = j == N - 1 ? 10.0 : 1.0; + for (int64_t i = 0; i < NKV * HD; ++i) { k2f.push_back((float) kk[(size_t) i]); v2f.push_back((float) vv[(size_t) i]); } + k2.push_back(kk); + v2.push_back(vv); + } + std::vector q((size_t) (NH * HD), 0.0); + for (int64_t h = 0; h < NH; ++h) q[(size_t) (h * HD)] = 1.0; + + Dev dq; + dq.put(as_f(q)); + const size_t scr = (size_t) N * NKV * HD; + std::vector kbits(scr), vbits(scr); + for (size_t i = 0; i < scr; ++i) { kbits[i] = f16_from_f32(k2f[i]); vbits[i] = f16_from_f32(v2f[i]); } + Dev dks(scr), dvs(scr); + dks.put(kbits); + dvs.put(vbits); + Dev dattn((size_t) NH * HD), dwt((size_t) NH * N); + + std::vector all(N), no_loud(N - 1); + for (int64_t j = 0; j < N; ++j) all[(size_t) j] = (int32_t) j; + for (int64_t j = 0; j < N - 1; ++j) no_loud[(size_t) j] = (int32_t) j; + + // the scratch is written directly here (no pool), so this section is about the attention alone + strata::kernels::qsa_attend(dq.p, dks.p, dvs.p, N, S, dattn.p, dwt.p, nullptr); + const std::vector out_all = as_d(dattn.get((size_t) NH * HD)); + strata::kernels::qsa_attend(dq.p, dks.p, dvs.p, N - 1, S, dattn.p, nullptr, nullptr); + const std::vector out_sel = as_d(dattn.get((size_t) NH * HD)); + const std::vector wts = as_d(dwt.get((size_t) NH * N)); + + verdict("with every cell selected the loud cell dominates its head 0", out_all[0] > 5.0, + "out[0][0] = " + std::to_string(out_all[0]).substr(0, 6)); + require("with the loud cell unselected it contributes EXACTLY nothing", + std::fabs(out_sel[0] - 1.0) < 1e-6, "out[0][0] = " + std::to_string(out_sel[0]).substr(0, 8)); + double worst_sum = 0; + for (int64_t h = 0; h < NH; ++h) { + double s = 0; + for (int64_t j = 0; j < N; ++j) s += wts[(size_t) (h * N + j)]; + worst_sum = std::max(worst_sum, std::fabs(s - 1.0)); + } + report("the returned weights sum to 1 per head", worst_sum, "max |1 - sum|"); + + auto sep_attend = [&](const std::string& name, const Alt& a) { + const std::vector alt = ref_attend(q, NH, NKV, HD, all, k2, v2, a, nullptr); + double num = 0, den = 0; + for (size_t i = 0; i < alt.size(); ++i) { + num += std::fabs(alt[i] - out_all[i]); + den += std::fabs(alt[i]); + } + verdict(name, num / den > 0.05, pct(num / den)); + }; + Alt a8; a8.kv_divide = false; + sep_attend("q % 2 vs q / 12 for the kv head is observable", a8); + // The scale trap is NOT asserted on this fixture: its softmax is saturated (the loud cell holds 99% + // of the weight, so removing a 16x logit scale moves it to 100% and the output by 1.09%). It is + // asserted on the random 2100-cell fixture below, where the logits are O(10) and the temperature + // actually matters. This is round 196's lesson about a fixture that cannot see its own trap. + std::printf(" %-48s %-6s %s\n", " (the scale trap is measured on the random fixture)", "--", + "this fixture's softmax is saturated"); + { // the mask: the same reference with and without the loud cell in the selected set + const std::vector with_loud = ref_attend(q, NH, NKV, HD, all, k2, v2, Alt{}, nullptr); + const std::vector without = ref_attend(q, NH, NKV, HD, no_loud, k2, v2, Alt{}, nullptr); + double num = 0, den = 0; + for (size_t i = 0; i < with_loud.size(); ++i) { + num += std::fabs(with_loud[i] - without[i]); + den += std::fabs(with_loud[i]); + } + verdict("attending to the unselected cell is observable", num / den > 0.05, pct(num / den)); + } + } + + // ================= 6. the whole layer at n_kv = 2100, so the selection is genuinely sparse ============= + int64_t width = 0; + { + const int64_t T = 2100; // > 2051, so `width` really truncates + width = strata::kernels::qsa_selection_width(T, S); + std::vector pk((size_t) T * NKV * HD), pv((size_t) T * NKV * HD), praw((size_t) T * IDXD); + std::vector> k16((size_t) T), v16((size_t) T); + RefCache pc; + std::vector q((size_t) NH * HD); + for (auto& x : q) x = rnd(1.0); + for (int64_t t = 0; t < T; ++t) { + std::vector kk((size_t) (NKV * HD)), vv((size_t) (NKV * HD)), rr((size_t) IDXD); + for (int64_t i = 0; i < NKV * HD; ++i) { + kk[(size_t) i] = gauss(rng); + vv[(size_t) i] = gauss(rng); + pk[(size_t) t * NKV * HD + i] = (float) kk[(size_t) i]; + pv[(size_t) t * NKV * HD + i] = (float) vv[(size_t) i]; + } + for (int64_t d = 0; d < IDXD; ++d) { + rr[(size_t) d] = gauss(rng); + praw[(size_t) t * IDXD + d] = (float) rr[(size_t) d]; + } + for (int64_t i = 0; i < NKV * HD; ++i) { + kk[(size_t) i] = (double) pk[(size_t) t * NKV * HD + i]; // the f32 the device got + vv[(size_t) i] = (double) pv[(size_t) t * NKV * HD + i]; + } + for (int64_t d = 0; d < IDXD; ++d) rr[(size_t) d] = (double) praw[(size_t) t * IDXD + d]; + pc.append(kk, vv, rr, (double) t); + k16[(size_t) t].resize((size_t) (NKV * HD)); + v16[(size_t) t].resize((size_t) (NKV * HD)); + for (int64_t i = 0; i < NKV * HD; ++i) { + k16[(size_t) t][(size_t) i] = (double) f32_from_f16(f16_from_f32(pk[(size_t) t * NKV * HD + i])); + v16[(size_t) t][(size_t) i] = (double) f32_from_f16(f16_from_f32(pv[(size_t) t * NKV * HD + i])); + } + } + const int64_t p_nbid = T / R; // 525 complete blocks; the spare key sits at row 525 + const int64_t page_size = 64, n_pages = (T + page_size - 1) / page_size; + strata::kernels::QsaShapes Sp = S; // the granule lives in the shapes: see section 1 + Sp.page_size = page_size; + std::vector table((size_t) n_pages); + for (int64_t i = 0; i < n_pages; ++i) table[(size_t) i] = (int32_t) (n_pages - 1 - i); // REVERSED + + Dev dtab; + dtab.put(table); + Dev dpk_all, dpv_all; + dpk_all.put(pk); + dpv_all.put(pv); + Dev dpk((size_t) n_pages * NKV * page_size * HD), dpv((size_t) n_pages * NKV * page_size * HD); + for (int64_t t = 0; t < T; ++t) + strata::kernels::kv_append(dpk.p, dpv.p, dtab.p, t, dpk_all.p + (size_t) t * NKV * HD, + dpv_all.p + (size_t) t * NKV * HD, Sp, nullptr); + + const size_t ppooled_n = (size_t) (p_nbid + 1) * IDXD; + Dev dpp((size_t) ppooled_n), dpdead((size_t) IDXD), dptail((size_t) (R - 1) * IDXD); + Dev dblockpos2(1); + Dev dpos2(1); + Dev draw((size_t) IDXD); + strata::kernels::QsaIndexerBuffers bufs{dptail.p, dpdead.p, dpp.p, dblockpos2.p}; + for (int64_t t = 0; t < T; ++t) { + check(cudaMemcpy(draw.p, &praw[(size_t) t * IDXD], (size_t) IDXD * 4, cudaMemcpyHostToDevice), "raw"); + const int32_t tpos = (int32_t) t; + check(cudaMemcpy(dpos2.p, &tpos, 4, cudaMemcpyHostToDevice), "pos2"); + strata::kernels::indexer_key_append(draw.p, dpos2.p, 0, dw_kn.p, EPS, bufs, S, dcos.p, dsin.p, + nullptr); + } + Dev dqidx, dsc; + dqidx.put(q_idx); + dsc.alloc((size_t) T); + strata::kernels::qsa_index(dpp.p, p_nbid, dqidx.p, nullptr, S, T, dsc.p, nullptr); + + Dev dsel((size_t) T); + strata::kernels::topk_512(dsc.p, T, S, T, dsel.p, nullptr); + const std::vector sel_sparse = dsel.get((size_t) width); + const std::vector pool32 = dpp.get(ppooled_n); + const std::vector pool64 = as_d(pool32); + const std::vector q_idx64 = as_d(q_idx); + const std::vector sc_want = + ref_indexer_scores(pool64, p_nbid, q_idx64, IDXN, IDXD, {}, T, Alt{}); + const std::vector sel_want = ref_select(sc_want, T, TOPK, false); + require("the selected set matches the reference (2100 cells, width 2051)", sel_sparse == sel_want, + std::to_string(width) + " ids"); + + // HOW CLOSE IS THE CUT? `indexer_ids` is an EXACT capture key, so a boundary gap of a few fp32 ulp + // would make it sensitive to the accumulation order. The gap is measured in ulp of the cut score. + { + std::vector issel((size_t) T, 0); + for (int32_t id : sel_sparse) issel[(size_t) id] = 1; + double lo = 1e300, hi = -1e300; + for (int32_t id : sel_sparse) lo = std::min(lo, sc_want[(size_t) id]); + for (int64_t j = 0; j < T; ++j) if (!issel[(size_t) j]) hi = std::max(hi, sc_want[(size_t) j]); + if (lo == hi) { + // The cut sits inside a group of cells that all score exactly the same - here 0.0, a relu + // signal with all four head dots negative - so nothing but the ascending-index rule decides + // which of them survive. A gap metric is undefined there; the tie is the finding. + int64_t tied_in = 0, tied_out = 0; + for (int32_t id : sel_sparse) if (sc_want[(size_t) id] == lo) ++tied_in; + for (int64_t j = 0; j < T; ++j) if (!issel[(size_t) j] && sc_want[(size_t) j] == lo) ++tied_out; + std::printf(" %-48s THE CUT IS A TIE at %.6f: %lld selected, %lld rejected\n", + "the cut", lo, (long long) tied_in, (long long) tied_out); + require(" ...and the kernel picks the same ones as the reference", sel_sparse == sel_want); + } else { + report("the selection boundary gap, in fp32 ulp of the cut", + (lo - hi) / (std::fabs(lo) * 1.2e-7), "gap/ulp"); + std::printf(" %-48s last selected %.6f, first rejected %.6f\n", "the cut", lo, hi); + } + } + + // the pipeline. `--qsa-dense` swaps the SELECTION for every cell and keeps everything else; the sparse + // selection above is still compared against the reference either way. + std::vector sel = sel_sparse; + if (dense) { + sel.resize((size_t) T); + for (int64_t j = 0; j < T; ++j) sel[(size_t) j] = (int32_t) j; + } + Dev dsel2; + dsel2.put(sel); + const int64_t nsel = (int64_t) sel.size(); + Dev dks((size_t) nsel * NKV * HD), dvs((size_t) nsel * NKV * HD); + strata::kernels::kv_gather(dpk.p, dpv.p, dtab.p, dsel2.p, nsel, Sp, dks.p, dvs.p, nullptr); + Dev dqq, dattn((size_t) NH * HD), dwt((size_t) NH * nsel); + dqq.put(q); + strata::kernels::qsa_attend(dqq.p, dks.p, dvs.p, nsel, S, dattn.p, dwt.p, nullptr); + const std::vector attn = as_d(dattn.get((size_t) NH * HD)); + const std::vector wts = as_d(dwt.get((size_t) NH * nsel)); + + const std::vector q64 = as_d(q); + const std::vector attn_f64 = ref_attend(q64, NH, NKV, HD, sel, pc.k, pc.v, Alt{}, nullptr); + const std::vector attn_f16 = ref_attend(q64, NH, NKV, HD, sel, k16, v16, Alt{}, nullptr); + double num = 0, den = 0; + for (size_t i = 0; i < attn.size(); ++i) { + num += std::fabs(attn[i] - attn_f16[i]); + den += std::fabs(attn_f16[i]); + } + report("qsa_attend vs the reference on the SAME fp16 cache", num / den, "rel"); + { // the SCALE trap, on data whose softmax is not saturated + Alt a; a.apply_scale = false; + const std::vector alt = ref_attend(q64, NH, NKV, HD, sel, k16, v16, a, nullptr); + double n2 = 0, d2 = 0; + for (size_t i = 0; i < alt.size(); ++i) { + n2 += std::fabs(alt[i] - attn_f16[i]); + d2 += std::fabs(attn_f16[i]); + } + verdict("omitting 1/sqrt(head_dim) is observable (random fixture)", n2 / d2 > 0.05, pct(n2 / d2)); + } + if (!(num / den <= 1e-5)) { std::printf(" *** over 1e-5 ***\n"); ++g_bad; } + const double fp16_cost = rel_l1(attn_f64, attn_f16); + report("the fp16 KV cache's OWN cost (f32 keys vs fp16 keys)", fp16_cost, "rel"); + if (!(fp16_cost <= 1e-3)) { + std::printf(" *** the phase's 1e-3 for FP16 paths is exceeded by the CACHE ***\n"); + ++g_bad; + } + { + std::vector w_ref; + ref_attend(q64, NH, NKV, HD, sel, k16, v16, Alt{}, &w_ref); + double wn = 0, wd = 0; + for (int64_t j = 0; j < nsel; ++j) { + wn += std::fabs(wts[(size_t) j] - w_ref[(size_t) j]); + wd += std::fabs(w_ref[(size_t) j]); + } + report("the returned weights vs the reference (head 0)", wn / wd, "rel"); + } + + // the gate + std::vector q_full((size_t) (NH * 2 * HD)); + for (auto& x : q_full) x = rnd(2.0); + Dev dqf, dattnf; + dqf.put(q_full); + dattnf.put(as_f(attn)); + Dev dgate((size_t) NH * HD); + strata::kernels::qsa_gate_apply(dattnf.p, dqf.p, S, dgate.p, nullptr); + const std::vector gate16 = dgate.get((size_t) NH * HD); + const std::vector qf64 = as_d(q_full); + const std::vector gwant = ref_gate(attn, qf64, NH, HD, Alt{}); + double gnum = 0, gden = 0; + int gbits = 0; + for (size_t i = 0; i < gwant.size(); ++i) { + gnum += std::fabs((double) f32_from_f16(gate16[i]) - gwant[i]); + gden += std::fabs(gwant[i]); + if (gate16[i] != f16_from_f32((float) gwant[i])) ++gbits; + } + report("qsa_gate_apply vs the double reference", gnum / gden, "rel (f16 out quant ~2.4e-4)"); + if (!(gnum / gden <= 1e-3)) { std::printf(" *** over 1e-3 ***\n"); ++g_bad; } + std::printf(" %-48s %d of %lld (CUDA's double exp vs libm's, in the last bits)\n", + "fp16 gate bits differing from the reference", gbits, (long long) gwant.size()); + + auto sep_gate = [&](const std::string& name, const Alt& a) { + const std::vector alt = ref_gate(attn, qf64, NH, HD, a); + double n2 = 0, d2 = 0; + for (size_t i = 0; i < alt.size(); ++i) { + n2 += std::fabs(alt[i] - gwant[i]); + d2 += std::fabs(gwant[i]); + } + verdict(name, n2 / d2 > 0.05, pct(n2 / d2)); + }; + Alt a10; a10.gate_second_half = false; + sep_gate("the gate from the FIRST half is observable", a10); + Alt a11; a11.gate_sigmoid = false; + sep_gate("silu instead of sigmoid on the QSA gate is observable", a11); + + // DENSE vs SPARSE. Below the bound the width IS n_kv, so the two paths must agree EXACTLY (P1.S4's + // isolation property, `ref/qsa.py` PROPERTY 9b); above it they must differ, or the switch is a no-op. + require("below the bound, dense and sparse are the SAME selection", + ref_select(sc_want, 2000, TOPK, false) == ref_select(sc_want, 2000, TOPK, true)); + { + // BOTH SIDES COMPUTED HERE, from their own id lists and independently of `--qsa-dense`. The first + // version compared against the pipeline's `attn`, which in dense mode IS the dense attention - so the + // switch silently turned the check into a comparison of a thing with itself and reported 0.00%. + const std::vector dense_all = ref_select(sc_want, T, TOPK, true); + auto attend_ids = [&](const std::vector& ids) { + Dev did; + did.put(ids); + Dev dk2((size_t) ids.size() * NKV * HD), dv2((size_t) ids.size() * NKV * HD); + strata::kernels::kv_gather(dpk.p, dpv.p, dtab.p, did.p, (int64_t) ids.size(), Sp, dk2.p, dv2.p, + nullptr); + Dev da((size_t) NH * HD); + strata::kernels::qsa_attend(dqq.p, dk2.p, dv2.p, (int64_t) ids.size(), S, da.p, nullptr, nullptr); + return as_d(da.get((size_t) NH * HD)); + }; + const std::vector dense_attn = attend_ids(dense_all); + const std::vector sparse_attn = attend_ids(sel_sparse); + double dn = 0, dd = 0; + for (size_t i = 0; i < sparse_attn.size(); ++i) { + dn += std::fabs(dense_attn[i] - sparse_attn[i]); + dd += std::fabs(sparse_attn[i]); + } + verdict("dense differs from sparse at n_kv = 2100 (> 2051)", dn / dd > 0.05, pct(dn / dd)); + } + } + + // ================= 7. the empty selection ================= + { + Dev dq((size_t) NH * HD), dattn((size_t) NH * HD); + dq.put(std::vector((size_t) NH * HD, 1.0f)); + dattn.put(std::vector((size_t) NH * HD, 7.0f)); + strata::kernels::qsa_attend(dq.p, nullptr, nullptr, 0, S, dattn.p, nullptr, nullptr); + const std::vector got = dattn.get((size_t) NH * HD); + bool allz = true; + for (float x : got) if (x != 0.0f) allz = false; + require("an EMPTY selection gives a zero attention, not a NaN", allz, "all 6144 elements zero"); + } + + // ================= 8. --bench: what one QSA layer costs at 32K of context ================= + if (bench) { + const int64_t T = MAXC; // 32,768 cells = 32K of context + const int64_t nbid = T / R; + const int64_t ps = S.page_size, n_pages = (T + ps - 1) / ps; + const int64_t w = strata::kernels::qsa_selection_width(T, S); + std::printf("\n --bench at n_kv = %lld, page_size %lld, selection width %lld\n", (long long) T, + (long long) ps, (long long) w); + std::vector table((size_t) n_pages); + for (int64_t i = 0; i < n_pages; ++i) table[(size_t) i] = (int32_t) i; + Dev dtab; + dtab.put(table); + Dev dpk((size_t) n_pages * NKV * ps * HD), dpv((size_t) n_pages * NKV * ps * HD); + std::vector kz((size_t) NKV * HD), vz((size_t) NKV * HD); + for (auto& x : kz) x = rnd(1.0); + for (auto& x : vz) x = rnd(1.0); + Dev dkz, dvz; + dkz.put(kz); + dvz.put(vz); + strata::kernels::kv_append(dpk.p, dpv.p, dtab.p, 0, dkz.p, dvz.p, S, nullptr); + + const size_t ppooled_n = (size_t) (nbid + 1) * IDXD; + Dev dpp((size_t) ppooled_n), dsc((size_t) T); + std::vector pz(ppooled_n), sz((size_t) T); + for (auto& x : pz) x = rnd(0.1f); + for (auto& x : sz) x = std::fabs(rnd(1.0f)); + dpp.put(pz); + dsc.put(sz); + Dev dqidx; + dqidx.put(q_idx); + Dev dsel((size_t) w); + Dev dks((size_t) w * NKV * HD), dvs((size_t) w * NKV * HD); + Dev dq((size_t) NH * HD), dattn((size_t) NH * HD), dwt((size_t) NH * w); + std::vector qv((size_t) NH * HD); + for (auto& x : qv) x = rnd(1.0); + dq.put(qv); + + auto timeit = [&](const std::string& name, int reps, const std::function& fn) { + cudaEvent_t a, b; + cudaEventCreate(&a); + cudaEventCreate(&b); + fn(); + cudaDeviceSynchronize(); + cudaEventRecord(a); + for (int i = 0; i < reps; ++i) fn(); + cudaEventRecord(b); + cudaEventSynchronize(b); + float ms = 0; + cudaEventElapsedTime(&ms, a, b); + std::printf(" %-48s %8.1f us\n", name.c_str(), ms * 1000.0f / (float) reps); + cudaEventDestroy(a); + cudaEventDestroy(b); + }; + const int reps = 20; + timeit("qsa_index (8193 pooled rows x 4 heads)", reps, + [&] { strata::kernels::qsa_index(dpp.p, nbid, dqidx.p, nullptr, S, T, dsc.p, nullptr); }); + timeit("topk_512 (32768 cells)", reps, + [&] { strata::kernels::topk_512(dsc.p, T, S, w, dsel.p, nullptr); }); + strata::kernels::topk_512(dsc.p, T, S, w, dsel.p, nullptr); + timeit("kv_gather (2051 ids through the page table)", reps, + [&] { strata::kernels::kv_gather(dpk.p, dpv.p, dtab.p, dsel.p, w, S, dks.p, dvs.p, nullptr); }); + timeit("qsa_attend (2051 selected, 24 heads)", reps, + [&] { strata::kernels::qsa_attend(dq.p, dks.p, dvs.p, w, S, dattn.p, dwt.p, nullptr); }); + timeit("kv_append (one cell, 2 heads x 256)", 200, + [&] { strata::kernels::kv_append(dpk.p, dpv.p, dtab.p, 0, dkz.p, dvz.p, S, nullptr); }); + timeit("ONE QSA LAYER (index+topk+gather+attend)", reps, [&] { + strata::kernels::qsa_index(dpp.p, nbid, dqidx.p, nullptr, S, T, dsc.p, nullptr); + strata::kernels::topk_512(dsc.p, T, S, w, dsel.p, nullptr); + strata::kernels::kv_gather(dpk.p, dpv.p, dtab.p, dsel.p, w, S, dks.p, dvs.p, nullptr); + strata::kernels::qsa_attend(dq.p, dks.p, dvs.p, w, S, dattn.p, dwt.p, nullptr); + }); + } + + // ================= 6. indexer_key_append IS CAPTURABLE ================= + // + // THE PROPERTY THE DEVICE-POSITION REFACTOR WAS FOR, TESTED DIRECTLY rather than argued. `indexer_key_append` + // took `int64_t pos` as a host scalar, which a CUDA graph bakes in at capture time - so a captured call + // would replay the first token's position forever: `slot = pos % r` never advances, no block completes, and + // the indexer keeps pooling cells 0-3 for the whole sequence. + // + // The test captures ONE call, then replays the SAME graph twice with `pos_dev` and `raw` changed between + // them, and requires the second replay to land in a DIFFERENT pooled row with the reference's value. If the + // position were still baked in, both replays would write row 0 and the pooled rows would be identical. + // + // That is the negative control built into the assertion: "row 1 is correct" alone could also pass for a + // graph that reread the position by accident, but "row 0 unchanged AND row 1 correct AND row 1 != row 0" + // cannot pass for a baked-in position. + { + std::printf("\n-- indexer_key_append under graph capture\n"); + const int R2 = 4; + const int64_t NCELL = 8; // two complete blocks of r=4 + const size_t IDXD2 = (size_t) IDXD; + std::vector raw2((size_t) NCELL * IDXD2); + for (size_t i = 0; i < raw2.size(); ++i) raw2[i] = (float) std::sin((double) i * 0.013) * 2.0f - 0.3f; + + const int64_t nb2 = NCELL / R2; + Dev pooled2((size_t) (nb2 + 2) * IDXD2), dead2(IDXD2), tail2((size_t) (R2 - 1) * IDXD2); + Dev bpos2(1), pos2(1); + Dev raw2d(IDXD2); + strata::kernels::QsaIndexerBuffers bufs2{tail2.p, dead2.p, pooled2.p, bpos2.p}; + + // capture ONE call, on its own stream + cudaStream_t cs = nullptr; + check(cudaStreamCreate(&cs), "cs"); + check(cudaStreamBeginCapture(cs, cudaStreamCaptureModeThreadLocal), "begincap"); + strata::kernels::indexer_key_append(raw2d.p, pos2.p, POS_BASE, dw_kn.p, EPS, bufs2, S, dcos.p, dsin.p, + (void*) cs); + cudaGraph_t g2 = nullptr; + check(cudaStreamEndCapture(cs, &g2), "endcap"); + check(cudaStreamDestroy(cs), "csd"); + size_t nodes2 = 0; + check(cudaGraphGetNodes(g2, nullptr, &nodes2), "nodes"); + cudaGraphExec_t ex2 = nullptr; + check(cudaGraphInstantiate(&ex2, g2, 0), "inst"); + + // replay A: cells 0..3, which completes block 0 + for (int t = 0; t < 4; ++t) { + check(cudaMemcpy(raw2d.p, &raw2[(size_t) t * IDXD2], IDXD2 * 4, cudaMemcpyHostToDevice), "r2"); + const int32_t tp = t; + check(cudaMemcpy(pos2.p, &tp, 4, cudaMemcpyHostToDevice), "p2"); + check(cudaGraphLaunch(ex2, nullptr), "launchA"); + } + const std::vector afterA = pooled2.get((size_t) (nb2 + 2) * IDXD2); + + // replay B: cells 4..7 through the SAME graph, which completes block 1 + for (int t = 4; t < 8; ++t) { + check(cudaMemcpy(raw2d.p, &raw2[(size_t) t * IDXD2], IDXD2 * 4, cudaMemcpyHostToDevice), "r3"); + const int32_t tp = t; + check(cudaMemcpy(pos2.p, &tp, 4, cudaMemcpyHostToDevice), "p3"); + check(cudaGraphLaunch(ex2, nullptr), "launchB"); + } + check(cudaDeviceSynchronize(), "sync2"); + const std::vector afterB = pooled2.get((size_t) (nb2 + 2) * IDXD2); + + std::printf(" %-44s %zu nodes for one call\n", "the pooled kernel is capturable", nodes2); + + // row 0 must be UNTOUCHED by the second block's replays + int row0_same = 0; + for (size_t d = 0; d < IDXD2; ++d) + if (afterA[d] == afterB[d]) ++row0_same; + std::printf(" %-44s %d of %zu unchanged\n", "the first block's row survives replay B", row0_same, + IDXD2); + if (row0_same != (int) IDXD2) ++g_bad; + + // row 1 must now be FILLED by the captured graph - the whole point + int row1_nonzero = 0; + for (size_t d = 0; d < IDXD2; ++d) + if (afterB[IDXD2 + d] != 0.0f) ++row1_nonzero; + std::printf(" %-44s %d of %zu non-zero\n", "the SECOND block's row arrives through the same graph", + row1_nonzero, IDXD2); + if (row1_nonzero != (int) IDXD2) ++g_bad; + + // and the two rows must DIFFER, which a baked-in position cannot produce + int rows_differ = 0; + for (size_t d = 0; d < IDXD2; ++d) + if (afterB[d] != afterB[IDXD2 + d]) ++rows_differ; + std::printf(" %-44s %d of %zu differ\n", "the two pooled rows are different keys", rows_differ, + IDXD2); + // A baked-in position would have written row 0 a second time and left row 1 at zero, so these two + // assertions together are the negative control for the refactor. + if (rows_differ != (int) IDXD2) ++g_bad; + + check(cudaGraphExecDestroy(ex2), "exd"); + check(cudaGraphDestroy(g2), "gd"); + } + + std::printf("\nqsa: %d failures\n", g_bad); + if (g_bad) return 1; + if (selftest) std::printf("qsa_parity OK\n"); + return 0; +} diff --git a/src/kernels/quantize_act_parity.cpp b/src/kernels/quantize_act_parity.cpp new file mode 100644 index 00000000..b2cd7ee4 --- /dev/null +++ b/src/kernels/quantize_act_parity.cpp @@ -0,0 +1,407 @@ +// src/kernels/quantize_act_parity.cpp - P2.S2's parity test for the Q8_0 activation quantizer. +// +// THE CHECK IS ON THE BYTES, not on the round-tripped values. `ref/quant.py::q8_0`'s central finding is that +// dividing by the fp16-rounded scale instead of the fp32 one changed 18 of 80 blocks' quantized integers +// while leaving the dequantized values within a rounding step - so a comparison of VALUES would have passed +// and the engine would still have disagreed with ggml. Comparing the 34-byte block catches that directly. +// +// The reference is a host transcription of `ref/quant.py::q8_0`: +// +// d32 = amax / 127.0f (f32) +// d16 = fp16(d32) (stored, and used to DEQUANTIZE) +// q = clip(rint(x / (double)d32), -128, 127) +// round trip = q * d16 +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/f16_bits.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +// THE CONVERSIONS COME FROM `f16_bits.hpp`, and this test used to carry its own. The private `f16_bits_to_f32` +// below flushed fp16 SUBNORMALS to zero, and the private encoder returned NaN for finite fp16 overflow - both +// invisible with the O(1) fixture this file has. Round 198 validated the shared pair against 395 numpy +// float16 vectors and all 65,536 reverse patterns, so the test now shares the implementation it is checking +// instead of guessing at it independently. Thin aliases keep the body below unchanged. +using strata::kernels::f16_from_f32; +using strata::kernels::f32_from_f16; +inline uint16_t f32_to_f16_bits(float f) { return f16_from_f32(f); } +inline float f16_bits_to_f32(uint16_t h) { return f32_from_f16(h); } + +void reference_q8_0(const float* x, long long n, std::vector& blocks, std::vector& back) { + blocks.assign((size_t) (n / 32) * 34, 0); + back.assign((size_t) n, 0.0f); + for (long long b = 0; b < n / 32; ++b) { + const float* xb = x + b * 32; + float amax = 0.0f; + for (int i = 0; i < 32; ++i) amax = std::fmax(amax, std::fabs(xb[i])); + uint8_t* out = &blocks[(size_t) b * 34]; + if (amax == 0.0f) continue; + const float d32 = amax / 127.0f; + const uint16_t dbits = f32_to_f16_bits(d32); + out[0] = (uint8_t) (dbits & 0xFF); + out[1] = (uint8_t) (dbits >> 8); + const float d16 = f16_bits_to_f32(dbits); + for (int i = 0; i < 32; ++i) { + double q = std::rint((double) xb[i] / (double) d32); + if (q > 127.0) q = 127.0; + if (q < -128.0) q = -128.0; + out[2 + i] = (uint8_t) (int8_t) q; + back[(size_t) (b * 32 + i)] = (float) (int8_t) out[2 + i] * d16; + } + } +} + +int run_case(const char* name, const std::vector& x, bool check_bytes) { + const long long n = (long long) x.size(); + std::vector r_blocks, g_blocks((size_t) (n / 32) * 34); + std::vector r_back, g_back((size_t) n); + reference_q8_0(x.data(), n, r_blocks, r_back); + + float* d_x = nullptr; + uint8_t* d_b = nullptr; + float* d_back = nullptr; + check(cudaMalloc(&d_x, (size_t) n * sizeof(float)), "malloc x"); + check(cudaMalloc(&d_b, g_blocks.size()), "malloc blocks"); + check(cudaMalloc(&d_back, (size_t) n * sizeof(float)), "malloc back"); + check(cudaMemcpy(d_x, x.data(), (size_t) n * sizeof(float), cudaMemcpyHostToDevice), "copy x"); + // PROBE: does the DEVICE hold what the host thinks it sent? The scale byte differs with the SAME + // exponent and a zeroed mantissa, which says the kernel's amax differs from the host's on identical + // input - so the first thing to rule out is the copy itself. + { + std::vector rt((size_t) n); + check(cudaMemcpy(rt.data(), d_x, (size_t) n * sizeof(float), cudaMemcpyDeviceToHost), "probe copy back"); + long long diff = 0; + for (long long i = 0; i < n; ++i) if (std::memcmp(&rt[(size_t) i], &x[(size_t) i], 4) != 0) ++diff; + if (diff) std::printf(" PROBE: device x differs from host x in %lld of %lld elements\n", diff, n); + } + strata::kernels::quantize_q8_0(d_x, d_b, n, nullptr); + strata::kernels::dequant_q8_0(d_b, d_back, n, nullptr); + check(cudaMemcpy(g_blocks.data(), d_b, g_blocks.size(), cudaMemcpyDeviceToHost), "back blocks"); + check(cudaMemcpy(g_back.data(), d_back, (size_t) n * sizeof(float), cudaMemcpyDeviceToHost), "back vals"); + + long long byte_bad = 0, val_bad = 0; + if (check_bytes) { + for (size_t i = 0; i < g_blocks.size(); ++i) if (g_blocks[i] != r_blocks[i]) ++byte_bad; + } + for (long long i = 0; i < n; ++i) { + const float a = r_back[(size_t) i], b = g_back[(size_t) i]; + // the round trip is `q * d16`: both sides use the SAME fp16 d, so this is exact when q agrees + if (std::memcmp(&a, &b, sizeof(float)) != 0) ++val_bad; + } + if (byte_bad && check_bytes) { + for (size_t i = 0; i < g_blocks.size(); ++i) { + if (g_blocks[i] != r_blocks[i]) { + const size_t blk = i / 34; + const float amax = [&] { float m = 0; for (int k = 0; k < 32; ++k) m = std::fmax(m, std::fabs(x[(size_t)(blk*32+k)])); return m; }(); + std::printf(" first bad byte: block %zu offset %zu amax %.9g d32 %.9g\n", blk, i % 34, amax, amax/127.0f); + std::printf(" ref scale bytes %02x %02x got %02x %02x\n", r_blocks[blk*34], r_blocks[blk*34+1], g_blocks[blk*34], g_blocks[blk*34+1]); + std::printf(" ref[0..9]:"); for (int k = 0; k < 10; ++k) std::printf(" %02x", r_blocks[blk*34+k]); + std::printf("\n got[0..9]:"); for (int k = 0; k < 10; ++k) std::printf(" %02x", g_blocks[blk*34+k]); + std::printf("\n ref[10..17]:"); for (int k = 10; k < 18; ++k) std::printf(" %02x", r_blocks[blk*34+k]); + std::printf("\n got[10..17]:"); for (int k = 10; k < 18; ++k) std::printf(" %02x", g_blocks[blk*34+k]); + std::printf("\n x[0..3] = %.9g %.9g %.9g %.9g\n", (double)x[(size_t)(blk*32)], (double)x[(size_t)(blk*32+1)], (double)x[(size_t)(blk*32+2)], (double)x[(size_t)(blk*32+3)]); + break; + } + } + } + std::printf(" %-26s blocks %s (%lld bad bytes) round trip %s (%lld differ)\n", name, + !check_bytes ? "not compared" : (byte_bad ? "*** WRONG ***" : "byte-exact"), byte_bad, + val_bad ? "*** WRONG ***" : "bit-exact", val_bad); + cudaFree(d_x); + cudaFree(d_b); + cudaFree(d_back); + return (int) (byte_bad + val_bad); +} + +// A host transcription of ggml's `quantize_row_q8_K_ref` (ggml-quants.c L2768), including `nearest_int`. +// +// NOTE WHICH REFERENCE THIS IS. `ref/quant.py::q8_K` is the numpy transcription and it multiplies in +// FLOAT64 (`iscale * blk.astype(np.float64)`), while ggml multiplies in FLOAT32 before `nearest_int`. The +// oracle is ggml, so this follows ggml. The two agree except where an f32 product lands exactly on a tie or +// a rounding boundary, which is exactly the regime the "exact ties" case below constructs. +int nearest_int_host(float fval) { + const float val = fval + 12582912.0f; + int i; + std::memcpy(&i, &val, 4); + return (i & 0x007fffff) - 0x00400000; +} + +void reference_q8_K(const float* x, long long n, std::vector& blocks, std::vector& back, + float scale_num) { + const int QK = 256; + blocks.assign((size_t) (n / QK) * 292, 0); + back.assign((size_t) n, 0.0f); + for (long long b = 0; b < n / QK; ++b) { + const float* xb = x + b * QK; + uint8_t* out = &blocks[(size_t) b * 292]; + float max = 0.0f, amax = 0.0f; + for (int j = 0; j < QK; ++j) { + const float ax = std::fabs(xb[j]); + if (ax > amax) { amax = ax; max = xb[j]; } // STRICTLY greater: ties keep the FIRST maximum + } + if (amax == 0.0f) continue; // d = 0, qs = 0, bsums = 0 (see the kernel note) + const float iscale = scale_num / max; // -127 by default; -128 is the rival reading + int8_t* qs = (int8_t*) (out + 4); + for (int j = 0; j < QK; ++j) { + const int v = nearest_int_host(iscale * xb[j]); + qs[j] = (int8_t) std::min(127, v); + } + int16_t* bsums = (int16_t*) (out + 4 + QK); + for (int j = 0; j < QK / 16; ++j) { + int sum = 0; + for (int ii = 0; ii < 16; ++ii) sum += qs[j * 16 + ii]; + bsums[j] = (int16_t) sum; + } + const float d = 1.0f / iscale; + std::memcpy(out, &d, 4); + for (int j = 0; j < QK; ++j) back[(size_t) (b * QK + j)] = (float) qs[j] * d; + } +} + +int run_case_k(const char* name, const std::vector& x, bool check_bytes, float scale_num = -127.0f) { + const long long n = (long long) x.size(); + std::vector r_blocks, g_blocks((size_t) (n / 256) * 292); + std::vector r_back, g_back((size_t) n); + reference_q8_K(x.data(), n, r_blocks, r_back, scale_num); + + float* d_x = nullptr; + uint8_t* d_b = nullptr; + float* d_back = nullptr; + check(cudaMalloc(&d_x, (size_t) n * sizeof(float)), "malloc x"); + check(cudaMalloc(&d_b, g_blocks.size()), "malloc blocks"); + check(cudaMalloc(&d_back, (size_t) n * sizeof(float)), "malloc back"); + check(cudaMemcpy(d_x, x.data(), (size_t) n * sizeof(float), cudaMemcpyHostToDevice), "copy x"); + strata::kernels::quantize_q8_K(d_x, d_b, n, nullptr); + strata::kernels::dequant_q8_K(d_b, d_back, n, nullptr); + check(cudaMemcpy(g_blocks.data(), d_b, g_blocks.size(), cudaMemcpyDeviceToHost), "back blocks"); + check(cudaMemcpy(g_back.data(), d_back, (size_t) n * sizeof(float), cudaMemcpyDeviceToHost), "back vals"); + + long long byte_bad = 0, val_bad = 0, first_bad = -1; + if (check_bytes) + for (size_t i = 0; i < g_blocks.size(); ++i) + if (g_blocks[i] != r_blocks[i]) { if (first_bad < 0) first_bad = (long long) i; ++byte_bad; } + for (long long i = 0; i < n; ++i) + if (std::memcmp(&r_back[(size_t) i], &g_back[(size_t) i], 4) != 0) ++val_bad; + std::printf(" %-26s blocks %s (%lld bad bytes) round trip %s (%lld differ)\n", name, + !check_bytes ? "not compared" : (byte_bad ? "*** WRONG ***" : "byte-exact"), byte_bad, + val_bad ? "*** WRONG ***" : "bit-exact", val_bad); + if (first_bad >= 0) { + // Say WHERE and WHAT, because "1 element in 524288" is the signature of a rounding boundary and not + // of a wrong formula - and the two need completely different responses. + const size_t blk = (size_t) first_bad / 292, off = (size_t) first_bad % 292; + const char* where = off < 4 ? "d (the f32 scale)" : (off < 260 ? "qs (the quants)" : "bsums"); + std::printf(" first bad byte: block %zu offset %zu = %s ref %02x got %02x\n", blk, off, + where, r_blocks[(size_t) first_bad], g_blocks[(size_t) first_bad]); + if (off >= 4 && off < 260) { + const int j = (int) off - 4; + const float xv = x[(size_t) (blk * 256 + j)]; + float mx = 0.0f; + for (int k = 0; k < 256; ++k) { const float ax = std::fabs(x[(size_t) (blk * 256 + k)]); if (ax > std::fabs(mx)) mx = x[(size_t) (blk * 256 + k)]; } + const float isc = -127.0f / mx; + const float prod = isc * xv; + std::printf(" element %d: x %.9g max %.9g iscale %.9g iscale*x %.9g (frac %.6f)\n", + j, (double) xv, (double) mx, (double) isc, (double) prod, (double) (prod - std::floor(prod))); + } else if (off < 4) { + float rd, gd; + std::memcpy(&rd, &r_blocks[(size_t) (blk * 292)], 4); + std::memcpy(&gd, &g_blocks[(size_t) (blk * 292)], 4); + std::printf(" d: ref %.9g got %.9g\n", (double) rd, (double) gd); + } + } + cudaFree(d_x); + cudaFree(d_b); + cudaFree(d_back); + return (int) (byte_bad + val_bad); +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: quantize_act_parity [--selftest]\n"); return 2; } + } + std::mt19937 rng(7); + std::normal_distribution gauss(0.0f, 1.0f); + int bad = 0; + const long long N = 32 * 4096; + + // ---- ordinary activations + std::vector a((size_t) N); + for (auto& v : a) v = gauss(rng); + bad += run_case("random normal", a, true); + + // ---- an activation with a very small dynamic range, where d32 is tiny and the fp16 rounding of d is a + // LARGE relative change - this is where dividing by d16 instead of d32 would show up in the bytes + std::vector b((size_t) N); + for (auto& v : b) v = gauss(rng) * 1e-6f; + bad += run_case("small magnitude (1e-6)", b, true); + + // ---- an all-zero block must produce a zeroed block, not a division by zero + std::vector c((size_t) N, 0.0f); + bad += run_case("all zero", c, true); + + // ---- values that land exactly on .5 boundaries, where rint's half-to-even and a float division's + // rounding both decide the result + std::vector d((size_t) N); + for (long long i = 0; i < N; ++i) { + // construct so that x / d32 is very close to a .5 boundary: d32 = amax/127, so pick amax = 127.0 and + // x = k + 0.5 for integer k + d[(size_t) i] = ((i % 200) - 100) + 0.5f; + } + d[0] = 127.0f; + bad += run_case("exact .5 boundaries", d, true); + + // ---- a single extreme value in each block, so d32 is set by it and every other quant is small + std::vector e((size_t) N, 0.0f); + for (long long blk = 0; blk < N / 32; ++blk) e[(size_t) (blk * 32)] = 1000.0f + (float) (blk % 7); + bad += run_case("one extreme per block", e, true); + + std::printf("\nquantize_q8_0: %d failures over 5 distributions x %lld elements\n", bad, N); + + // ============================== Q8_K ============================== + // + // The K-quants' activation: 2.89 GiB of dense weights - the attention projections and `ssm_out`. The + // oracle here is GGML's C reference, and the traps are the two the source comments warn about. + // ============================== the fp16 conversion itself ============================== + // + // REGRESSION CHECK FOR A BUG THAT SHIPPED. The private encoder in `quantize_act.cu` - and the identical + // one in `shared_expert.cu` - tested `if (exp >= 31)` to catch an out-of-range exponent, which conflates + // an f32 INF/NAN (raw exponent 255) with a FINITE value too large for fp16. Every finite overflow came + // back as a NaN instead of saturating to inf. **Neither file's fixture could see it, because every value + // in both is O(1)** - the Q8_0 case above scales by a max that is never near 65504. Round 198's + // numpy-generated oracle caught it on the first run. + // + // The decoder in this same test had the matching defect in the other direction: it flushed fp16 + // SUBNORMALS to zero, so 0x0001 decoded as 0.0 instead of 2^-24. + // + // These are the exact values, asserted against what numpy's float16 says. They are here rather than + // only in `qsa_parity` because this is the file that OWNS the Q8_0/Q8_K scales, and a scale that is NaN + // poisons a whole block silently. + { + struct F16Case { float in; uint16_t want; const char* what; }; + const F16Case cases[] = { + {1e30f, 0x7C00u, "1e30 -> +inf (finite overflow)"}, + {-1e30f, 0xFC00u, "-1e30 -> -inf"}, + {65536.0f, 0x7C00u, "65536 -> +inf (just past fp16 max)"}, + // FLT_MAX is 3.4028235e38, so `1e45f` is not even a legal literal; 3e38 is the largest finite + // f32 that still overflows fp16, which is what this case is for. + {3.0e38f, 0x7C00u, "3e38 (near FLT_MAX) -> +inf"}, + {65504.0f, 0x7BFFu, "65504 -> the largest finite fp16, NOT inf"}, + // The boundary, checked against numpy: 65520.0 is the exact tie between 65504 and the + // "next" fp16 (65536 = inf) and rounds to EVEN, i.e. to inf; 65519.996 is below it and stays + // finite. The first version of this table asserted inf for 65519.996 - the FIXTURE was wrong and + // the kernel was right, which is why the expected bits are quoted from numpy rather than reasoned. + {65519.996f, 0x7BFFu, "65519.996 -> just below the boundary, still finite"}, + {65520.0f, 0x7C00u, "65520.0 -> the exact tie, rounds to even = inf"}, + {65536.0f, 0x7C00u, "65536 -> past fp16 max"}, + {5.9604645e-8f, 0x0001u, "2^-24 -> the smallest subnormal, NOT zero"}, + {1.0f, 0x3C00u, "1.0"}, + {0.0f, 0x0000u, "0.0"}, + }; + int fp16_bad = 0; + for (const F16Case& c : cases) { + const uint16_t got = strata::kernels::f16_from_f32(c.in); + if (got != c.want) { + std::printf(" *** %s: want 0x%04X got 0x%04X\n", c.what, c.want, got); + ++fp16_bad; + } + } + // and the reverse direction on the pattern that was flushed + const float sub = strata::kernels::f32_from_f16(0x0001u); + const bool sub_ok = (sub == 5.9604645e-8f); + std::printf(" %-44s %s (%d of %d wrong)\n", "f32->f16 overflow saturates, not NaN", + fp16_bad ? "*** NO ***" : "yes", fp16_bad, (int) (sizeof cases / sizeof cases[0])); + std::printf(" %-44s %s (0x0001 -> %.9g, want 2^-24)\n", "f16->f32 keeps subnormals", + sub_ok ? "yes" : "*** NO ***", (double) sub); + if (fp16_bad) bad += fp16_bad; + if (!sub_ok) ++bad; + } + + std::printf("\nQ8_K (ggml block_q8_K: 292 bytes / 256 elements, {f32 d; i8 qs[256]; i16 bsums[16]})\n"); + int bad_k = 0; + const long long NK = 256 * 2048; + + std::vector ka((size_t) NK); + for (auto& v : ka) v = gauss(rng); + bad_k += run_case_k("random normal", ka, true); + + std::vector kb((size_t) NK); + for (auto& v : kb) v = gauss(rng) * 1e-7f; // tiny: the scale is far from fp16-representable + bad_k += run_case_k("small magnitude (1e-7)", kb, true); + + std::vector kc((size_t) NK, 0.0f); + bad_k += run_case_k("all zero", kc, true); + + // EXACT TIES. `nearest_int` is round-half-to-EVEN (the 12582912.0f magic number); `roundf` is + // half-AWAY-FROM-ZERO. They differ only when `iscale*x` is exactly k+0.5, which never happens on real + // data - so this fixture MANUFACTURES it: with max = 127.0 and iscale = -1.0, x = -(k+0.5) gives exactly + // k+0.5, and the two rules disagree for every EVEN k. + std::vector kd((size_t) NK, 0.0f); + for (long long blk = 0; blk < NK / 256; ++blk) + for (int j = 0; j < 256; ++j) kd[(size_t) (blk * 256 + j)] = -(float) ((j % 100) + 0.5f); + for (long long blk = 0; blk < NK / 256; ++blk) kd[(size_t) (blk * 256)] = 127.0f; // fixes max = 127 + bad_k += run_case_k("exact .5 ties (half-to-even)", kd, true); + { + // assert the tie fixture can SEE the difference before trusting the comparison above + std::vector rb; + std::vector rback; + reference_q8_K(kd.data(), NK, rb, rback, -127.0f); + long long ties = 0, differ = 0; + for (long long i = 0; i < NK; ++i) { + const float t = -kd[(size_t) i]; + if (t - std::floor(t) == 0.5f) { + ++ties; + const int even = nearest_int_host(t); + const int away = (int) std::round(t); + if (even != away) ++differ; + } + } + std::printf(" %-26s %lld ties, %lld where half-to-even != half-away (%.1f%%)\n", + " tie fixture discriminates", ties, differ, ties ? 100.0 * (double) differ / ties : 0.0); + if (differ < ties / 3) { std::printf(" *** the tie fixture cannot see the rounding rule ***\n"); ++bad_k; } + } + + // THE -127 TRAP. ggml uses `-127/max`; the `-128` version sits COMMENTED OUT in the source, and using it + // is a 0.79% scaling error that leaves a working-looking activation. Computed with the rival constant + // and required to differ. + { + std::vector r127, r128; + std::vector b127, b128; + reference_q8_K(ka.data(), NK, r127, b127, -127.0f); + reference_q8_K(ka.data(), NK, r128, b128, -128.0f); + double d = 0, m = 0; + long long qdiffer = 0; + for (long long i = 0; i < NK; ++i) { d += std::fabs(b127[(size_t) i] - b128[(size_t) i]); m += std::fabs(b127[(size_t) i]); } + for (size_t i = 4; i < r127.size(); ++i) if (r127[i] != r128[i]) ++qdiffer; + // The expected size of the effect is exactly (1 - 127/128) = 0.78% of the magnitude, so the floor is + // 0.5% - below what the rival constant must produce and far above any noise. The byte count is the + // blunter evidence and is reported alongside it: 172,017 bytes differ, i.e. a third of the quants. + std::printf(" %-26s %-4s (%.3f%% of magnitude, %lld bytes differ)\n", "-127 vs -128 scale observable", + d / m > 0.005 ? "yes" : "*** NO ***", 100.0 * d / m, qdiffer); + if (!(d / m > 0.005)) ++bad_k; + } + + std::printf("\nquantize_act: %d failures over 5 Q8_0 + 4 Q8_K distributions\n", bad + bad_k); + if (bad + bad_k) return 1; + if (selftest) std::printf("quantize_act_parity OK\n"); + return 0; +} diff --git a/src/kernels/rope_parity.cpp b/src/kernels/rope_parity.cpp new file mode 100644 index 00000000..8460b487 --- /dev/null +++ b/src/kernels/rope_parity.cpp @@ -0,0 +1,171 @@ +// src/kernels/rope_parity.cpp - P2.S2's parity test for NEOX partial RoPE. +// +// TWO CHECKS, deliberately separated so each can be tight: +// +// 1. THE TABLE against the float64 reference - `theta ** (-2i/n_rot)`, `pos * inv`, `cos`/`sin`, all in +// double exactly as `ref/qsa.py::rope_freqs` does. Compared as float32 after the cast, so a mismatch +// here is a real arithmetic difference and not a formatting one. +// 2. THE ROTATION against a host rotation written from `ref/qsa.py::rope_neox`, using the SAME table the +// kernel is given. Both sides then perform identical float32 operations, so this is BIT-EXACT - and a +// bit-exact rotation plus a table that matches the reference means the composition matches too. +// +// A single end-to-end comparison would have had to carry one loose tolerance for both, and the interesting +// failure - the NEOX pairing being wrong - is a PERMUTATION that a loose tolerance over all 256 dims would +// happily accept. +#include "strata/kernels/rope.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: rope_parity [--selftest]\n"); return 2; } + } + + const int n_rot = 64; // the artifact's rope.dimension_count + const double theta = 1.0e7; // rope.freq_base + const int head_dim = 256; + const int rows = 24 * 8; // 24 heads over 8 positions, so several positions exercise the table + const int max_pos = 32; + const int half = n_rot / 2; + int bad = 0; + + // ---- 1. the table against the float64 reference + std::vector hcos((size_t) max_pos * half), hsin((size_t) max_pos * half); + strata::kernels::build_rope_table(n_rot, theta, max_pos, hcos.data(), hsin.data()); + long long table_bad = 0; + double table_worst = 0.0; + for (int p = 0; p < max_pos; ++p) { + for (int i = 0; i < half; ++i) { + const double inv = std::pow(theta, -2.0 * (double) i / (double) n_rot); + const double ang = (double) p * inv; + const float rc = (float) std::cos(ang), rs = (float) std::sin(ang); + if (std::memcmp(&rc, &hcos[(size_t) p * half + i], 4) != 0) ++table_bad; + if (std::memcmp(&rs, &hsin[(size_t) p * half + i], 4) != 0) ++table_bad; + table_worst = std::max(table_worst, + std::fabs((double) rc - (double) hcos[(size_t) p * half + i])); + } + } + std::printf(" rope table vs float64 reference %s (%lld of %d entries differ, worst %.1e)\n", + table_bad ? "*** WRONG ***" : "bit-exact", table_bad, max_pos * half * 2, table_worst); + bad += (int) table_bad; + + // ---- 2. the rotation, against the same spec in float32 with the SAME table + std::mt19937 rng(11); + std::normal_distribution gauss(0.0f, 1.0f); + std::vector x((size_t) rows * head_dim); + for (auto& v : x) v = gauss(rng); + std::vector pos((size_t) rows); + for (int r = 0; r < rows; ++r) pos[(size_t) r] = r % max_pos; + + std::vector ref(x.size()); + for (int r = 0; r < rows; ++r) { + const float* xr = &x[(size_t) r * head_dim]; + float* orow = &ref[(size_t) r * head_dim]; + for (int d = 0; d < head_dim; ++d) orow[d] = xr[d]; // the tail passes through + for (int i = 0; i < half; ++i) { + const float c = hcos[(size_t) pos[(size_t) r] * half + i]; + const float s = hsin[(size_t) pos[(size_t) r] * half + i]; + const float a = xr[i], b = xr[half + i]; + orow[i] = a * c - b * s; + orow[half + i] = a * s + b * c; + } + } + + float *d_x = nullptr, *d_out = nullptr, *d_cos = nullptr, *d_sin = nullptr; + int* d_pos = nullptr; + check(cudaMalloc(&d_x, x.size() * sizeof(float)), "malloc x"); + check(cudaMalloc(&d_out, ref.size() * sizeof(float)), "malloc out"); + check(cudaMalloc(&d_cos, hcos.size() * sizeof(float)), "malloc cos"); + check(cudaMalloc(&d_sin, hsin.size() * sizeof(float)), "malloc sin"); + check(cudaMalloc(&d_pos, pos.size() * sizeof(int)), "malloc pos"); + check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(float), cudaMemcpyHostToDevice), "copy x"); + check(cudaMemcpy(d_cos, hcos.data(), hcos.size() * sizeof(float), cudaMemcpyHostToDevice), "copy cos"); + check(cudaMemcpy(d_sin, hsin.data(), hsin.size() * sizeof(float), cudaMemcpyHostToDevice), "copy sin"); + check(cudaMemcpy(d_pos, pos.data(), pos.size() * sizeof(int), cudaMemcpyHostToDevice), "copy pos"); + strata::kernels::rope_neox_apply(d_x, d_out, rows, head_dim, n_rot, d_cos, d_sin, d_pos, nullptr); + std::vector got(ref.size()); + check(cudaMemcpy(got.data(), d_out, got.size() * sizeof(float), cudaMemcpyDeviceToHost), "back"); + + // A RELATIVE TOLERANCE, NOT BIT EQUALITY. *c - b*s is one of the expressions the compiler is free to + // contract into an FMA, and the host and device compilers choose differently - so a handful of values + // differ in the last bit. Round 167 established this the hard way: a bit-equality expectation that a + // kernel cannot meet is a test that reports a correct kernel as broken. The residual is reported, and if + // it were structural (a wrong pairing, a wrong table entry) it would be O(1), not O(1 ULP). + long long rot_bad = 0; + double worst = 0.0; + long long first_bad = -1; + // The denominator is the ROW'S INPUT MAGNITUDE, not the element's own value. *c - b*s cancels, so an + // element whose result is near zero reports a large relative error for a 1-ULP absolute one - which is the + // same metric mistake round 169 found on Q4_K's dot products, where the fix was to measure against + // sum|term| instead of the result. Here the natural scale of the computation is the largest input in the + // row, because every output element is a combination of two inputs with |cos|,|sin| <= 1. + std::vector row_scale((size_t) rows, 0.0); + for (int r = 0; r < rows; ++r) { + double m = 0; + for (int d = 0; d < head_dim; ++d) m = std::max(m, (double) std::fabs(x[(size_t) r * head_dim + d])); + row_scale[(size_t) r] = m > 1e-30 ? m : 1e-30; + } + for (size_t i = 0; i < ref.size(); ++i) { + const double a = ref[i], b = got[i]; + const double rel = std::fabs(a - b) / row_scale[i / (size_t) head_dim]; + if (!(rel <= worst)) worst = rel; + if (!(rel <= 1e-6)) { if (first_bad < 0) first_bad = (long long) i; ++rot_bad; } + } + std::printf(" rope rotation vs host reference %s (%lld of %zu over 1e-6, worst rel %.3e)\n", + rot_bad ? "*** WRONG ***" : "agrees", rot_bad, ref.size(), worst); + if (first_bad >= 0) std::printf(" first over-tolerance at element %lld\n", first_bad); + bad += (int) rot_bad; + + // ---- 3. THE PAIRING, asserted structurally rather than as a tolerance. NEOX pairs (i, i+half); the + // adjacent-pair convention would pair (2i, 2i+1). Feed an input that is 1 at dim 0 and 0 elsewhere, and + // check WHICH dims move: under NEOX only dims 0 and half may change, under the adjacent convention only + // dims 0 and 1 may. A tolerance over all 256 dims cannot tell the two apart. + { + std::vector e((size_t) head_dim, 0.0f); + e[0] = 1.0f; + std::vector o((size_t) head_dim, 0.0f); + check(cudaMemcpy(d_x, e.data(), e.size() * sizeof(float), cudaMemcpyHostToDevice), "copy e"); + // position 7, NOT position 0: at pos 0 sin is exactly 0, so dim half legitimately does not move and + // the check would report the correct kernel as wrong. The first version made exactly that mistake. + int p7 = 7; + check(cudaMemcpy(d_pos, &p7, sizeof(int), cudaMemcpyHostToDevice), "copy p7"); + strata::kernels::rope_neox_apply(d_x, d_out, 1, head_dim, n_rot, d_cos, d_sin, d_pos, nullptr); + check(cudaMemcpy(o.data(), d_out, o.size() * sizeof(float), cudaMemcpyDeviceToHost), "back e"); + int moved[4] = {0, 0, 0, 0}; // dims 0, 1, half, half+1 + const float* chk[4] = {&e[0], &e[1], &e[half], &e[half + 1]}; + (void) chk; + moved[0] = (o[0] != 0.0f || o[half] != 0.0f) ? 1 : 0; + const bool adjacent_moved = (o[1] != 0.0f); + const bool neox_moved = (o[half] != 0.0f); + std::printf(" pairing: dim 0 set -> NEOX moves dim %d (o[half]=%.6f), adjacent would move dim 1 " + "(o[1]=%.6f) %s\n", half, (double) o[half], (double) o[1], + (neox_moved && !adjacent_moved) ? "NEOX confirmed" : "*** WRONG CONVENTION ***"); + if (!(neox_moved && !adjacent_moved)) ++bad; + } + + std::printf("\nrope: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("rope_parity OK\n"); + return 0; +} diff --git a/src/kernels/router_top10_parity.cpp b/src/kernels/router_top10_parity.cpp new file mode 100644 index 00000000..ac7e652e --- /dev/null +++ b/src/kernels/router_top10_parity.cpp @@ -0,0 +1,167 @@ +// src/kernels/router_top10_parity.cpp - P2.S2's parity test for the MoE router. +// +// The reference here is a HOST implementation of `ref/moe.py::router` written from the same specification the +// kernel was written from - so this checks the kernel against the spec independently of the kernel, which is +// the strongest thing available without running llama.cpp. It is NOT a check against llama.cpp itself, and +// that gap is stated rather than implied. +// +// WHAT THE TEST CAN AND CANNOT SEE. It compares expert IDs exactly and weights to a tolerance, on several +// logit distributions including ties (where the "smallest index wins" rule is the only thing that decides) and +// near-ties (where a float difference decides). It also asserts that the 2**-14 CLAMP CANNOT TRIGGER for this +// model's geometry - see below - so the absence of a clamp test is a proven fact rather than an omission. +#include "strata/kernels/router_top10.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +constexpr double RENORM_CLAMP = 6.103515625e-05; // 2**-14 + +// The host reference: softmax over all experts, stable descending argsort, gather, clamp, renormalise. +void reference(const float* logits, int n_expert, int k, int* ids, float* w) { + double mx = logits[0]; + for (int e = 1; e < n_expert; ++e) mx = std::max(mx, (double) logits[e]); + double sum = 0; + std::vector p((size_t) n_expert); + for (int e = 0; e < n_expert; ++e) { + p[(size_t) e] = std::exp((double) logits[e] - mx); + sum += p[(size_t) e]; + } + for (int e = 0; e < n_expert; ++e) p[(size_t) e] /= sum; + + // stable descending: std::stable_sort with a strict greater-than keeps equal elements in index order + std::vector idx((size_t) n_expert); + std::iota(idx.begin(), idx.end(), 0); + std::stable_sort(idx.begin(), idx.end(), + [&](int a, int b) { return p[(size_t) a] > p[(size_t) b]; }); + double s = 0; + for (int i = 0; i < k; ++i) { + ids[i] = idx[(size_t) i]; + w[i] = (float) p[(size_t) idx[(size_t) i]]; + s += p[(size_t) idx[(size_t) i]]; + } + s = std::max(s, RENORM_CLAMP); + for (int i = 0; i < k; ++i) w[i] = (float) ((double) w[i] / s); +} + +int run_case(const char* name, const std::vector& logits, int n_tokens, int n_expert, int k, + double tol) { + std::vector h_ids((size_t) n_tokens * k); + std::vector h_w((size_t) n_tokens * k); + std::vector r_ids((size_t) n_tokens * k); + std::vector r_w((size_t) n_tokens * k); + for (int t = 0; t < n_tokens; ++t) { + reference(&logits[(size_t) t * n_expert], n_expert, k, &r_ids[(size_t) t * k], &r_w[(size_t) t * k]); + } + + float* d_l = nullptr; + int* d_ids = nullptr; + float* d_w = nullptr; + check(cudaMalloc(&d_l, logits.size() * sizeof(float)), "malloc logits"); + check(cudaMalloc(&d_ids, h_ids.size() * sizeof(int)), "malloc ids"); + check(cudaMalloc(&d_w, h_w.size() * sizeof(float)), "malloc w"); + check(cudaMemcpy(d_l, logits.data(), logits.size() * sizeof(float), cudaMemcpyHostToDevice), "copy"); + strata::kernels::router_top10(d_l, n_tokens, n_expert, k, d_ids, d_w, nullptr); + check(cudaMemcpy(h_ids.data(), d_ids, h_ids.size() * sizeof(int), cudaMemcpyDeviceToHost), "back ids"); + check(cudaMemcpy(h_w.data(), d_w, h_w.size() * sizeof(float), cudaMemcpyDeviceToHost), "back w"); + + long long id_bad = 0, w_bad = 0; + double worst = 0; + for (size_t i = 0; i < h_ids.size(); ++i) { + if (h_ids[i] != r_ids[i]) ++id_bad; + const double rel = std::fabs((double) h_w[i] - (double) r_w[i]) / + (std::fabs((double) r_w[i]) > 1e-30 ? std::fabs((double) r_w[i]) : 1e-30); + worst = std::max(worst, rel); + if (!(rel <= tol)) ++w_bad; + } + // every token's weights must sum to 1 unless the clamp replaced the sum + double worst_sum = 0; + for (int t = 0; t < n_tokens; ++t) { + double s = 0; + for (int i = 0; i < k; ++i) s += (double) h_w[(size_t) t * k + i]; + double raw = 0; + for (int i = 0; i < k; ++i) raw += (double) r_w[(size_t) t * k + i]; + // both sides divide by the same clamped scale, so the ratios agree; check the kernel's own sum + worst_sum = std::max(worst_sum, std::fabs(s - 1.0)); + } + std::printf(" %-26s ids %s (%lld bad) weights worst rel %.3e (%lld over tol) |sum-1| %.1e\n", name, + id_bad ? "*** WRONG ***" : "exact", id_bad, worst, w_bad, worst_sum); + cudaFree(d_l); + cudaFree(d_ids); + cudaFree(d_w); + return (int) (id_bad + w_bad); +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: router_top10_parity [--selftest]\n"); return 2; } + } + + const int NE = 512; // the real n_expert + const int K = 10; // the real top-k + const int NT = 64; + std::mt19937 rng(2024); + std::normal_distribution gauss(0.0f, 1.0f); + int bad = 0; + + // ---- random logits + std::vector a((size_t) NT * NE); + for (auto& v : a) v = gauss(rng); + bad += run_case("random normal", a, NT, NE, K, 1e-5); + + // ---- ALL-EQUAL logits: every expert ties, so the "smallest index wins" rule is the ONLY thing deciding + // the ids. An unstable sort or a >= comparison in the maximum scan fails this and passes everything else. + std::vector b((size_t) NT * NE, 0.5f); + bad += run_case("all equal (ties)", b, NT, NE, K, 1e-5); + + // ---- a few exact ties among otherwise distinct values + std::vector c((size_t) NT * NE); + for (int t = 0; t < NT; ++t) { + for (int e = 0; e < NE; ++e) c[(size_t) t * NE + e] = (e < 12) ? 2.0f : gauss(rng); + } + bad += run_case("12-way exact tie", c, NT, NE, K, 1e-5); + + // ---- one dominant expert: the top-1 must be that expert and its weight must dominate + std::vector d((size_t) NT * NE, 0.0f); + for (int t = 0; t < NT; ++t) d[(size_t) t * NE + (t % NE)] = 20.0f; + bad += run_case("dominant expert", d, NT, NE, K, 1e-5); + + // ---- THE CLAMP CANNOT TRIGGER, and that is provable rather than untested. + // The sum of the top k of a probability vector over n outcomes is at least k/n (the minimum is the uniform + // distribution). Here k/n = 10/512 = 0.01953125, which is 320x the 2**-14 = 6.1035e-05 clamp, so no input + // whatsoever can make this model's renormalisation clamp - not an extreme one, not an adversarial one. + // Asserting the bound is worth more than a test that cannot reach it. + const double min_possible = (double) K / (double) NE; + std::printf(" %-26s top-%d sum >= k/n = %.5f, clamp is %.3e -> margin %.0fx %s\n", "clamp reachability", + K, min_possible, RENORM_CLAMP, min_possible / RENORM_CLAMP, + min_possible > RENORM_CLAMP ? "(CLAMP IS UNREACHABLE, asserted)" : "*** REACHABLE ***"); + if (min_possible <= RENORM_CLAMP) { + std::fprintf(stderr, "the clamp CAN trigger, so it needs a test\n"); + ++bad; + } + + std::printf("\nrouter_top10: %d failures over 4 distributions x %d tokens x %d experts, top-%d\n", bad, NT, NE, + K); + if (bad) return 1; + if (selftest) std::printf("router_top10_parity OK\n"); + return 0; +} diff --git a/src/kernels/s2_gemv_parity.cpp b/src/kernels/s2_gemv_parity.cpp new file mode 100644 index 00000000..ab693799 --- /dev/null +++ b/src/kernels/s2_gemv_parity.cpp @@ -0,0 +1,173 @@ +// src/kernels/s2_gemv_parity.cpp - P2.S2's parity test for the S2 GEMV. +// +// The reference is the same CHAIN as the decode test: `strata::dequantize_q2_0` is a scalar transcription that +// `bench/micro/dequant_xcheck` checks against **ggml's own** dequantizer, so comparing the GPU GEMV against a +// dot product built on that scalar decode compares against ggml through one proven hop. +// +// WHY A TOLERANCE AND NOT BIT EQUALITY. The decode test is bit-exact because S2's decode is exact +// integer-to-float arithmetic. A dot product is not: the compiler is free to contract `a*b + acc` into an +// FMA, which changes the result in the last bits, and nvcc does. So this asserts a RELATIVE error, measures +// what it actually is, and prints the number - the phase spec allows 1e-3 for FP16 paths, and if the measured +// error were near that the tolerance would be hiding something rather than bounding rounding. +#include "strata/artifact/dequant.hpp" +#include "strata/kernels/s2_gemv.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int QK = 64; + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +// FP16 patterns, and they are deliberately NOT all powers of two. +// +// The first version of this table was {1.0, 0.5, 0.25, ...} - every value a power of two. The test then +// reported a worst relative error of EXACTLY zero, which is not a property of the kernel: (code-1) is in +// {-1,0,1,2} and a power-of-two times an fp16 is exact, so every product was exact and no rounding could +// occur in either implementation. A test whose arithmetic cannot round cannot exercise its own tolerance, and +// it would have hidden an accumulation-order or contraction bug for ever. +// +// The added patterns have full 11-bit mantissas, so products round and the two implementations' +// rounding/contraction behaviour actually matters. +const uint16_t kScales[] = { + 0x3C00, 0x3800, 0x3400, 0x3000, 0x2C00, 0x2800, 0x2000, // 1, 0.5, 0.25, ... powers of two + 0xBC00, 0xB800, 0xB400, 0xB000, 0xAC00, 0x1800, 0x1400, + 0x3E00, 0x3555, 0x3C01, 0x4248, 0x4123, 0x2AAA, 0x4A2B, // 1.75, 0.333.., 1.0009765625, ... + 0xBE00, 0xB555, 0xC248, 0x2AAB, 0x4A2C, 0x2AAB}; + +} // namespace + +int main(int argc, char** argv) { + long long n_in = 2560; // the real n_embd, so the row length is the engine's + long long n_out = 640; // the real expert intermediate width + double tol = 1e-5; + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--selftest") == 0) selftest = true; + else if (std::strcmp(argv[i], "--n-in") == 0 && i + 1 < argc) n_in = std::atoll(argv[++i]); + else if (std::strcmp(argv[i], "--n-out") == 0 && i + 1 < argc) n_out = std::atoll(argv[++i]); + else if (std::strcmp(argv[i], "--tol") == 0 && i + 1 < argc) tol = std::atof(argv[++i]); + else { + std::fprintf(stderr, "usage: s2_gemv_parity [--selftest] [--n-in N] [--n-out N] [--tol T]\n"); + return 2; + } + } + if (n_in % QK != 0) { std::fprintf(stderr, "--n-in must be a multiple of %d\n", QK); return 2; } + const long long nb = n_in / QK; + + std::mt19937 rng(999); + const int n_scales = (int) (sizeof(kScales) / sizeof(kScales[0])); + + // FP16 activations. Patterns are drawn from the same finite/exact table as the decode test, because a + // random 16-bit pattern can be NaN and a NaN makes the comparison fail for the wrong reason. + std::vector x((size_t) n_in); + for (long long i = 0; i < n_in; ++i) x[(size_t) i] = kScales[rng() % n_scales]; + + // S2 weight planes for an [n_in, n_out] matrix: row o occupies a contiguous run of nb blocks. + std::vector codes((size_t) n_out * nb * 16); + std::vector scales((size_t) n_out * nb); + for (size_t i = 0; i < codes.size(); ++i) codes[i] = (uint8_t) (rng() & 0xFF); + for (size_t i = 0; i < scales.size(); ++i) { + scales[i] = strata::fp16_to_fp32(kScales[rng() % n_scales]); + } + + // CPU reference: rebuild each row's RAW Q2_0 blocks from the planes, decode with the scalar path that + // dequant_xcheck proved equal to ggml, and accumulate in the same order the kernel does. + std::vector ref((size_t) n_out, 0.0f); + std::vector raw(18); + std::vector dec(QK); + for (long long o = 0; o < n_out; ++o) { + float acc = 0.0f; + for (long long b = 0; b < nb; ++b) { + const float d = scales[(size_t) (o * nb + b)]; + // write `d` back as the fp16 pattern the plane came from: the plane stores a WIDENED fp16, so + // re-narrowing is exact and the scalar decoder sees the same value the kernel does + const uint16_t d_bits = (uint16_t) [&] { + for (uint16_t c : kScales) { + if (strata::fp16_to_fp32(c) == d) return c; + } + return (uint16_t) 0x3C00; + }(); + raw[0] = (uint8_t) (d_bits & 0xFF); + raw[1] = (uint8_t) (d_bits >> 8); + std::memcpy(&raw[2], &codes[(size_t) ((o * nb + b) * 16)], 16); + strata::dequantize_q2_0(raw.data(), dec.data()); + for (int j = 0; j < QK; ++j) { + acc += dec[(size_t) j] * strata::fp16_to_fp32(x[(size_t) (b * QK + j)]); + } + } + ref[(size_t) o] = acc; + } + + uint16_t* d_x = nullptr; + uint8_t* d_codes = nullptr; + float *d_scales = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_x, x.size() * sizeof(uint16_t)), "cudaMalloc x"); + check(cudaMalloc(&d_codes, codes.size()), "cudaMalloc codes"); + check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "cudaMalloc scales"); + check(cudaMalloc(&d_y, (size_t) n_out * sizeof(float)), "cudaMalloc y"); + check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "copy x"); + check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "copy codes"); + check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice), + "copy scales"); + + strata::kernels::s2_gemv(d_x, d_codes, d_scales, d_y, n_in, n_out); + + std::vector got((size_t) n_out); + check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost), "copy back"); + + long long bad = 0, first_bad = -1; + double worst = 0.0; + for (long long o = 0; o < n_out; ++o) { + const double a = ref[(size_t) o], b = got[(size_t) o]; + const double denom = std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30; + const double rel = std::fabs(a - b) / denom; + if (!(rel <= worst)) worst = rel; + if (!(rel <= tol)) { + if (first_bad < 0) first_bad = o; + ++bad; + } + } + + // NON-VACUITY: a zero matrix and a zero activation vector agree at zero. The reference must actually vary + // and its magnitudes must be non-trivial, or the comparison cannot distinguish anything. + double lo = ref[0], hi = ref[0]; + for (float v : ref) { + lo = std::fmin(lo, v); + hi = std::fmax(hi, v); + } + const double spread = hi - lo; + std::printf("s2_gemv: n_in %lld, n_out %lld, %lld rows over tolerance, worst rel %.3e (tol %.1e)\n", n_in, + n_out, bad, worst, tol); + std::printf(" reference spread [%.4g, %.4g]\n", lo, hi); + if (spread <= 0.0) { + std::fprintf(stderr, "VACUOUS: the reference is constant, so agreement proves nothing\n"); + return 1; + } + if (bad) { + std::printf(" first bad row %lld: ref %.9g got %.9g\n", first_bad, (double) ref[(size_t) first_bad], + (double) got[(size_t) first_bad]); + return 1; + } + std::printf(" agrees with the scalar decode that dequant_xcheck proved equal to ggml\n"); + if (selftest) std::printf("s2_gemv_parity OK\n"); + + cudaFree(d_x); + cudaFree(d_codes); + cudaFree(d_scales); + cudaFree(d_y); + return 0; +} diff --git a/src/kernels/s2_gemv_q8_parity.cpp b/src/kernels/s2_gemv_q8_parity.cpp new file mode 100644 index 00000000..833e751a --- /dev/null +++ b/src/kernels/s2_gemv_q8_parity.cpp @@ -0,0 +1,181 @@ +// src/kernels/s2_gemv_q8_parity.cpp - the Q8_0-activation GEMV, and the size of the gap it closes. +// +// THREE CHECKS: +// 1. The quantized-activation GEMV matches a host reference that quantizes the activation exactly as +// `ref/quant.py::q8_0` does and then dots it against the same weights. +// 2. **The gap is REAL and MEASURABLE in this project's own kernels**: the same weights driven by an FP16 +// activation and by a Q8_0 activation must DIFFER, by roughly the activation contract's ~1%. Without this +// the first check would pass on a kernel that quietly used fp16 anyway, and the round-186 gap would look +// fixed without being fixed. +// 3. The measured size of that difference is reported, so the number in the state file is one this test +// produces rather than one quoted from elsewhere. +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/s_gemv.hpp" +#include "strata/kernels/s2_gemv_q8.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: s2_gemv_q8_parity [--selftest]\n"); return 2; } + } + + const long long n_in = 2560, n_out = 64; + const int tpr = 32; + std::mt19937 rng(31); + std::normal_distribution gauss(0.0f, 1.0f); + + std::vector x((size_t) n_in); + for (auto& v : x) v = gauss(rng); + std::vector codes((size_t) n_out * n_in / 4); + for (auto& v : codes) v = (uint8_t) (rng() & 0xFF); + std::vector scales((size_t) n_out * (n_in / 64)); + for (auto& v : scales) v = 0.001f + 0.0001f * (float) (rng() % 100); + + // ---- device buffers + float* d_x = nullptr; + uint8_t* d_act = nullptr; + uint8_t* d_codes = nullptr; + float *d_scales = nullptr, *d_y_fp16 = nullptr, *d_y_q8 = nullptr; + check(cudaMalloc(&d_x, (size_t) n_in * sizeof(float)), "m x"); + check(cudaMalloc(&d_act, (size_t) (n_in / 32) * 34), "m act"); + check(cudaMalloc(&d_codes, codes.size()), "m codes"); + check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "m scales"); + check(cudaMalloc(&d_y_fp16, (size_t) n_out * sizeof(float)), "m y1"); + check(cudaMalloc(&d_y_q8, (size_t) n_out * sizeof(float)), "m y2"); + check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(float), cudaMemcpyHostToDevice), "c x"); + check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "c codes"); + check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice), "c scales"); + + strata::kernels::quantize_q8_0(d_x, d_act, n_in, nullptr); + + // ---- host reference: quantize the activation with the project's own rule, then dot + std::vector h_act((size_t) (n_in / 32) * 34); + check(cudaMemcpy(h_act.data(), d_act, h_act.size(), cudaMemcpyDeviceToHost), "c act back"); + auto act_val = [&](long long i) { + const uint8_t* blk = &h_act[(size_t) (i / 32) * 34]; + const uint16_t dbits = (uint16_t) (blk[0] | (blk[1] << 8)); + // fp16 -> f32 without a CUDA header: 0x3C00 = 1.0, and the block scales here are normal + float d; + const uint32_t sign = (uint32_t) (dbits & 0x8000) << 16; + uint32_t ex = (dbits >> 10) & 0x1F, man = dbits & 0x3FF; + uint32_t out; + if (ex == 0) { out = sign; } + else { out = sign | ((ex - 15 + 127) << 23) | (man << 13); } + std::memcpy(&d, &out, 4); + return (float) ((int8_t) blk[2 + (i % 32)]) * d; + }; + std::vector ref((size_t) n_out, 0.0f); + for (long long o = 0; o < n_out; ++o) { + float acc = 0.0f; + for (long long g = 0; g < n_in / 64; ++g) { + const float d = scales[(size_t) (o * (n_in / 64) + g)]; + for (int j = 0; j < 64; ++j) { + const long long i = g * 64 + j; + const uint8_t byte = codes[(size_t) (o * (n_in / 4) + i / 4)]; + const int code = (byte >> ((i % 4) * 2)) & 3; + acc += (float) (code - 1) * d * act_val(i); + } + } + ref[(size_t) o] = acc; + } + strata::kernels::s2_gemv_q8(d_act, d_codes, d_scales, d_y_q8, n_in, n_out, tpr, nullptr); + std::vector got((size_t) n_out); + check(cudaMemcpy(got.data(), d_y_q8, got.size() * sizeof(float), cudaMemcpyDeviceToHost), "c y2"); + + int bad = 0; + double worst = 0.0, sum_abs = 0.0; + for (long long o = 0; o < n_out; ++o) { + const double a = ref[(size_t) o], b = got[(size_t) o]; + sum_abs += std::fabs(a); + // relative to the row magnitude is not needed here - these rows are not cancelling - but the metric is + // stated because round 169 and round 189 both showed the wrong denominator reads as a wrong kernel + const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30); + if (!(rel <= worst)) worst = rel; + // 1e-4, not 1e-5: the measured worst is 1.479e-05, which is FMA contraction over 2560 terms rather + // than a structural error, and a threshold that passes 62 of 64 by luck is not a threshold. The phase + // allows 1e-3 for FP16 paths, so this is ten times tighter than the spec and ten times looser than the + // measurement. + if (!(rel <= 1e-4)) ++bad; + } + std::printf(" %-38s %s (%d of %lld over 1e-4, worst rel %.3e, mean |ref| %.3f)\n", + "q8-activation GEMV vs host reference", bad ? "*** WRONG ***" : "matches", bad, n_out, worst, + sum_abs / (double) n_out); + + // ---- CHECK 2: the gap is real. The SAME weights driven by an fp16 activation must differ. + // The fp16 path wants fp16 activations, and `x` is f32, so it is converted exactly (the values are normal + // and small), which keeps the comparison about the ACTIVATION FORMAT and nothing else. + // A REAL fp32 -> fp16 conversion. The first version wrote `0x3C00 + (int)(x*64)`, which adds to the raw + // BITS and therefore changes the EXPONENT - x = 2.0 became 0x3C80 = 1.25 - so the two paths were driven by + // unrelated activations and the comparison reported 2770%. The number was a symptom of the fixture, and + // the fixture is what the check exists to be able to trust. + auto f32_to_f16 = [](float f) -> uint16_t { + uint32_t b; + std::memcpy(&b, &f, 4); + const uint32_t sign = (b >> 16) & 0x8000u; + int exp = (int) ((b >> 23) & 0xFF) - 127 + 15; + uint32_t man = b & 0x7FFFFFu; + if (exp >= 31) return (uint16_t) (sign | 0x7C00u | (man ? 0x200u : 0u)); + if (exp <= 0) { + if (exp < -10) return (uint16_t) sign; + man |= 0x800000u; + const uint32_t sh = (uint32_t) (14 - exp); + uint32_t h = (man >> sh) & 0x3FFu; + const uint32_t rem = man & ((1u << sh) - 1u); + if (rem > (1u << (sh - 1)) || (rem == (1u << (sh - 1)) && (h & 1u))) ++h; + return (uint16_t) (sign | h); + } + uint16_t h = (uint16_t) (sign | ((uint32_t) exp << 10) | (man >> 13)); + const uint32_t rem = man & 0x1FFFu; + if (rem > 0x1000u || (rem == 0x1000u && (h & 1u))) ++h; + return h; + }; + std::vector hx((size_t) n_in); + for (long long i = 0; i < n_in; ++i) hx[(size_t) i] = f32_to_f16(x[(size_t) i]); + uint16_t* d_hx = nullptr; + check(cudaMalloc(&d_hx, hx.size() * sizeof(uint16_t)), "m hx"); + check(cudaMemcpy(d_hx, hx.data(), hx.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "c hx"); + strata::kernels::SForm form{2, -1, 64, strata::kernels::Codebook::Affine, false}; + strata::kernels::s_gemv_split(d_hx, d_codes, d_scales, nullptr, d_y_fp16, n_in, n_out, form, tpr); + std::vector y16((size_t) n_out); + check(cudaMemcpy(y16.data(), d_y_fp16, y16.size() * sizeof(float), cudaMemcpyDeviceToHost), "c y1"); + + double diff = 0.0; + for (long long o = 0; o < n_out; ++o) { + diff += std::fabs((double) y16[(size_t) o] - (double) got[(size_t) o]); + } + const double rel_gap = diff / (sum_abs > 1e-30 ? sum_abs : 1e-30); + std::printf(" %-38s %.4f%% of the reference magnitude\n", "fp16-activation vs q8-activation", rel_gap * 100); + if (rel_gap < 1e-4) { + std::fprintf(stderr, + "the two activation paths agree to %.2e, so this test CANNOT see the difference it exists " + "to measure - either the q8 path silently used fp16, or the fixture is degenerate\n", + rel_gap); + ++bad; + } + + std::printf("\ns2_gemv_q8: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("s2_gemv_q8_parity OK\n"); + return 0; +} diff --git a/src/kernels/s_gemv_parity.cpp b/src/kernels/s_gemv_parity.cpp new file mode 100644 index 00000000..5c0bed53 --- /dev/null +++ b/src/kernels/s_gemv_parity.cpp @@ -0,0 +1,634 @@ +// src/kernels/s_gemv_parity.cpp - P2.S2's parity test for the S-family GEMV. +// +// Four source types, chosen to cover every branch the kernel has: +// +// S2 Q2_0 2-bit, bias -1, group 64 affine +// S4 Q4_0 4-bit, bias -8, group 32 affine +// S4 IQ4_NL 4-bit, bias 0, group 32 CODEBOOK (non-linear, no bias can express it) +// S8 Q8_0 8-bit, bias -128, group 32 affine +// +// That is all three template instantiations, both codebooks, and four different biases. The offset branch +// (`has_offset`, only Q4_K and Q5_K) is NOT exercised here and the test prints that fact rather than leaving a +// green result to imply otherwise - Q4_K's canonicalisation needs the packed 6-bit scale/min pairs, and the +// honest move is to say so than to half-build it. +// +// THE REFERENCE IS THE CHAIN: for each type the CPU side reconstructs the RAW GGUF block from the canonical +// planes and decodes it with that type's scalar dequantizer from include/strata/artifact/dequant.hpp - the +// ones `bench/micro/dequant_xcheck` checks against **ggml's own** dequantizers. Reconstructing raw from +// canonical is the inverse of what the packer does, and it is written here independently of the packer so the +// two are not the same code. +#include "strata/artifact/dequant.hpp" +#include "strata/kernels/s_gemv.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +// Finite, exactly representable fp16 patterns WITH full mantissas. Not powers of two: with only powers of +// two every product is exact and the comparison cannot round, which is how the first version of s2_gemv's test +// managed a worst error of exactly zero without proving anything. +const uint16_t kScales[] = {0x3E00, 0x3555, 0x3C01, 0x4248, 0x4123, 0x2AAA, 0x4A2B, 0x3800, + 0xBE00, 0xB555, 0xC248, 0x2AAB, 0x4A2C, 0x2AAB, 0xB800}; + +struct Case { + const char* name; + strata::kernels::SForm form; + int raw_bytes; // one block + // Reconstruct the raw GGUF block for one group from the canonical codes/scales, and return the number of + // elements the block decodes to (which may exceed group_elems: Q8_0's block IS one group, Q2_0's too). + int (*to_raw)(const uint8_t* codes, size_t code_off, const uint16_t* scale_bits, size_t scale_off, uint8_t* raw); + double (*dequant)(const uint8_t* raw, float* out); +}; + +// ---- Q2_0: raw = { fp16 d ; uint8 qs[16] }, 64 elements, code i at byte i/4 bits (i%4)*2 - identical to the +// canonical plane, so this is a straight copy plus the scale. +int q2_0_to_raw(const uint8_t* codes, size_t co, const uint16_t* scale_bits, size_t so, uint8_t* raw) { + const uint16_t d = scale_bits[so]; + raw[0] = (uint8_t) (d & 0xFF); + raw[1] = (uint8_t) (d >> 8); + // `codes` is one code PER ELEMENT (64 of them); a Q2_0 block stores them PACKED, 4 per byte, element i at + // byte i/4 bits (i%4)*2. The two coincide as a byte layout only if you confuse them, which is exactly the + // mistake the first version of this function made by copying 16 bytes verbatim. + for (int b = 0; b < 16; ++b) { + raw[2 + b] = (uint8_t) ((codes[co + 4 * b + 0] & 3) | ((codes[co + 4 * b + 1] & 3) << 2) | + ((codes[co + 4 * b + 2] & 3) << 4) | ((codes[co + 4 * b + 3] & 3) << 6)); + } + return 64; +} +double q2_0_deq(const uint8_t* raw, float* out) { + strata::dequantize_q2_0(raw, out); + return 64; +} + +// ---- Q4_0: raw = { fp16 d ; uint8 qs[16] }, 32 elements; element j is the LOW nibble of byte j and element +// j+16 the HIGH nibble. The canonical plane stores one code per element, so this re-packs them. +int q4_0_to_raw(const uint8_t* codes, size_t co, const uint16_t* scale_bits, size_t so, uint8_t* raw) { + const uint16_t d = scale_bits[so]; + raw[0] = (uint8_t) (d & 0xFF); + raw[1] = (uint8_t) (d >> 8); + for (int j = 0; j < 16; ++j) raw[2 + j] = (uint8_t) ((codes[co + j] & 0x0F) | (codes[co + 16 + j] << 4)); + return 32; +} +double q4_0_deq(const uint8_t* raw, float* out) { + strata::dequantize_q4_0(raw, out); + return 32; +} + +// ---- IQ4_NL: identical packing to Q4_0, different decode (a table, not a bias). +int iq4_to_raw(const uint8_t* codes, size_t co, const uint16_t* scale_bits, size_t so, uint8_t* raw) { + return q4_0_to_raw(codes, co, scale_bits, so, raw); +} +double iq4_deq(const uint8_t* raw, float* out) { + strata::dequantize_iq4_nl(raw, out); + return 32; +} + +// ---- Q8_0: raw = { fp16 d ; int8 qs[32] }, 32 elements. The canonical code plane is UNSIGNED with bias +// -128 (round 154's fix), so the raw int8 byte is exactly the canonical code reinterpreted - a straight copy, +// which is why this type is the cheapest one to check and the one whose sign bug was found by the pack. +int q8_0_to_raw(const uint8_t* codes, size_t co, const uint16_t* scale_bits, size_t so, uint8_t* raw) { + const uint16_t d = scale_bits[so]; + raw[0] = (uint8_t) (d & 0xFF); + raw[1] = (uint8_t) (d >> 8); + // NOT a straight copy. The canonical code plane is UNSIGNED with bias -128, so canonical code c means the + // value (c - 128); the raw block stores that as an `int8_t`, whose reinterpretation as an unsigned byte is + // `c ^ 0x80` - a SIGN-BIT FLIP, not the identity. Assuming identity is the same family of mistake as the + // round-154 Q8_0 bug that the pack found, and it is why this reconstruction is written out rather than + // memcpy'd. + for (int j = 0; j < 32; ++j) raw[2 + j] = (uint8_t) (codes[co + j] ^ 0x80); + return 32; +} +double q8_0_deq(const uint8_t* raw, float* out) { + strata::dequantize_q8_0(raw, out); + return 32; +} + +// ---- S4/Q4_K: the ONLY canonical form with an offset, and the only one whose scale is an FP32 PRODUCT +// (`fl(d*sc)`) rather than a widened fp16. Both facts make it the case worth having. +// +// There is no `to_raw` for it in the table above because its canonical planes are DERIVED from the raw +// parameters, not the other way round: the packer computes `scale = d*sc` and `offset = -(dmin*m)`, so this +// generates d, dmin and the eight (sc, m) pairs, derives the planes exactly as the packer does, and writes a +// raw block from the SAME parameters. Inverting those products instead would be ambiguous. +void test_q4k(long long n_in, long long n_out, double tol, int* total_bad) { + using strata::kernels::Codebook; + using strata::kernels::SForm; + const SForm form{4, 0, 32, Codebook::Affine, true}; + + std::mt19937 rng(777); + const int n_scales = (int) (sizeof(kScales) / sizeof(kScales[0])); + const long long n_groups = n_in / 32; + const long long codes_per_row = n_in / 2; + + std::vector x((size_t) n_in); + for (long long i = 0; i < n_in; ++i) x[(size_t) i] = kScales[rng() % n_scales]; + + std::vector codes_el((size_t) n_out * n_in); + for (size_t i = 0; i < codes_el.size(); ++i) codes_el[i] = (uint8_t) (rng() & 0x0F); + std::vector codes((size_t) n_out * codes_per_row, 0); + for (size_t i = 0; i < codes_el.size(); ++i) { + const size_t o = i / (size_t) n_in, k = i % (size_t) n_in; + codes[o * (size_t) codes_per_row + k / 2] |= (uint8_t) (codes_el[i] << ((k % 2) * 4)); + } + std::vector scales((size_t) n_out * n_groups), offsets((size_t) n_out * n_groups); + + struct Row { + uint16_t d, dmin; + uint8_t sc[8], m[8]; + }; + std::vector rows((size_t) n_out); + for (long long o = 0; o < n_out; ++o) { + Row& r = rows[(size_t) o]; + r.d = kScales[rng() % n_scales]; + r.dmin = kScales[rng() % n_scales]; + for (int g = 0; g < 8; ++g) { + r.sc[g] = (uint8_t) (rng() & 0x3F); + r.m[g] = (uint8_t) (rng() & 0x3F); + for (long long sb = 0; sb < n_in / 256; ++sb) { + scales[(size_t) (o * n_groups + sb * 8 + g)] = strata::fp16_to_fp32(r.d) * (float) r.sc[g]; + offsets[(size_t) (o * n_groups + sb * 8 + g)] = -(strata::fp16_to_fp32(r.dmin) * (float) r.m[g]); + } + } + } + + std::vector ref((size_t) n_out, 0.0f); + std::vector cond((size_t) n_out, 0.0); + double worst_plane_ref = 0.0; // max |planes - raw block| over every element, on the CPU alone + std::vector blk(144); + std::vector dec(256); + // A Q4_K raw block covers 256 elements, so a 2560-element row is TEN superblocks, each with its own + // 144-byte block. The first version of this loop ran `step < 4` - one superblock, 256 of the row's 2560 + // elements - while the kernel accumulated all of them, and the resulting 29x relative error was the + // reference being short, not the kernel being wrong. + for (long long o = 0; o < n_out; ++o) { + const Row& r = rows[(size_t) o]; + float acc = 0.0f; + double sum_abs = 0.0; // the natural scale of this row's dot product (see the metric below) + double plane_ref_diff = 0.0; // (a) vs (b): planes against the raw block, both on the CPU + for (long long sb = 0; sb < n_in / 256; ++sb) { + // ONE 144-byte block per 256-element SUPERBLOCK, with all four steps' qs bytes filled BEFORE the + // decode. The previous version decoded a fresh block per STEP and then read all 256 elements + // from each, so three quarters of every decode came from zeroed qs - four decodes of a + // quarter-filled block per superblock, which is the whole of the 1.17e4 disagreement. + // `scripts/probe_q4k_groups.cpp` builds it this way and agrees exactly, group by group. + blk.assign(144, 0); + blk[0] = (uint8_t) (r.d & 0xFF); + blk[1] = (uint8_t) (r.d >> 8); + blk[2] = (uint8_t) (r.dmin & 0xFF); + blk[3] = (uint8_t) (r.dmin >> 8); + for (int j = 0; j < 4; ++j) { // the exact inverse of get_scale_min_k4 + blk[4 + j] = (uint8_t) ((r.sc[j] & 0x3F) | ((r.sc[j + 4] >> 4) << 6)); + blk[8 + j] = (uint8_t) ((r.m[j] & 0x3F) | ((r.m[j + 4] >> 4) << 6)); + blk[12 + j] = (uint8_t) ((r.sc[j + 4] & 0x0F) | ((r.m[j + 4] & 0x0F) << 4)); + } + for (int step = 0; step < 4; ++step) { + for (int l = 0; l < 32; ++l) { // elements 0..31 LOW nibbles, 32..63 HIGH + const long long e0 = o * n_in + sb * 256 + step * 64 + l; + const long long e1 = e0 + 32; + blk[16 + step * 32 + l] = + (uint8_t) ((codes_el[(size_t) e0] & 0x0F) | (codes_el[(size_t) e1] << 4)); + } + } + strata::dequantize_q4_K(blk.data(), dec.data()); + for (int j = 0; j < 256; ++j) { + const float term = dec[(size_t) j] * strata::fp16_to_fp32(x[(size_t) (sb * 256 + j)]); + acc += term; + sum_abs += std::fabs((double) term); // the CONDITION of this dot product + + // THE THIRD DECODE. (a) the raw block through the scalar dequantizer, (b) the canonical + // PLANES through `code*scale + offset`, (c) the GPU. If (a) != (b) then this test built + // planes that disagree with its own raw block and the kernel is innocent; if (a) == (b) and + // (c) differs, the kernel is wrong. Without (b) there is no way to tell which. + const long long elem = sb * 256 + j; + const long long g = elem / 32; + const int code = (int) codes_el[(size_t) (o * n_in + elem)]; + const float b_dec = (float) code * scales[(size_t) (o * n_groups + g)] + + offsets[(size_t) (o * n_groups + g)]; + if (std::fabs((double) (b_dec - dec[(size_t) j])) > plane_ref_diff) { + plane_ref_diff = std::fabs((double) (b_dec - dec[(size_t) j])); + } + } + } + ref[(size_t) o] = acc; + cond[(size_t) o] = sum_abs; + if (!(plane_ref_diff <= worst_plane_ref)) worst_plane_ref = plane_ref_diff; + } + + uint16_t* d_x = nullptr; + uint8_t* d_codes = nullptr; + float *d_scales = nullptr, *d_offsets = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_x, x.size() * sizeof(uint16_t)), "cudaMalloc x"); + check(cudaMalloc(&d_codes, codes.size()), "cudaMalloc codes"); + check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "cudaMalloc scales"); + check(cudaMalloc(&d_offsets, offsets.size() * sizeof(float)), "cudaMalloc offsets"); + check(cudaMalloc(&d_y, (size_t) n_out * sizeof(float)), "cudaMalloc y"); + check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "copy x"); + check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "copy codes"); + check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice), + "copy scales"); + check(cudaMemcpy(d_offsets, offsets.data(), offsets.size() * sizeof(float), cudaMemcpyHostToDevice), + "copy offsets"); + strata::kernels::s_gemv(d_x, d_codes, d_scales, d_offsets, d_y, n_in, n_out, form); + std::vector got((size_t) n_out); + check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost), "copy back"); + + // THE ERROR IS MEASURED AGAINST sum|term|, NOT AGAINST THE RESULT. A dot product of 2560 signed terms + // cancels, so `|ref-got| / |ref|` is a statement about the condition number, not about the kernel: a row + // whose terms are ~1e7 and whose result is ~1e3 reports a relative error of 50 for an absolute error of + // 1e-6 of the terms. `sum_abs` is the natural scale of the computation, and comparing to it is what + // makes a tolerance mean "agrees to within rounding" instead of "did not cancel". + long long bad = 0; + double worst = 0.0, worst_res = 0.0; + for (long long o = 0; o < n_out; ++o) { + const double a = ref[(size_t) o], b = got[(size_t) o]; + const double scale = cond[(size_t) o] > 1e-30 ? cond[(size_t) o] : 1e-30; + const double rel = std::fabs(a - b) / scale; + const double rel_res = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30); + if (!(rel <= worst)) worst = rel; + if (!(rel_res <= worst_res)) worst_res = rel_res; + if (!(rel <= tol)) ++bad; + } + double lo = ref[0], hi = ref[0]; + for (float v : ref) { + lo = std::fmin(lo, v); + hi = std::fmax(hi, v); + } + std::printf(" %-10s %lld rows, %lld over tol, worst rel-to-terms %.3e (rel-to-result %.3e)\n", + "S4/Q4_K", n_out, bad, worst, worst_res); + std::printf(" ref spread [%.4g, %.4g], condition (sum|term|) up to %.4g\n", lo, hi, + *std::max_element(cond.begin(), cond.end())); + std::printf(" CPU planes vs CPU raw block: max |diff| = %.3e -> %s\n", worst_plane_ref, + worst_plane_ref == 0.0 + ? "the test is SELF-CONSISTENT" + : "*** THE TEST DISAGREES WITH ITSELF - fix the reference before believing any GPU result ***"); + if (hi - lo <= 0.0) { + std::fprintf(stderr, "VACUOUS: S4/Q4_K reference is constant\n"); + std::exit(1); + } + *total_bad += (int) bad; + cudaFree(d_x); + cudaFree(d_codes); + cudaFree(d_scales); + cudaFree(d_offsets); + cudaFree(d_y); +} + +} // namespace + + +// ------------------------------------------------------------------ benchmark +// +// P2.S10 wants a first measurement and a ledger entry, and the objective is stated in tokens per second, so +// the useful output is not GiB/s but "milliseconds of expert matvec per token". Warm-up runs are discarded +// (the first launch pays module load and page faults) and the MINIMUM is reported alongside the mean, because +// the minimum is the one not polluted by another process on the machine. +void bench_s2_gemv(long long n_in, long long n_out, int iters, int* split_bad) { + using strata::kernels::Codebook; + using strata::kernels::SForm; + const SForm form{2, -1, 64, Codebook::Affine, false}; + const long long n_groups = n_in / 64; + const long long codes_per_row = n_in / 4; + + std::vector codes((size_t) n_out * codes_per_row, 0xA5); + std::vector scales((size_t) n_out * n_groups, 0.001f); + std::vector x((size_t) n_in, 0x3C00); // 1.0 in fp16 + + uint16_t* d_x = nullptr; + uint8_t* d_codes = nullptr; + float *d_scales = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_x, x.size() * sizeof(uint16_t)), "bench x"); + check(cudaMalloc(&d_codes, codes.size()), "bench codes"); + check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "bench scales"); + check(cudaMalloc(&d_y, (size_t) n_out * sizeof(float)), "bench y"); + check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "bench copy x"); + check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "bench copy codes"); + check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice), + "bench copy scales"); + + for (int i = 0; i < 3; ++i) strata::kernels::s_gemv(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form); + + std::vector ms; + for (int i = 0; i < iters; ++i) { + const auto t0 = std::chrono::steady_clock::now(); + strata::kernels::s_gemv(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form); + const auto t1 = std::chrono::steady_clock::now(); + ms.push_back(std::chrono::duration(t1 - t0).count()); + } + std::sort(ms.begin(), ms.end()); + double mean = 0; + for (double v : ms) mean += v; + mean /= (double) ms.size(); + + const double weights = (double) n_in * (double) n_out; + const double bytes = (double) n_out * (double) codes_per_row + (double) n_out * n_groups * 4.0; + std::printf("s2_gemv bench [%lld x %lld] %d iters\n", n_in, n_out, iters); + std::printf(" ms per gemv: min %.4f median %.4f mean %.4f\n", ms.front(), ms[ms.size() / 2], mean); + std::printf(" min -> %.1f G weights/s, %.1f GiB/s of S2 streamed\n", weights / ms.front() / 1e6, + bytes / (1024.0 * 1024 * 1024) / (ms.front() / 1000.0)); + + // The projection the objective is stated against: 48 layers x 10 experts x 3 roles, all of one token's + // experts, at the measured per-role rate. This is the VRAM-resident path only - the CPU expert path and + // the KV/attention work are separate and are NOT included. + const double roles_per_token = 48.0 * 10.0 * 3.0; + const double tok_ms = roles_per_token * ms.front(); + std::printf(" per token: %.0f roles x %.4f ms = %.1f ms -> %.1f tok/s (expert matvec only)\n", + roles_per_token, ms.front(), tok_ms, tok_ms > 0 ? 1000.0 / tok_ms : 0.0); + std::printf(" NOTE: expert matvec ONLY. No attention, no KV, no norm, no router, no sampling, and it\n" + " assumes every expert is already resident in VRAM at zero cost.\n"); + + // The NAIVE kernel's output is the reference for the split one. `s_gemv` is checked against the scalar + // dequantizer by this file's parity cases, so its result here is a validated value, and the split kernel + // sums in a different order - so this is a relative comparison, not bit equality. + strata::kernels::s_gemv(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form); + std::vector ref_naive((size_t) n_out); + check(cudaMemcpy(ref_naive.data(), d_y, ref_naive.size() * sizeof(float), cudaMemcpyDeviceToHost), + "copy naive reference"); + + // ---- the row-split variant, same planes, same process ------------------- + // Swept rather than guessed: more threads per row buys parallelism and costs a deeper reduction. + for (int tpr : {32, 64, 128, 256}) { + if (tpr > n_in) continue; + for (int i = 0; i < 3; ++i) { + strata::kernels::s_gemv_split(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form, tpr); + } + std::vector sms; + for (int i = 0; i < iters; ++i) { + const auto t0 = std::chrono::steady_clock::now(); + strata::kernels::s_gemv_split(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form, tpr); + sms.push_back(std::chrono::duration(std::chrono::steady_clock::now() - t0) + .count()); + } + std::sort(sms.begin(), sms.end()); + + // correctness of the thing being timed + std::vector got((size_t) n_out); + check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost), + "copy split result"); + long long bad = 0; + double worst = 0.0; + for (long long o = 0; o < n_out; ++o) { + const double a = ref_naive[(size_t) o], b = got[(size_t) o]; + const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30); + if (!(rel <= worst)) worst = rel; + if (!(rel <= 1e-4)) ++bad; + } + if (bad) { + std::printf(" split tpr=%-4d *** WRONG *** %lld of %lld rows differ, worst rel %.3e\n", tpr, + bad, n_out, worst); + *split_bad += (int) bad; + } + + // ---- the quad-amortised S2 kernel, timed AND checked against the naive reference ---- + // Only meaningful for S2: it assumes four elements share one code byte, which is true of 2-bit codes. + if (form.code_bits == 2 && (n_in % 64) == 0) { + for (int t2 : {32, 64, 128}) { + for (int i = 0; i < 3; ++i) { + strata::kernels::s2_gemv_quads(d_x, d_codes, d_scales, d_y, n_in, n_out, t2); + } + std::vector qms; + for (int i = 0; i < iters; ++i) { + const auto q0 = std::chrono::steady_clock::now(); + strata::kernels::s2_gemv_quads(d_x, d_codes, d_scales, d_y, n_in, n_out, t2); + qms.push_back(std::chrono::duration( + std::chrono::steady_clock::now() - q0).count()); + } + std::sort(qms.begin(), qms.end()); + check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost), + "copy quads result"); + long long qbad = 0; + double qworst = 0.0; + for (long long o = 0; o < n_out; ++o) { + const double a = ref_naive[(size_t) o], b = got[(size_t) o]; + const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30); + if (rel > qworst) qworst = rel; + if (!(rel <= 1e-4)) ++qbad; + } + if (qbad) *split_bad += (int) qbad; + std::printf(" quads tpr=%-4d min %.4f ms -> %6.1f G weights/s vs naive %.2fx %s\n", t2, + qms.front(), weights / qms.front() / 1e6, ms.front() / qms.front(), + qbad ? "*** WRONG ***" : "checked ok"); + } + } + + // ---- the two-lever kernel, both configurations, timed AND checked ---- + if (form.code_bits == 2 && (n_in % 64) == 0) { + for (int staged = 0; staged < 2; ++staged) { + for (int t3 : {32, 64, 128}) { + for (int i = 0; i < 3; ++i) { + strata::kernels::s2_gemv_fast(d_x, d_codes, d_scales, d_y, n_in, n_out, t3, staged != 0); + } + std::vector fms; + for (int i = 0; i < iters; ++i) { + const auto q0 = std::chrono::steady_clock::now(); + strata::kernels::s2_gemv_fast(d_x, d_codes, d_scales, d_y, n_in, n_out, t3, staged != 0); + fms.push_back(std::chrono::duration( + std::chrono::steady_clock::now() - q0).count()); + } + std::sort(fms.begin(), fms.end()); + check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost), + "copy fast result"); + long long fbad = 0; + for (long long o = 0; o < n_out; ++o) { + const double a = ref_naive[(size_t) o], b = got[(size_t) o]; + const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30); + if (!(rel <= 1e-4)) ++fbad; + } + if (fbad) *split_bad += (int) fbad; + std::printf(" fast %s tpr=%-4d min %.4f ms -> %6.1f G weights/s vs naive %.2fx %s\n", + staged ? "staged" : "global", t3, fms.front(), + weights / fms.front() / 1e6, ms.front() / fms.front(), + fbad ? "*** WRONG ***" : "checked ok"); + } + } + } + + const double sp = ms.front() / sms.front(); + std::printf(" split tpr=%-4d min %.4f ms -> %6.1f G weights/s speedup vs naive %.2fx\n", tpr, + sms.front(), weights / sms.front() / 1e6, sp); + } + + cudaFree(d_x); + cudaFree(d_codes); + cudaFree(d_scales); + cudaFree(d_y); +} + +int main(int argc, char** argv) { + long long n_in = 2560; + long long n_out = 128; + // 1e-4, not 1e-5: the measured worst is ~1e-5 (FMA contraction of a 2560-term f32 accumulation), so a + // 1e-5 threshold would pass by 4% and fail on a different n_in. The phase spec allows 1e-3 for FP16 + // paths; this is 10x tighter than the spec and 10x looser than the measurement. + double tol = 1e-4; + bool selftest = false; + bool bench = false; + int iters = 200; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--selftest") selftest = true; + else if (a == "--bench") bench = true; + else if (a == "--iters" && i + 1 < argc) iters = std::atoi(argv[++i]); + else if (a == "--n-in" && i + 1 < argc) n_in = std::atoll(argv[++i]); + else if (a == "--n-out" && i + 1 < argc) n_out = std::atoll(argv[++i]); + else { + std::fprintf(stderr, "usage: s_gemv_parity [--selftest] [--n-in N] [--n-out N]\n"); + return 2; + } + } + + using strata::kernels::Codebook; + using strata::kernels::SForm; + std::vector cases; + cases.push_back({"S2/Q2_0", SForm{2, -1, 64, Codebook::Affine, false}, 18, q2_0_to_raw, q2_0_deq}); + cases.push_back({"S4/Q4_0", SForm{4, -8, 32, Codebook::Affine, false}, 18, q4_0_to_raw, q4_0_deq}); + cases.push_back({"S4/IQ4_NL", SForm{4, 0, 32, Codebook::Iq4Nl, false}, 18, iq4_to_raw, iq4_deq}); + cases.push_back({"S8/Q8_0", SForm{8, -128, 32, Codebook::Affine, false}, 34, q8_0_to_raw, q8_0_deq}); + + std::mt19937 rng(4242); + const int n_scales = (int) (sizeof(kScales) / sizeof(kScales[0])); + int total_bad = 0; + + for (const Case& tc : cases) { + const int bits = tc.form.code_bits; + const int per_byte = 8 / bits; + const int G = tc.form.group_elems; + const long long n_groups = n_in / G; + const long long codes_per_row = n_in / per_byte; + + // activations + std::vector x((size_t) n_in); + for (long long i = 0; i < n_in; ++i) x[(size_t) i] = kScales[rng() % n_scales]; + + // Codes are generated ONE PER ELEMENT and then packed into the plane the kernel reads. + // + // The first version of this test generated a random BYTE ARRAY and used it as both, which is the bug + // that made S2 pass and the other three fail by 50x-500x. The two are not the same thing: the kernel + // reads element i from byte i/per_byte at bit (i%per_byte)*bits, while the reference needs one code per + // element to rebuild a raw GGUF block. For S2 (per_byte = 4, which IS Q2_0's own raw layout) treating + // one random array as both happened to agree - and that coincidence is exactly what a test must not + // depend on. + std::vector codes_el((size_t) n_out * n_in); + for (size_t i = 0; i < codes_el.size(); ++i) codes_el[i] = (uint8_t) (rng() & ((1u << bits) - 1)); + std::vector codes((size_t) n_out * codes_per_row, 0); + for (long long o = 0; o < n_out; ++o) { + for (long long i = 0; i < n_in; ++i) { + const long long byi = o * codes_per_row + i / per_byte; + codes[(size_t) byi] |= (uint8_t) (codes_el[(size_t) (o * n_in + i)] + << ((i % per_byte) * bits)); + } + } + std::vector scales((size_t) n_out * n_groups); + std::vector scale_bits((size_t) n_out * n_groups); + for (size_t i = 0; i < scales.size(); ++i) { + scale_bits[i] = kScales[rng() % n_scales]; + scales[i] = strata::fp16_to_fp32(scale_bits[i]); + } + + // CPU reference + std::vector ref((size_t) n_out, 0.0f); + std::vector raw((size_t) tc.raw_bytes); + std::vector dec((size_t) G); + for (long long o = 0; o < n_out; ++o) { + float acc = 0.0f; + for (long long g = 0; g < n_groups; ++g) { + const size_t co = (size_t) o * n_in + (size_t) g * G; // the UNPACKED code array + const size_t so = (size_t) o * n_groups + (size_t) g; + // The raw block carries the scale as fp16 and the canonical plane carries it widened, so the + // block is rebuilt from the PATTERN the plane came from. No float-to-fp16 conversion is + // needed anywhere, and the decoder therefore sees bit-identically what the kernel reads. + const int n = tc.to_raw(codes_el.data(), co, scale_bits.data(), so, raw.data()); + tc.dequant(raw.data(), dec.data()); + for (long long j = 0; j < (long long) n; ++j) { + acc += dec[(size_t) j] * strata::fp16_to_fp32(x[(size_t) (g * G + j)]); + } + } + ref[(size_t) o] = acc; + } + + // GPU + uint16_t* d_x = nullptr; + uint8_t* d_codes = nullptr; + float *d_scales = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_x, x.size() * sizeof(uint16_t)), "cudaMalloc x"); + check(cudaMalloc(&d_codes, codes.size()), "cudaMalloc codes"); + check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "cudaMalloc scales"); + check(cudaMalloc(&d_y, (size_t) n_out * sizeof(float)), "cudaMalloc y"); + check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "copy x"); + check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "copy codes"); + check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice), + "copy scales"); + strata::kernels::s_gemv(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, tc.form); + std::vector got((size_t) n_out); + check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost), "copy back"); + + long long bad = 0; + double worst = 0.0; + for (long long o = 0; o < n_out; ++o) { + const double a = ref[(size_t) o], b = got[(size_t) o]; + const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30); + if (!(rel <= worst)) worst = rel; + if (!(rel <= tol)) ++bad; + } + double lo = ref[0], hi = ref[0]; + for (float v : ref) { + lo = std::fmin(lo, v); + hi = std::fmax(hi, v); + } + std::printf(" %-10s %lld rows, %lld over tol, worst rel %.3e ref spread [%.4g, %.4g]\n", tc.name, + n_out, bad, worst, lo, hi); + if (hi - lo <= 0.0) { + std::fprintf(stderr, "VACUOUS: %s reference is constant\n", tc.name); + return 1; + } + total_bad += (int) bad; + + cudaFree(d_x); + cudaFree(d_codes); + cudaFree(d_scales); + cudaFree(d_y); + } + + std::printf("s_gemv: %zu type cases, %d rows over tolerance (tol %.1e)\n", cases.size(), total_bad, tol); + // Q4_K IS NOT PART OF THE DEFAULT RUN. It FAILS: 128 of 128 rows, worst error 0.586 of sum|term| - a + // real difference, not cancellation (the condition number is 2.8e7 and the error is 1.6e7). Either the + // kernel's offset handling or this test's reconstruction of a Q4_K block is wrong and the two have not + // been separated yet. Running it by default would turn ctest red and hide regressions in the four cases + // that ARE verified; leaving it out silently would be worse. It is one flag away, and it is recorded as + // OPEN in the project state. + // Q4_K is IN the default run as of round 171. Rounds 169-170 gated it because it FAILED, and the + // failure was this test's own reconstruction of a Q4_K block - four decodes of a quarter-filled block per + // superblock - not the kernel. It now agrees to 2.8e-07 of sum|term|, so the gate goes rather than + // staying as a habit: a gate that outlives its reason is a check that quietly stopped running. + test_q4k(n_in, n_out, tol, &total_bad); + std::printf("s_gemv total: %d rows over tolerance\n", total_bad); + if (bench) { + // the real expert shapes: gate/up are [2560 x 640], down is [640 x 2560] + int split_bad = 0; + bench_s2_gemv(2560, 640, iters, &split_bad); + bench_s2_gemv(640, 2560, iters, &split_bad); + std::printf("split kernel rows disagreeing with the naive reference: %d\n", split_bad); + // accumulated HERE, inside the block that declares it, and it makes the binary FAIL: a kernel that is + // benchmarked but never compared is an unverified kernel being quoted for performance. + total_bad += split_bad; + } + if (total_bad) return 1; + if (selftest) std::printf("s_gemv_parity OK\n"); + return 0; +} diff --git a/src/kernels/s_gemv_q8k_parity.cpp b/src/kernels/s_gemv_q8k_parity.cpp new file mode 100644 index 00000000..882122b0 --- /dev/null +++ b/src/kernels/s_gemv_q8k_parity.cpp @@ -0,0 +1,236 @@ +// src/kernels/s_gemv_q8k_parity.cpp - the Q8_K activation path of `s_gemv`. +// +// THE POINT OF THIS KERNEL IS A CONTRACT, so the test is built around the contract rather than around the +// arithmetic: +// +// 1. the kernel against a host reference that dequantizes the SAME Q8_K bytes and the SAME S-form planes, +// in double - so what is under test is the indexing, not the format; +// 2. the Q8_K activation is produced by the ACTUAL `quantize_q8_K`, which is byte-exact against ggml +// (round 198). Building the activation by hand here would make this a test of two transcriptions; +// 3. **THE Q8_K-vs-FP16 GAP IS MEASURED**, because that is the number the whole activation-contract +// decision rests on and it has only ever been measured on Q2_0 weights (0.6175%) and on BF16 ones +// (0.141-0.156%). A K-quant weight is a third case and it is the one the dense projections use. +#include "strata/kernels/f16_bits.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/s_gemv.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +using strata::kernels::SForm; +using strata::kernels::Codebook; + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +/// The host decode, transcribed from `docs/pack-format.md`'s rule: `value = cb[code] * scale + offset`, with +/// the integer bias applied INSIDE the integer domain before the one multiply. +const signed char kIq4Nl[16] = {-127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113}; + +double weight_at(const std::vector& codes, const std::vector& scales, + const std::vector& offset, const SForm& f, long long n_in, long long o, long long i) { + const int per_byte = 8 / f.code_bits; + const long long codes_per_row = n_in / per_byte; + const long long n_groups = n_in / f.group_elems; + const uint8_t* c = codes.data() + o * codes_per_row; + const int code = (c[i / per_byte] >> ((int) (i % per_byte) * f.code_bits)) & ((1 << f.code_bits) - 1); + const double v = (f.codebook == Codebook::Iq4Nl) ? (double) kIq4Nl[code & 0x0F] + : (double) (code + f.code_bias); + const long long g = i / f.group_elems; + double w = v * (double) scales[(size_t) (o * n_groups + g)]; + if (f.has_offset) w += (double) offset[(size_t) (o * n_groups + g)]; + return w; +} + +/// Dequantize a block_q8_K buffer on the host: 292 bytes per 256 elements. +double q8k_at(const std::vector& x, long long i) { + const uint8_t* blk = x.data() + (size_t) (i / 256) * 292; + float d; + std::memcpy(&d, blk, 4); + const int8_t q = ((const int8_t*) (blk + 4))[i % 256]; + return (double) d * (double) q; +} + +struct Case { + const char* what; + int bits; + int bias; + int group; + Codebook cb; + bool has_offset; +}; + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: s_gemv_q8k_parity [--selftest]\n"); return 2; } + } + int bad = 0; + + // The forms the DENSE weights actually use, taken from the manifest rather than invented: + // attn_qkv.weight IQ4_XS S4 group 32, no offset (the GDN mixer's qkv) + // attn_gate.weight Q4_K S4 group 32, offset (the GDN mixer's z) + // ssm_out.weight Q5_K S8 group 32, offset (the GDN mixer's output) + const Case cases[] = { + {"IQ4_XS S4 g32 no-offset", 4, -8, 32, Codebook::Affine, false}, + {"Q4_K S4 g32 offset", 4, 0, 32, Codebook::Affine, true}, + {"Q5_K S8 g32 offset", 8, 0, 32, Codebook::Affine, true}, + }; + + for (const Case& cs : cases) { + const long long n_in = 512, n_out = 64; // 2 Q8_K blocks wide, small enough for a host reference + std::mt19937 rng(2024); + std::normal_distribution g(0.0f, 1.0f); + std::vector xf((size_t) n_in); + for (auto& v : xf) v = g(rng); + + SForm f; + f.code_bits = cs.bits; + f.code_bias = cs.bias; + f.group_elems = cs.group; + f.codebook = cs.cb; + f.has_offset = cs.has_offset; + + const int per_byte = 8 / cs.bits; + std::vector codes((size_t) (n_out * n_in / per_byte)); + for (auto& v : codes) v = (uint8_t) (rng() & 0xFF); + const long long n_groups = n_in / cs.group; + std::vector scales((size_t) (n_out * n_groups)), offs((size_t) (n_out * n_groups)); + for (auto& v : scales) v = 0.01f + 0.001f * (float) (rng() % 20); + for (auto& v : offs) v = 0.2f * g(rng); + + // ---- the activation, from the REAL quantizer + std::vector xq8k((size_t) (n_in / 256) * 292); + float* d_xf = nullptr; + uint8_t* d_xq = nullptr; + check(cudaMalloc(&d_xf, (size_t) n_in * 4), "xf"); + check(cudaMalloc(&d_xq, xq8k.size()), "xq"); + check(cudaMemcpy(d_xf, xf.data(), (size_t) n_in * 4, cudaMemcpyHostToDevice), "cxf"); + strata::kernels::quantize_q8_K(d_xf, d_xq, n_in, nullptr); + check(cudaMemcpy(xq8k.data(), d_xq, xq8k.size(), cudaMemcpyDeviceToHost), "cxq"); + + // ---- host reference in double, over the same bytes + std::vector want((size_t) n_out, 0.0f); + for (long long o = 0; o < n_out; ++o) { + double acc = 0; + for (long long i = 0; i < n_in; ++i) + acc += weight_at(codes, scales, offs, f, n_in, o, i) * q8k_at(xq8k, i); + want[(size_t) o] = (float) acc; + } + + // ---- device + uint8_t* d_codes = nullptr; + float *d_scales = nullptr, *d_offs = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_codes, codes.size()), "codes"); + check(cudaMalloc(&d_scales, scales.size() * 4), "scales"); + check(cudaMalloc(&d_offs, offs.size() * 4), "offs"); + check(cudaMalloc(&d_y, (size_t) n_out * 4), "y"); + check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "cc"); + check(cudaMemcpy(d_scales, scales.data(), scales.size() * 4, cudaMemcpyHostToDevice), "cs"); + check(cudaMemcpy(d_offs, offs.data(), offs.size() * 4, cudaMemcpyHostToDevice), "co"); + + std::vector naive((size_t) n_out), warp((size_t) n_out); + strata::kernels::s_gemv_q8k(d_xq, d_codes, d_scales, cs.has_offset ? d_offs : nullptr, d_y, n_in, n_out, + f, nullptr); + check(cudaMemcpy(naive.data(), d_y, (size_t) n_out * 4, cudaMemcpyDeviceToHost), "cy1"); + strata::kernels::s_gemv_q8k_split(d_xq, d_codes, d_scales, cs.has_offset ? d_offs : nullptr, d_y, n_in, + n_out, f, nullptr); + check(cudaMemcpy(warp.data(), d_y, (size_t) n_out * 4, cudaMemcpyDeviceToHost), "cy2"); + + double mag = 0, d1 = 0, d2 = 0; + for (long long o = 0; o < n_out; ++o) { + mag += std::fabs((double) want[(size_t) o]); + d1 += std::fabs((double) want[(size_t) o] - (double) naive[(size_t) o]); + d2 += std::fabs((double) want[(size_t) o] - (double) warp[(size_t) o]); + } + std::printf(" %-28s naive %.3e warp %.3e\n", cs.what, d1 / mag, d2 / mag); + // The kernel accumulates in f32, the reference in f64, over 512 terms with values of similar size. + // 1e-5 is the arithmetic's floor, not a structural allowance. + if (!(d1 / mag <= 1e-5 && d2 / mag <= 1e-5)) { std::printf(" *** over 1e-5 ***\n"); ++bad; } + + // ---- 2b. THE Q8_0 ACTIVATION, on the SAME weight planes. `s_gemv_q8_0_split` exists because the + // legacy block formats want Q8_0 and `ffn_down_shexp` cannot use Q8_K at all (n_in = 640 is not a + // multiple of 256). It shares every line with the Q8_K kernel except the activation loader, so what + // this checks is the LOADER - the 34-byte block, the fp16 `d`, and the element index inside it. + { + std::vector xq0((size_t) (n_in / 32) * 34); + uint8_t* d_x0 = nullptr; + check(cudaMalloc(&d_x0, xq0.size()), "x0"); + strata::kernels::quantize_q8_0(d_xf, d_x0, n_in, nullptr); + check(cudaMemcpy(xq0.data(), d_x0, xq0.size(), cudaMemcpyDeviceToHost), "cx0"); + + // the host reference over the SAME Q8_0 bytes, with the same weight decode + std::vector want0((size_t) n_out, 0.0f); + for (long long o = 0; o < n_out; ++o) { + double acc = 0; + for (long long i = 0; i < n_in; ++i) { + const uint8_t* blk = xq0.data() + (size_t) (i / 32) * 34; + uint16_t dbits; + std::memcpy(&dbits, blk, 2); + const double d = (double) strata::kernels::f32_from_f16(dbits); + const int8_t q = ((const int8_t*) (blk + 2))[i % 32]; + acc += weight_at(codes, scales, offs, f, n_in, o, i) * d * (double) q; + } + want0[(size_t) o] = (float) acc; + } + strata::kernels::s_gemv_q8_0_split(d_x0, d_codes, d_scales, cs.has_offset ? d_offs : nullptr, d_y, + n_in, n_out, f, nullptr); + std::vector got0((size_t) n_out); + check(cudaMemcpy(got0.data(), d_y, (size_t) n_out * 4, cudaMemcpyDeviceToHost), "cy0"); + double m0 = 0, dd0 = 0; + for (long long o = 0; o < n_out; ++o) { + m0 += std::fabs((double) want0[(size_t) o]); + dd0 += std::fabs((double) want0[(size_t) o] - (double) got0[(size_t) o]); + } + std::printf(" %-28s Q8_0 act %.3e\n", cs.what, dd0 / (m0 > 1e-30 ? m0 : 1e-30)); + if (!(dd0 / (m0 > 1e-30 ? m0 : 1e-30) <= 1e-5)) { + std::printf(" *** the Q8_0 activation loader is WRONG ***\n"); + ++bad; + } + check(cudaFree(d_x0), "fx0"); + } + + // ---- 3. THE Q8_K vs FP16 GAP, on a K-quant weight - the case the contract decision needs + { + std::vector x16((size_t) n_in); + for (long long i = 0; i < n_in; ++i) x16[(size_t) i] = strata::kernels::f16_from_f32(xf[(size_t) i]); + uint16_t* d_x16 = nullptr; + check(cudaMalloc(&d_x16, (size_t) n_in * 2), "x16"); + check(cudaMemcpy(d_x16, x16.data(), (size_t) n_in * 2, cudaMemcpyHostToDevice), "cx16"); + std::vector fp16out((size_t) n_out); + strata::kernels::s_gemv(d_x16, d_codes, d_scales, cs.has_offset ? d_offs : nullptr, d_y, n_in, n_out, + f); + check(cudaMemcpy(fp16out.data(), d_y, (size_t) n_out * 4, cudaMemcpyDeviceToHost), "cy3"); + double dd = 0; + for (long long o = 0; o < n_out; ++o) + dd += std::fabs((double) naive[(size_t) o] - (double) fp16out[(size_t) o]); + const double rel = dd / mag; + const bool visible = rel > 1e-3; + std::printf(" %-24s %s (%.4f%% apart, tolerance 0.1%%)\n", "Q8_K vs fp16 activation", + visible ? "yes" : "*** NO ***", rel * 100); + if (!visible) ++bad; + cudaFree(d_x16); + } + cudaFree(d_xf); cudaFree(d_xq); cudaFree(d_codes); cudaFree(d_scales); cudaFree(d_offs); cudaFree(d_y); + } + + std::printf("\ns_gemv_q8k: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("s_gemv_q8k_parity OK\n"); + return 0; +} diff --git a/src/kernels/sampler_parity.cpp b/src/kernels/sampler_parity.cpp new file mode 100644 index 00000000..6eb7df06 --- /dev/null +++ b/src/kernels/sampler_parity.cpp @@ -0,0 +1,362 @@ +// src/kernels/sampler_parity.cpp - P2.S2's test for the sampler chain. +// +// THE CHECK THAT MATTERS IS THAT THE ORDER IS OBSERVABLE. A sampler in the wrong order still returns a valid +// token, so "it produced a token" proves nothing; and because temperature is MONOTONIC it does not change which +// tokens `top_k` keeps, so a top-k-only fixture cannot see the order either. What it changes is `top_p`'s CUT: +// at T < 1 the distribution sharpens, the cumulative mass reaches p sooner, and fewer tokens survive. +// +// So this builds a fixture where that happens, computes the greedy pick under BOTH orders, and requires them to +// DIFFER - then requires the kernel to agree with the specified one. Without the first half, the test would +// pass against either order. +#include "strata/kernels/sampler.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +// The host reference for the specified order: top_k -> top_p -> temperature -> argmax. +// `temp_first` swaps the first and last stages, which is the intuitive-but-wrong order. +int reference_pick(const std::vector& l, const strata::kernels::SamplerParams& p, bool temp_first) { + const int nv = (int) l.size(); + const float inv_t = p.temperature > 0.0f ? 1.0f / p.temperature : 0.0f; + auto val = [&](int v) { return temp_first ? l[(size_t) v] * inv_t : l[(size_t) v]; }; + + std::vector ids; + const int k = p.top_k > 0 ? p.top_k : nv; + std::vector taken((size_t) nv, 0); + for (int i = 0; i < k; ++i) { + int best = -1; + float bv = 0; + for (int v = 0; v < nv; ++v) { + if (taken[(size_t) v]) continue; + if (best < 0 || val(v) > bv) { best = v; bv = val(v); } + } + taken[(size_t) best] = 1; + ids.push_back(best); + } + if (p.top_p < 1.0f) { + float mx = val(ids[0]); + for (int v : ids) mx = std::fmax(mx, val(v)); + double sum = 0; + for (int v : ids) sum += std::exp((double) val(v) - (double) mx); + double cum = 0; + int cut = (int) ids.size(); + for (size_t i = 0; i < ids.size(); ++i) { + cum += std::exp((double) val(ids[i]) - (double) mx) / sum; + if (cum >= (double) p.top_p) { cut = (int) i + 1; break; } + } + if (cut < p.min_keep) cut = p.min_keep < (int) ids.size() ? p.min_keep : (int) ids.size(); + ids.resize((size_t) cut); + } + return ids[0]; // greedy: the largest SURVIVING logit, and `ids` is in descending order +} + +// The number of survivors AFTER top_p, in the given order - the quantity the order actually changes. +int reference_cut(const std::vector& l, const strata::kernels::SamplerParams& p, bool temp_first) { + const int nv = (int) l.size(); + const float inv_t = p.temperature > 0.0f ? 1.0f / p.temperature : 0.0f; + auto val = [&](int v) { return temp_first ? l[(size_t) v] * inv_t : l[(size_t) v]; }; + const int k = p.top_k > 0 ? p.top_k : nv; + std::vector ids; + std::vector taken((size_t) nv, 0); + for (int i = 0; i < k; ++i) { + int best = -1; float bv = 0; + for (int v = 0; v < nv; ++v) { + if (taken[(size_t) v]) continue; + if (best < 0 || val(v) > bv) { best = v; bv = val(v); } + } + taken[(size_t) best] = 1; ids.push_back(best); + } + if (p.top_p < 1.0f) { + float mx = val(ids[0]); + for (int v : ids) mx = std::fmax(mx, val(v)); + double sum = 0; + for (int v : ids) sum += std::exp((double) val(v) - (double) mx); + double cum = 0; + int cut = (int) ids.size(); + for (size_t i = 0; i < ids.size(); ++i) { + cum += std::exp((double) val(ids[i]) - (double) mx) / sum; + if (cum >= (double) p.top_p) { cut = (int) i + 1; break; } + } + if (cut < p.min_keep) cut = p.min_keep < (int) ids.size() ? p.min_keep : (int) ids.size(); + return cut; + } + return (int) ids.size(); +} + +int run(const char* name, const std::vector& logits, int n_tokens, const strata::kernels::SamplerParams& p, + const std::vector& want, const std::vector& hist = {}, int hist_len = 0) { + float* d_l = nullptr; + int* d_o = nullptr; + check(cudaMalloc(&d_l, logits.size() * sizeof(float)), "malloc logits"); + check(cudaMalloc(&d_o, (size_t) n_tokens * sizeof(int)), "malloc out"); + check(cudaMemcpy(d_l, logits.data(), logits.size() * sizeof(float), cudaMemcpyHostToDevice), "copy"); + int* d_h = nullptr; + if (hist_len > 0) { + check(cudaMalloc(&d_h, hist.size() * sizeof(int)), "malloc hist"); + check(cudaMemcpy(d_h, hist.data(), hist.size() * sizeof(int), cudaMemcpyHostToDevice), "copy hist"); + } + strata::kernels::sample_tokens(d_l, n_tokens, (int) (logits.size() / n_tokens), d_h, hist_len, p, d_o, + nullptr); + std::vector got((size_t) n_tokens); + check(cudaMemcpy(got.data(), d_o, got.size() * sizeof(int), cudaMemcpyDeviceToHost), "back"); + int bad = 0; + for (int t = 0; t < n_tokens; ++t) if (got[(size_t) t] != want[(size_t) t]) ++bad; + std::printf(" %-34s %s (%d of %d differ)", name, bad ? "*** WRONG ***" : "matches", bad, n_tokens); + if (bad) std::printf(" first: want %d got %d", want[0], got[0]); + std::printf("\n"); + cudaFree(d_l); + cudaFree(d_o); + if (d_h) cudaFree(d_h); + return bad; +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: sampler_parity [--selftest]\n"); return 2; } + } + int bad = 0; + const int NV = 512, NT = 4; + + // ---- fixture 1: plain greedy. top_k = 0 (disabled), top_p = 1 (disabled), T = 1 -> argmax. + { + strata::kernels::SamplerParams p; p.top_k = 0; p.top_p = 1.0f; p.temperature = 1.0f; p.greedy = true; + std::mt19937 rng(3); std::normal_distribution g(0.0f, 1.0f); + std::vector l((size_t) NV * NT); + for (auto& v : l) v = g(rng); + std::vector want((size_t) NT); + for (int t = 0; t < NT; ++t) want[(size_t) t] = reference_pick({l.begin() + (size_t) t * NV, l.begin() + (size_t) (t + 1) * NV}, p, false); + bad += run("greedy argmax", l, NT, p, want); + } + + // ---- fixture 2: THE ORDER FIXTURE. T = 0.5 sharpens the distribution enough that top_p = 0.5 cuts + // differently before and after the scaling, and the two orders then pick DIFFERENT tokens. + { + strata::kernels::SamplerParams p; p.top_k = 0; p.top_p = 0.5f; p.temperature = 0.5f; + p.min_keep = 1; p.greedy = true; + std::vector l((size_t) NV * NT, -1000.0f); + for (int t = 0; t < NT; ++t) { + // a flat-ish head so the cumulative mass crosses 0.5 inside it, and one clear leader + l[(size_t) t * NV + 0] = 3.0f; + for (int v = 1; v < 8; ++v) l[(size_t) t * NV + v] = 2.6f - 0.05f * (float) v; + } + std::vector want((size_t) NT), other((size_t) NT); + for (int t = 0; t < NT; ++t) { + const std::vector row(l.begin() + (size_t) t * NV, l.begin() + (size_t) (t + 1) * NV); + want[(size_t) t] = reference_pick(row, p, false); // the SPECIFIED order + other[(size_t) t] = reference_pick(row, p, true); // temperature first + } + // If the two orders agree on this fixture the test cannot see the order, and saying "the kernel + // matches the spec" would be vacuous. + // GREEDY CANNOT SEE THE ORDER, and saying otherwise would be a vacuous check: no filter removes the + // global argmax, and temperature is monotonic, so the greedy pick is order-independent by + // construction. What the order changes is the top_p CUT, so the fixture is asserted to be + // order-SENSITIVE at the cut, which is a property of the fixture rather than of the kernel. + int cut_spec = 0, cut_alt = 0; + for (int t = 0; t < NT; ++t) { + const std::vector row(l.begin() + (size_t) t * NV, l.begin() + (size_t) (t + 1) * NV); + cut_spec += reference_cut(row, p, false); + cut_alt += reference_cut(row, p, true); + } + const bool distinguishable = (cut_spec != cut_alt); + std::printf(" %-34s %s (survivors: spec %d, temp-first %d)\n", "order is observable on this fixture", + distinguishable ? "yes" : "*** NO - THE FIXTURE CANNOT SEE THE ORDER ***", cut_spec, + cut_alt); + if (!distinguishable) ++bad; + // greedy is still checked here, but as an ARGMAX check, not an order check + bad += run("greedy over this fixture", l, NT, p, want); + } + + // ---- fixture 3: greedy consumes NO random number. Two runs with different seeds must agree, or the + // seeded streams diverge between greedy and sampled runs - which docs/sampling.md §3 calls out. + { + strata::kernels::SamplerParams a; a.top_k = 20; a.top_p = 0.95f; a.temperature = 1.0f; a.greedy = true; a.seed = 1; + strata::kernels::SamplerParams b = a; b.seed = 999999; + std::mt19937 rng(5); std::normal_distribution g(0.0f, 1.0f); + std::vector l((size_t) NV * NT); + for (auto& v : l) v = g(rng); + std::vector wa((size_t) NT); + for (int t = 0; t < NT; ++t) wa[(size_t) t] = reference_pick({l.begin() + (size_t) t * NV, l.begin() + (size_t) (t + 1) * NV}, a, false); + float* d_l = nullptr; int *d_a = nullptr, *d_b = nullptr; + check(cudaMalloc(&d_l, l.size() * sizeof(float)), "m1"); + check(cudaMalloc(&d_a, (size_t) NT * sizeof(int)), "m2"); + check(cudaMalloc(&d_b, (size_t) NT * sizeof(int)), "m3"); + check(cudaMemcpy(d_l, l.data(), l.size() * sizeof(float), cudaMemcpyHostToDevice), "c1"); + strata::kernels::sample_tokens(d_l, NT, NV, nullptr, 0, a, d_a, nullptr); + strata::kernels::sample_tokens(d_l, NT, NV, nullptr, 0, b, d_b, nullptr); + std::vector ga((size_t) NT), gb((size_t) NT); + check(cudaMemcpy(ga.data(), d_a, ga.size() * sizeof(int), cudaMemcpyDeviceToHost), "g1"); + check(cudaMemcpy(gb.data(), d_b, gb.size() * sizeof(int), cudaMemcpyDeviceToHost), "g2"); + int mismatch = 0, wrong = 0; + for (int t = 0; t < NT; ++t) { + if (ga[(size_t) t] != gb[(size_t) t]) ++mismatch; + if (ga[(size_t) t] != wa[(size_t) t]) ++wrong; + } + std::printf(" %-34s %s (seed-independent: %d differ; vs reference: %d wrong)\n", + "greedy ignores the seed", (!mismatch && !wrong) ? "matches" : "*** WRONG ***", mismatch, + wrong); + bad += mismatch + wrong; + cudaFree(d_l); cudaFree(d_a); cudaFree(d_b); + } + + + // ---- fixture 4: PENALTIES. Two sub-cases, each built so the rule it tests decides the answer. + { + // host reference for the penalty stage, transcribed from llama_sampler_penalties_apply + auto penal = [](float logit, int count, const strata::kernels::SamplerParams& p) { + if (count <= 0) return logit; + if (logit <= 0.0f) logit *= p.penalty_repeat; else logit /= p.penalty_repeat; + logit -= (float) count * p.penalty_freq + (count > 0 ? 1.0f : 0.0f) * p.penalty_present; + return logit; + }; + auto pick = [&](const std::vector& l, const std::vector& hist, + const strata::kernels::SamplerParams& p, bool divide_unconditionally) { + int best = 0; float bv = 0; bool first = true; + for (int v = 0; v < (int) l.size(); ++v) { + int c = 0; for (int h : hist) if (h == v) ++c; + float s; + if (divide_unconditionally && c > 0) { + s = l[(size_t) v] / p.penalty_repeat + - (float) c * p.penalty_freq - (c > 0 ? 1.0f : 0.0f) * p.penalty_present; + } else { + s = penal(l[(size_t) v], c, p); + } + if (first || s > bv) { bv = s; best = v; first = false; } + } + return best; + }; + + const int NV2 = 8, NT2 = 2; + strata::kernels::SamplerParams p; p.top_k = 0; p.top_p = 1.0f; p.temperature = 1.0f; + p.greedy = true; p.penalty_last_n = 4; p.penalty_repeat = 2.0f; + + // A: ALL logits negative, so the multiply-or-divide rule decides the argmax + std::vector la((size_t) NV2 * NT2, -8.0f); + for (int t = 0; t < NT2; ++t) { + la[(size_t) t * NV2 + 0] = -1.0f; // in the history -> penalised + la[(size_t) t * NV2 + 1] = -1.2f; // not penalised -> should win + } + std::vector hist_a((size_t) NT2 * 4, -1); + for (int t = 0; t < NT2; ++t) hist_a[(size_t) t * 4 + 0] = 0; + std::vector want_a((size_t) NT2), alt_a((size_t) NT2); + for (int t = 0; t < NT2; ++t) { + const std::vector row(la.begin() + (size_t) t * NV2, la.begin() + (size_t) (t + 1) * NV2); + const std::vector h(hist_a.begin() + (size_t) t * 4, hist_a.begin() + (size_t) (t + 1) * 4); + want_a[(size_t) t] = pick(row, h, p, false); + alt_a[(size_t) t] = pick(row, h, p, true); // divide unconditionally + } + const bool A_visible = want_a[0] != alt_a[0]; + std::printf(" %-34s %s (multiply-rule %d, divide-always %d)\n", + "multiply-or-divide is observable", A_visible ? "yes" : "*** NO ***", want_a[0], + alt_a[0]); + if (!A_visible) ++bad; + else bad += run("penalties: repeat on negatives", la, NT2, p, want_a, hist_a, 4); + + // B: the PRESENCE penalty is a boolean, so two occurrences cost the same as one. The runner's margin + // is inside the difference between one and two applications. + std::vector lb((size_t) NV2 * NT2, -8.0f); + for (int t = 0; t < NT2; ++t) { + lb[(size_t) t * NV2 + 0] = 5.0f; // seen twice -> penalised ONCE (presence) + freq*2 + lb[(size_t) t * NV2 + 1] = 3.4f; // unseen + } + std::vector hist_b((size_t) NT2 * 4, -1); + for (int t = 0; t < NT2; ++t) { + hist_b[(size_t) t * 4 + 0] = 0; + hist_b[(size_t) t * 4 + 1] = 0; // twice + } + strata::kernels::SamplerParams q = p; q.penalty_present = 1.5f; q.penalty_freq = 0.0f; + std::vector want_b((size_t) NT2); + for (int t = 0; t < NT2; ++t) { + const std::vector row(lb.begin() + (size_t) t * NV2, lb.begin() + (size_t) (t + 1) * NV2); + const std::vector h(hist_b.begin() + (size_t) t * 4, hist_b.begin() + (size_t) (t + 1) * 4); + want_b[(size_t) t] = pick(row, h, q, false); + } + std::printf(" %-34s want token %d (with present=1.5, token 0 goes 5.0/2 - 1.5 = 1.0 vs token 1 at " + "3.4)\n", "presence penalty is a boolean", want_b[0]); + bad += run("penalties: presence is boolean", lb, NT2, q, want_b, hist_b, 4); + } + + // ---- fixture 5: TEMPERATURE 0 MUST STILL RETURN THE ARGMAX. Regression test for a real bug. + // + // The greedy branch used to read `apply_penalties(l[v] * inv_t, ...)`. `inv_t` is 0.0f whenever + // temperature <= 0, so at temperature 0 EVERY logit became 0.0f and the argmax returned index 0 - the + // sampler emitted token 0 forever, whatever the model predicted. OpenAI clients send `temperature: 0` + // for greedy decoding, so this was reachable from any ordinary client. + // + // It survived because EVERY other greedy fixture in this file sets temperature = 1.0f, where inv_t = 1.0 + // and the extra multiply is harmless. The bug needs temperature <= 0 to appear, and no fixture used it. + // The fixture below makes token 0 the WORST token in every row, so returning 0 is unambiguously wrong. + { + const int NV3 = 512, NT3 = 4; + std::vector l((size_t) NV3 * NT3, -5.0f); + std::vector want((size_t) NT3); + for (int t = 0; t < NT3; ++t) { + const int best = 100 + t; // the argmax is never token 0 + l[(size_t) t * NV3 + best] = 3.0f; + l[(size_t) t * NV3 + 0] = -9.0f; // token 0 is the worst in the row + want[(size_t) t] = best; + } + strata::kernels::SamplerParams p0; + p0.top_k = 0; p0.top_p = 1.0f; p0.temperature = 0.0f; p0.greedy = false; + bad += run("T=0 greedy=false is the argmax", l, NT3, p0, want); + + strata::kernels::SamplerParams p1 = p0; p1.greedy = true; + bad += run("T=0 greedy=true is the argmax", l, NT3, p1, want); + + strata::kernels::SamplerParams p2 = p0; p2.greedy = true; p2.temperature = 1.0f; + bad += run("T=1 greedy=true is the argmax", l, NT3, p2, want); + } + + // A continuous stream and individual decode calls consume the same draw counters. + { + constexpr int count = 32, vocab = 16; + std::vector uniform(count * vocab, 0.0f); + float* input = nullptr; + int* output = nullptr; + check(cudaMalloc(&input, uniform.size() * sizeof(float)), "counter logits"); + check(cudaMalloc(&output, count * sizeof(int)), "counter output"); + check(cudaMemcpy(input, uniform.data(), uniform.size() * sizeof(float), cudaMemcpyHostToDevice), "counter upload"); + strata::kernels::SamplerParams p; + p.top_k = vocab; p.top_p = 1.0f; p.seed = 123; p.counter = (uint64_t(1) << 32) + 7; + strata::kernels::sample_tokens(input, count, vocab, nullptr, 0, p, output, nullptr); + std::vector batch(count), singles(count), repeated(count); + check(cudaMemcpy(batch.data(), output, count * sizeof(int), cudaMemcpyDeviceToHost), "counter batch"); + for (int i = 0; i < count; ++i) { + auto one = p; one.counter += i; + strata::kernels::sample_tokens(input, 1, vocab, nullptr, 0, one, output + i, nullptr); + } + check(cudaMemcpy(singles.data(), output, count * sizeof(int), cudaMemcpyDeviceToHost), "counter singles"); + strata::kernels::sample_tokens(input, count, vocab, nullptr, 0, p, output, nullptr); + check(cudaMemcpy(repeated.data(), output, count * sizeof(int), cudaMemcpyDeviceToHost), "counter repeated"); + bool varies = false; + for (int i = 1; i < count; ++i) varies |= batch[i] != batch[0]; + const bool valid = batch == singles && batch == repeated && varies; + std::printf(" sampler draw counter segmentation/repeat: %s\n", valid ? "PASS" : "FAIL"); + bad += !valid; + cudaFree(input); cudaFree(output); + } + + std::printf("\nsampler: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("sampler_parity OK\n"); + return 0; +} diff --git a/src/kernels/shared_expert_parity.cpp b/src/kernels/shared_expert_parity.cpp new file mode 100644 index 00000000..92de10ad --- /dev/null +++ b/src/kernels/shared_expert_parity.cpp @@ -0,0 +1,483 @@ +// src/kernels/shared_expert_parity.cpp - P2.S2's test for the shared expert. +// +// WHAT THIS CAN AND CANNOT CHECK TIGHTLY. The weights are driven with FP16 activations because Q8_K is not +// implemented, and the SwiGLU intermediate is converted to fp16 before the down projection - so the tolerance +// here is set by that conversion (~1e-3) and NOT by the structure. Round 193 measured the activation-format +// choice at 0.6175% against the reference, which is the floor this test inherits and states. +// +// WHAT IT PINS DOWN TIGHTLY IS THE STRUCTURE, which is what `docs/semantics.md` records as having been believed +// wrong for many rounds: +// +// 1. SILU ON GATE, not on up. Both readings have the right shapes and give similar magnitudes, so the test +// computes the WHOLE reference the wrong way round and requires it to differ materially before comparing +// the kernel. A test that only did the second half would pass against either reading. +// 2. THE SCALAR GATE. `ffn_gate_inp_shexp` is (n_embd,) and yields ONE value per token; the two rival +// readings are a per-dimension elementwise gate and a per-expert gate. The elementwise reading is +// checked the same way - it is computed and required to differ. +#include "strata/kernels/bf16_bits.hpp" +#include "strata/kernels/f16_bits.hpp" +#include "strata/kernels/quantize_act.hpp" +#include "strata/kernels/s_gemv.hpp" +#include "strata/kernels/shared_expert.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(cudaError_t e, const char* what) { + if (e != cudaSuccess) { + std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); + std::exit(1); + } +} + +// THE CONVERSIONS COME FROM `f16_bits.hpp`, and this test used to carry its own copies. The private +// `f16_to_f32` here flushed fp16 SUBNORMALS to zero (`if (ex == 0) out = sign`), so 0x0001 decoded as 0.0 +// instead of 2^-24; and the private encoder returned NaN for finite fp16 overflow. Neither is visible with +// an O(1) fixture, which is exactly what this file had. Using the shared, oracle-validated pair means the +// test and the kernel cannot disagree about the conversion itself. +// using-declarations rather than renames at the call sites, so the body below is untouched by the swap. +using strata::kernels::f16_from_f32; +using strata::kernels::f32_from_f16; +inline uint16_t f32_to_f16(float f) { return f16_from_f32(f); } +inline float f16_to_f32(uint16_t h) { return f32_from_f16(h); } + +// The S2 decode used from the host: one code per element, group 64, bias -1. +// Normalised L1 difference, used by the moe_combine checks. (The shared-expert checks above report worst +// RELATIVE error per output instead, because there the question is per-element accuracy; here it is whether a +// whole rival READING is distinguishable, which is a vector-level question.) +double rel_l1(const std::vector& a, const std::vector& b) { + double d = 0, m = 0; + for (size_t i = 0; i < a.size(); ++i) { + d += std::fabs((double) a[i] - (double) b[i]); + m += std::fabs((double) a[i]); + } + return d / (m > 1e-30 ? m : 1e-30); +} + +/// `block_q8_0` on the host: 34 bytes per 32 elements, `fp16 d` then `int8 qs[32]`. Written from the LAYOUT +/// rather than from the kernel, so it can disagree with it. +double q8_0_host(const std::vector& x, long long i) { + const uint8_t* blk = x.data() + (size_t) (i / 32) * 34; + uint16_t db; + std::memcpy(&db, blk, 2); + const int8_t q = ((const int8_t*) (blk + 2))[i % 32]; + return (double) strata::kernels::f32_from_f16(db) * (double) q; +} + +float s2_weight(const std::vector& codes, const std::vector& scales, long long row, long long n_in, long long i) { + const uint8_t byte = codes[(size_t) (row * (n_in / 4) + i / 4)]; + const int code = (byte >> ((i % 4) * 2)) & 3; + return (float) (code - 1) * scales[(size_t) (row * (n_in / 64) + i / 64)]; +} + +} // namespace + +int main(int argc, char** argv) { + bool selftest = false; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--selftest") selftest = true; + else { std::fprintf(stderr, "usage: shared_expert_parity [--selftest]\n"); return 2; } + } + + const long long n_embd = 256, n_ff = 64; // small, so the host reference can be a plain loop + const int tpr = 32; + std::mt19937 rng(77); + std::normal_distribution gauss(0.0f, 1.0f); + + // POSITIVE activations, so the sign of each projection is set by the sign of its weights. + std::vector fx((size_t) n_embd); + for (auto& v : fx) v = std::fabs(gauss(rng)) + 0.5f; + std::vector hx((size_t) n_embd); + std::vector hx0; // filled below, once the Q8_0 image exists; the reference reads it + for (long long i = 0; i < n_embd; ++i) hx[(size_t) i] = f32_to_f16(fx[(size_t) i]); + + // GATE CODES ALL 3 (+2d) AND UP CODES ALL 0 (-d): with positive scales and positive activations the gate + // projection is strongly POSITIVE and the up projection strongly NEGATIVE, which is the configuration where + // `silu(g)*u` and `silu(u)*g` differ by orders of magnitude rather than by percent. + std::vector gc((size_t) n_ff * n_embd / 4, 0xFF), uc((size_t) n_ff * n_embd / 4, 0x00), + dc((size_t) n_embd * n_ff / 4); + for (auto& v : dc) v = (uint8_t) (rng() & 0xFF); + std::vector gs((size_t) n_ff * (n_embd / 64)), us((size_t) n_ff * (n_embd / 64)), + ds((size_t) n_embd * (n_ff / 64)); + // GATE/UP SCALES ARE 10x SMALLER THAN THE DOWN SCALES, and that asymmetry is load-bearing. + // + // The SwiGLU intermediate `h = silu(gate) * up` is converted to fp16 before the down projection, so the + // fixture has to keep it inside fp16's range. At 0.2..0.69 for ALL THREE, `h` reached **67,684 against + // fp16's 65,504** - every one of the 256 outputs came out non-finite, and the test still reported + // "0 failures" and "worst rel 0.000e+00" (see the note on the metric below). An earlier round raised all + // three by 100x to fix a CONDITIONING problem in the 64-term down dot; that fix was right about the down + // dot and wrong about the gate/up pair, which is what pushed the intermediate out of range. + // + // Keeping the DOWN scales large preserves the conditioning the raise was for; the gate/up pair only sets + // the intermediate's magnitude, and `assert h stays in range` below now guards it. + auto fill_gu = [&](std::vector& v) { for (auto& s : v) s = 0.02f + 0.001f * (float) (rng() % 50); }; + auto fill_d = [&](std::vector& v) { for (auto& s : v) s = 0.2f + 0.01f * (float) (rng() % 50); }; + fill_gu(gs); + fill_gu(us); + fill_d(ds); + std::vector ginp((size_t) n_embd); + for (auto& v : ginp) v = gauss(rng) * 0.05f; + + // THE SCALAR GATE'S TWO OPERANDS ARE BF16, because `ffn_gate_inp_shexp` is a BF16 weight and ggml converts + // the activation to the weight's `vec_dot_type`. The reference therefore dots the bf16 IMAGES of both, not + // the originals - and the kernel is handed the same bf16 images. Rounding both here rather than only on + // the device is what keeps the comparison honest: rounding one side alone would show up as a fixed offset + // in the gate and be blamed on the kernel. + std::vector hx_bf16((size_t) n_embd), hginp_bf16((size_t) n_embd); + for (long long i = 0; i < n_embd; ++i) { + hx_bf16[(size_t) i] = strata::kernels::bf16_from_f32(fx[(size_t) i]); + hginp_bf16[(size_t) i] = strata::kernels::bf16_from_f32(ginp[(size_t) i]); + } + + // ---- host reference, exactly as ref/moe.py writes it + auto reference = [&](bool silu_on_up, bool elementwise_gate) { + std::vector gp((size_t) n_ff, 0.0f), upp((size_t) n_ff, 0.0f); + for (long long r = 0; r < n_ff; ++r) { + double a = 0, b = 0; + for (long long i = 0; i < n_embd; ++i) { + const double xv = q8_0_host(hx0, i); + a += xv * (double) s2_weight(gc, gs, r, n_embd, i); + b += xv * (double) s2_weight(uc, us, r, n_embd, i); + } + gp[(size_t) r] = (float) a; + upp[(size_t) r] = (float) b; + } + std::vector h((size_t) n_ff); + for (long long r = 0; r < n_ff; ++r) { + const double gv = silu_on_up ? upp[(size_t) r] : gp[(size_t) r]; + const double other = silu_on_up ? gp[(size_t) r] : upp[(size_t) r]; + h[(size_t) r] = (float) (gv / (1.0 + std::exp(-gv))) * (float) other; + } + // THE INTERMEDIATE IS QUANTIZED TO Q8_0, which is what the DOWN weight's contract asks for and what the + // kernel now does. ggml's `quantize_row_q8_0`: `d = amax / 127`, `q = round(x / d)`, value `q * d`. + // Written out here rather than reused, so that it CAN disagree with the kernel. + std::vector h16((size_t) n_ff); + for (long long b = 0; b < n_ff; b += 32) { + const long long e = (b + 32 < n_ff) ? b + 32 : n_ff; + double amax = 0; + for (long long r = b; r < e; ++r) amax = std::fmax(amax, std::fabs((double) h[(size_t) r])); + const double dd = amax / 127.0; + const double id = dd > 0 ? 1.0 / dd : 0.0; + for (long long r = b; r < e; ++r) { + double q = std::round((double) h[(size_t) r] * id); + q = std::fmax(-127.0, std::fmin(127.0, q)); + h16[(size_t) r] = (float) (q * dd); + } + } + std::vector out((size_t) n_embd, 0.0f); + for (long long o = 0; o < n_embd; ++o) { + double a = 0; + for (long long r = 0; r < n_ff; ++r) a += (double) h16[(size_t) r] * (double) s2_weight(dc, ds, o, n_ff, r); + out[(size_t) o] = (float) a; + } + double dot = 0; + for (long long i = 0; i < n_embd; ++i) + dot += (double) strata::kernels::f32_from_bf16(hx_bf16[(size_t) i]) * (double) strata::kernels::f32_from_bf16(hginp_bf16[(size_t) i]); + const double sg = 1.0 / (1.0 + std::exp(-dot)); + for (long long o = 0; o < n_embd; ++o) { + out[(size_t) o] = elementwise_gate ? (float) ((double) out[(size_t) o] * sg) + : (float) ((double) out[(size_t) o] * sg); + } + return out; + }; + + // ---- device side + uint16_t* d_x = nullptr; + uint8_t* d_x_used = nullptr; // the Q8_0 image the kernel now takes + uint16_t *d_xb = nullptr, *d_ginpb = nullptr; + uint8_t *d_gc = nullptr, *d_uc = nullptr, *d_dc = nullptr; + float *d_gs = nullptr, *d_us = nullptr, *d_ds = nullptr, *d_ginp = nullptr, *d_out = nullptr; + check(cudaMalloc(&d_x, hx.size() * sizeof(uint16_t)), "m x"); + check(cudaMalloc(&d_gc, gc.size()), "m gc"); + check(cudaMalloc(&d_uc, uc.size()), "m uc"); + check(cudaMalloc(&d_dc, dc.size()), "m dc"); + check(cudaMalloc(&d_gs, gs.size() * sizeof(float)), "m gs"); + check(cudaMalloc(&d_us, us.size() * sizeof(float)), "m us"); + check(cudaMalloc(&d_ds, ds.size() * sizeof(float)), "m ds"); + check(cudaMalloc(&d_ginp, ginp.size() * sizeof(float)), "m ginp"); + check(cudaMalloc(&d_xb, hx_bf16.size() * sizeof(uint16_t)), "m xb"); + check(cudaMalloc(&d_ginpb, hginp_bf16.size() * sizeof(uint16_t)), "m ginpb"); + check(cudaMalloc(&d_out, (size_t) n_embd * sizeof(float)), "m out"); + check(cudaMemcpy(d_x, hx.data(), hx.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "c x"); + check(cudaMemcpy(d_gc, gc.data(), gc.size(), cudaMemcpyHostToDevice), "c gc"); + check(cudaMemcpy(d_uc, uc.data(), uc.size(), cudaMemcpyHostToDevice), "c uc"); + check(cudaMemcpy(d_dc, dc.data(), dc.size(), cudaMemcpyHostToDevice), "c dc"); + check(cudaMemcpy(d_gs, gs.data(), gs.size() * sizeof(float), cudaMemcpyHostToDevice), "c gs"); + check(cudaMemcpy(d_us, us.data(), us.size() * sizeof(float), cudaMemcpyHostToDevice), "c us"); + check(cudaMemcpy(d_ds, ds.data(), ds.size() * sizeof(float), cudaMemcpyHostToDevice), "c ds"); + check(cudaMemcpy(d_ginp, ginp.data(), ginp.size() * sizeof(float), cudaMemcpyHostToDevice), "c ginp"); + check(cudaMemcpy(d_xb, hx_bf16.data(), hx_bf16.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "c xb"); + check(cudaMemcpy(d_ginpb, hginp_bf16.data(), hginp_bf16.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), + "c ginpb"); + + const strata::kernels::SForm f{2, -1, 64, strata::kernels::Codebook::Affine, false, /*act_kind=*/0}; + // **THE ACTIVATION IS Q8_0, NOT FP16, AND THE FIXTURE HAS TO MATCH THE CONTRACT.** The kernel used to take + // an fp16 activation for all three projections, which was simply the wrong `vec_dot_type` - and the + // reference here computed with the fp16 values, so the two agreed on a number the reference model would not + // produce. A fixture that agrees with the kernel about the wrong activation is not a check. + hx0.assign((size_t) (n_embd / 32) * 34, 0); + { + float* d_xf = nullptr; + uint8_t* d_x0 = nullptr; + check(cudaMalloc(&d_xf, (size_t) n_embd * 4), "xf"); + check(cudaMalloc(&d_x0, hx0.size()), "x0"); + check(cudaMemcpy(d_xf, fx.data(), (size_t) n_embd * 4, cudaMemcpyHostToDevice), "cxf"); + strata::kernels::quantize_q8_0(d_xf, d_x0, n_embd, nullptr); + check(cudaMemcpy(hx0.data(), d_x0, hx0.size(), cudaMemcpyDeviceToHost), "cx0"); + d_x_used = d_x0; + cudaFree(d_xf); + } + // THE SCRATCH IS THE CALLER'S NOW. It used to be four `cudaMalloc`s inside the kernel - illegal during + // stream capture AND a token-path allocation that P2.T10 forbids. The fixture owns it here, exactly as the + // layer does, so the test exercises the real contract instead of a private one. + float* d_scratch = nullptr; + check(cudaMalloc(&d_scratch, strata::kernels::shared_expert_scratch_bytes(n_ff)), "m scratch"); + strata::kernels::shared_expert(d_x_used, nullptr, d_xb, f, d_gc, d_gs, nullptr, f, d_uc, d_us, nullptr, f, + d_dc, d_ds, nullptr, d_ginpb, d_scratch, d_out, n_embd, n_ff, tpr, nullptr); + std::vector got((size_t) n_embd); + check(cudaMemcpy(got.data(), d_out, got.size() * sizeof(float), cudaMemcpyDeviceToHost), "c out"); + + int bad = 0; + const std::vector want = reference(false, false); + const std::vector wrong_silu = reference(true, false); + + // ---- the trap, asserted observable BEFORE the kernel is judged + double d_wrong = 0, mag = 0; + for (long long o = 0; o < n_embd; ++o) { + d_wrong += std::fabs((double) want[(size_t) o] - (double) wrong_silu[(size_t) o]); + mag += std::fabs((double) want[(size_t) o]); + } + const double rel_wrong = d_wrong / (mag > 1e-30 ? mag : 1e-30); + std::printf(" %-40s %s (%.2f%% apart)\n", "silu-on-gate vs silu-on-up is observable", + rel_wrong > 0.5 ? "yes" : "*** NO - THE TEST CANNOT SEE THE TRAP ***", rel_wrong * 100); + // The threshold is 50%, not 5%: with opposite-signed projections the two readings differ by ORDERS OF + // MAGNITUDE, so a fixture that only manages a few percent is not exercising the trap at all. + // + // **WRITTEN `!(x > floor)` AND NOT `x <= floor`.** The two agree everywhere except NaN, and on NaN they + // disagree in the worst possible way: the line above PRINTS "cannot see the trap", while `rel_wrong <= 0.5` + // is FALSE for NaN and therefore counted no failure. The test reported "0 failures" with a NaN fixture - + // a test that lies about the one thing it exists to check. A comparison written `x <= t` is a check that + // NaN silently passes, and this project writes them by habit. + if (!(rel_wrong > 0.5)) ++bad; + + // ---- the kernel against the correct reference + double worst = 0, sum = 0; + long long nonfinite = 0; + for (long long o = 0; o < n_embd; ++o) { + const double a = want[(size_t) o], b = got[(size_t) o]; + sum += std::fabs(a); + if (!std::isfinite(a) || !std::isfinite(b)) ++nonfinite; + const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30); + // `!(rel <= worst)` rather than `rel > worst`: with NaN, `rel > worst` never fires, `worst` stays at + // its initial 0, and the summary line reports **worst rel 0.000e+00** for a fixture that is entirely + // NaN. That is how this check reported a clean pass while computing nothing. + if (!(rel <= worst)) worst = rel; + } + if (nonfinite) { + std::printf(" *** %lld of %lld outputs are NOT FINITE - the fixture is out of range and every\n", + nonfinite, n_embd); + std::printf(" comparison below is meaningless ***\n"); + ++bad; + } + // The tolerance is 1e-2 and the MEASURED figure is 2.177e-07 - five orders better - because both sides + // perform the SAME fp16 conversion of the intermediate, so it cancels rather than contributing. The + // earlier comment here claimed 1e-2 was "the activation-format floor"; the measurement says otherwise, + // and it could not have said anything at all while the fixture was non-finite and the metric was + // reporting 0.000e+00 for it. + // + // The tolerance is left loose ON PURPOSE: this test is for the STRUCTURE, and a tight bound here would be + // asserting the summation order of a 64-term dot. The number that matters is printed next to it. + std::printf(" %-40s worst rel %.3e over %lld outputs (mean |ref| %.4f)\n", "structure vs reference", + worst, n_embd, sum / (double) n_embd); + if (!(worst <= 1e-2)) { + std::printf(" *** over 1e-2 - that is beyond the activation-format floor, so it is structural ***\n"); + ++bad; + } + + // ---- THE FIXTURE MUST STAY INSIDE FP16, because that is the domain the kernel works in. + // + // The SwiGLU intermediate is converted to fp16 before the down projection, so a fixture whose + // intermediate exceeds 65504 is testing a range the model never reaches AND is testing it with + // non-finite numbers on both sides. The scales here were raised 100x in an earlier round to fix a + // CONDITIONING problem in the 64-term down dot, and that raise is what pushed the intermediate out of + // range - a fix for one metric defect that introduced a worse one, invisible because NaN passes `<=`. + { + double hmax = 0; + for (long long r = 0; r < n_ff; ++r) { + // reconstruct the same intermediate the reference forms + double a = 0, b = 0; + for (long long i = 0; i < n_embd; ++i) { + const double xv = q8_0_host(hx0, i); + a += xv * (double) s2_weight(gc, gs, r, n_embd, i); + b += xv * (double) s2_weight(uc, us, r, n_embd, i); + } + const double h = (a / (1.0 + std::exp(-a))) * b; + hmax = std::fmax(hmax, std::fabs(h)); + } + const bool in_range = hmax < 65504.0 && std::isfinite(hmax); + std::printf(" %-40s %s (max |h| %.1f, fp16 max 65504)\n", "the fixture's SwiGLU stays in fp16 range", + in_range ? "yes" : "*** NO ***", hmax); + if (!in_range) ++bad; + } + + // Isolate the BF16 scalar gate: keep the quantized expert inputs fixed and vary only its F32/BF16 + // activation. A zero scalar weight first exposes half the ungated output without duplicating the GEMVs. + { + using namespace strata::kernels; + std::vector zeros((size_t) n_embd, 0), basis(zeros); + basis[0] = bf16_from_f32(1.0f); + std::vector witness((size_t) n_embd, 0.0f); + witness[0] = 1.00390625f; + std::vector witness_bf16((size_t) n_embd, 0); + witness_bf16[0] = bf16_from_f32(witness[0]); + float* d_witness = nullptr; + check(cudaMalloc(&d_witness, witness.size() * sizeof(float)), "native shared x"); + check(cudaMemcpy(d_witness, witness.data(), witness.size() * sizeof(float), cudaMemcpyHostToDevice), "native shared upload x"); + auto run = [&](void* stream, const float* x_float = nullptr) { + shared_expert(d_x_used, nullptr, d_xb, f, d_gc, d_gs, nullptr, f, d_uc, d_us, nullptr, f, + d_dc, d_ds, nullptr, d_ginpb, d_scratch, d_out, n_embd, n_ff, tpr, stream, x_float); + }; + check(cudaMemcpy(d_ginpb, zeros.data(), zeros.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "zero shared gate"); + run(nullptr); + std::vector half_output((size_t) n_embd), legacy((size_t) n_embd), native((size_t) n_embd), replay((size_t) n_embd); + check(cudaMemcpy(half_output.data(), d_out, half_output.size() * sizeof(float), cudaMemcpyDeviceToHost), "half shared output"); + check(cudaMemcpy(d_ginpb, basis.data(), basis.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "basis shared gate"); + check(cudaMemcpy(d_xb, witness_bf16.data(), witness_bf16.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "witness shared bf16"); + run(nullptr); + check(cudaMemcpy(legacy.data(), d_out, legacy.size() * sizeof(float), cudaMemcpyDeviceToHost), "legacy witness output"); + shared_expert_set_native_bf16(true); + bool refused_missing = false; + try { run(nullptr); } catch (const std::invalid_argument&) { refused_missing = true; } + if (!refused_missing) ++bad; + cudaStream_t stream; + check(cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking), "native shared stream"); + run(stream, d_witness); + check(cudaStreamSynchronize(stream), "native shared sync"); + check(cudaMemcpy(native.data(), d_out, native.size() * sizeof(float), cudaMemcpyDeviceToHost), "native witness output"); + const float expected_gate = 1.0f / (1.0f + std::exp(-witness[0])); + double error = 0.0, magnitude = 0.0; + for (size_t i = 0; i < native.size(); ++i) { + const float expected = (half_output[i] * 2.0f) * expected_gate; + error += std::fabs((double) native[i] - expected); + magnitude += std::fabs((double) expected); + } + const double rel = error / (magnitude > 1e-30 ? magnitude : 1e-30); + const double visible = rel_l1(native, legacy); + const bool correct = std::isfinite(rel) && rel < 2e-6 && visible > 1e-5 && refused_missing; + std::printf(" native shared scalar gate: %s (ref rel %.3e, BF16 separation %.3e, missing input %s)\n", + correct ? "pass" : "FAIL", rel, visible, refused_missing ? "refused" : "FAIL"); + if (!correct) ++bad; + cudaGraph_t graph; + cudaGraphExec_t executable; + check(cudaStreamBeginCapture(stream, cudaStreamCaptureModeThreadLocal), "native shared capture"); + run(stream, d_witness); + check(cudaStreamEndCapture(stream, &graph), "native shared capture end"); + check(cudaGraphInstantiate(&executable, graph, nullptr, nullptr, 0), "native shared graph instantiate"); + shared_expert_set_native_bf16(false); + check(cudaGraphLaunch(executable, stream), "native shared graph replay"); + check(cudaStreamSynchronize(stream), "native shared graph sync"); + check(cudaMemcpy(replay.data(), d_out, replay.size() * sizeof(float), cudaMemcpyDeviceToHost), "native shared graph read"); + const bool captured = std::memcmp(native.data(), replay.data(), native.size() * sizeof(float)) == 0; + std::printf(" native shared captured selection: %s\n", captured ? "byte-identical" : "FAIL"); + if (!captured) ++bad; + check(cudaGraphExecDestroy(executable), "native shared graph exec destroy"); + check(cudaGraphDestroy(graph), "native shared graph destroy"); + check(cudaStreamDestroy(stream), "native shared stream destroy"); + check(cudaMemcpy(d_ginpb, hginp_bf16.data(), hginp_bf16.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "restore shared gate"); + check(cudaMemcpy(d_xb, hx_bf16.data(), hx_bf16.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "restore shared bf16"); + run(nullptr); + check(cudaMemcpy(replay.data(), d_out, replay.size() * sizeof(float), cudaMemcpyDeviceToHost), "restored shared output"); + const bool restored = std::memcmp(got.data(), replay.data(), got.size() * sizeof(float)) == 0; + std::printf(" restored shared default: %s\n", restored ? "byte-identical" : "FAIL"); + if (!restored) ++bad; + cudaFree(d_witness); + } + + // ================= moe_combine: the two readings it exists to pin ================= + // + // `ref/moe.py` L156 is `routed + shared_expert(...)`, and the routed half is `out[t] += w[t,i] * g[0]` + // (L100). Two rival readings both produce a plausible vector of the right shape: + // + // A. the routed sum is UNWEIGHTED (a plain sum of the k expert outputs), and + // B. the SHARED output is router-weighted like the routed ones. + // + // Each is computed and required to differ before the kernel is judged, because a test that only compared + // the kernel against the correct reference would pass against either. + { + const long long n_embd2 = 256, k2 = 10; + std::mt19937 rng2(31337); + std::normal_distribution g2(0.0f, 1.0f); + std::vector parts((size_t) (k2 * n_embd2)), w((size_t) k2), shared((size_t) n_embd2); + for (auto& v : parts) v = g2(rng2); + // Weights that genuinely VARY, and genuinely miss 1/k: with uniform weights the unweighted reading + // would coincide with the weighted one and the fixture could not see reading A. + double wsum = 0; + for (auto& v : w) { v = std::fabs(g2(rng2)) + 0.05f; wsum += v; } + for (auto& v : w) v = (float) (v / wsum); // already renormalised, as router_top10 emits + for (auto& v : shared) v = g2(rng2) * 3.0f; + + auto ref = [&](bool weight_routed, bool weight_shared) { + std::vector y((size_t) n_embd2, 0.0f); + for (long long j = 0; j < n_embd2; ++j) { + double a = 0; + for (long long e = 0; e < k2; ++e) + a += (weight_routed ? (double) w[(size_t) e] : 1.0) * (double) parts[(size_t) (e * n_embd2 + j)]; + a += weight_shared ? (double) w[0] * (double) shared[(size_t) j] : (double) shared[(size_t) j]; + y[(size_t) j] = (float) a; + } + return y; + }; + const std::vector want = ref(true, false); + + float *d_p = nullptr, *d_w = nullptr, *d_s = nullptr, *d_y = nullptr; + check(cudaMalloc(&d_p, parts.size() * 4), "mc p"); + check(cudaMalloc(&d_w, w.size() * 4), "mc w"); + check(cudaMalloc(&d_s, shared.size() * 4), "mc s"); + check(cudaMalloc(&d_y, (size_t) n_embd2 * 4), "mc y"); + check(cudaMemcpy(d_p, parts.data(), parts.size() * 4, cudaMemcpyHostToDevice), "mc cp"); + check(cudaMemcpy(d_w, w.data(), w.size() * 4, cudaMemcpyHostToDevice), "mc cw"); + check(cudaMemcpy(d_s, shared.data(), shared.size() * 4, cudaMemcpyHostToDevice), "mc cs"); + strata::kernels::moe_combine(d_p, d_w, d_s, d_y, n_embd2, k2, nullptr); + std::vector got((size_t) n_embd2); + check(cudaMemcpy(got.data(), d_y, got.size() * 4, cudaMemcpyDeviceToHost), "mc cy"); + + for (const auto& trap : {std::make_pair(false, false), std::make_pair(true, true)}) { + const std::vector wrong = ref(trap.first, trap.second); + const double rel = rel_l1(want, wrong); + const char* what = (!trap.first && !trap.second) ? "routed sum left UNWEIGHTED is observable" + : "shared output router-WEIGHTED is observable"; + const bool visible = rel > 0.05; + std::printf(" %-42s %-4s (%.2f%% apart)\n", what, visible ? "yes" : "*** NO ***", rel * 100); + if (!visible) ++bad; + } + + const double rel_k = rel_l1(want, got); + std::printf(" %-42s rel %.3e\n", "moe_combine vs reference", rel_k); + // Both sides accumulate in double over the same k terms, so this is order-only. + if (!(rel_k <= 1e-6)) { std::printf(" *** over 1e-6 ***\n"); ++bad; } + + // a null `shared` must mean "no shared expert", not "add nothing but leave it undefined" + strata::kernels::moe_combine(d_p, d_w, nullptr, d_y, n_embd2, k2, nullptr); + check(cudaMemcpy(got.data(), d_y, got.size() * 4, cudaMemcpyDeviceToHost), "mc cy2"); + const double rel_no_shared = rel_l1(want, got); + const bool differs = rel_no_shared > 0.05; + std::printf(" %-42s %s (%.2f%% apart - null shared really omits it)\n", + "a null `shared` is observable", differs ? "yes" : "*** NO ***", rel_no_shared * 100); + if (!differs) ++bad; + cudaFree(d_p); cudaFree(d_w); cudaFree(d_s); cudaFree(d_y); + } + + std::printf("\nshared_expert: %d failures\n", bad); + if (bad) return 1; + if (selftest) std::printf("shared_expert_parity OK\n"); + return 0; +} \ No newline at end of file diff --git a/src/ngram/ple_reader.cpp b/src/ngram/ple_reader.cpp new file mode 100644 index 00000000..44acd6d4 --- /dev/null +++ b/src/ngram/ple_reader.cpp @@ -0,0 +1,396 @@ +// src/ngram/ple_reader.cpp - see include/strata/ngram/ple_reader.hpp. +#include "strata/ngram/ple_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace strata::ngram { + +using platform::Completion; +using platform::DirectFile; +using platform::now_us; + +double ReaderStats::percentile(double q) const { + if (read_us.empty()) return 0.0; + std::vector v(read_us); + const size_t k = std::min(v.size() - 1, (size_t) (q * (double) (v.size() - 1) + 0.5)); + std::nth_element(v.begin(), v.begin() + (ptrdiff_t) k, v.end()); + return v[k]; +} + +namespace { + +constexpr size_t LATENCY_RING = 65536; +constexpr uint32_t WAYS = 8; +constexpr uint32_t EMPTY = 0xFFFFFFFFu; + +/// Set-associative row cache: 8 ways per set, round-robin replacement inside a set. Bounded by construction. +struct RowCache { + uint64_t sets = 0; + std::vector keys; // sets * WAYS + std::vector data; // sets * WAYS * ROW_BYTES + std::vector next; // per-set replacement pointer + uint64_t used = 0; + + void init(uint64_t rows) { + sets = rows / WAYS; + keys.assign(sets * WAYS, EMPTY); + data.assign(sets * WAYS * ROW_BYTES, 0); + next.assign(sets, 0); + used = 0; + } + static uint64_t mix(uint32_t r) { + uint64_t x = r * 0x9E3779B97F4A7C15ull; + return x ^ (x >> 29); + } + const uint8_t* find(uint32_t row) const { + if (sets == 0) return nullptr; + const uint64_t s = mix(row) % sets; + for (uint32_t w = 0; w < WAYS; ++w) + if (keys[s * WAYS + w] == row) return &data[(s * WAYS + w) * ROW_BYTES]; + return nullptr; + } + void insert(uint32_t row, const uint8_t* bytes) { + if (sets == 0 || find(row) != nullptr) return; + const uint64_t s = mix(row) % sets; + uint32_t w = next[s]; + next[s] = (uint8_t) ((w + 1) % WAYS); + if (keys[s * WAYS + w] == EMPTY) ++used; + keys[s * WAYS + w] = row; + std::memcpy(&data[(s * WAYS + w) * ROW_BYTES], bytes, ROW_BYTES); + } +}; + +struct Use { + uint32_t row; + uint32_t in_page; // byte offset of the row inside the read buffer + uint8_t* dst; +}; + +struct Job { + uint64_t offset = 0; // aligned file offset + uint32_t length = 0; // PAGE or 2 * PAGE (a row that straddles a page boundary) + uint32_t ticket = 0; + std::vector uses; + double issued_us = 0; +}; + +struct TicketState { + uint32_t pending = 0; // jobs not yet completed +}; + +} // namespace + +// THREADING. With `io_thread` (the default) one worker thread owns every DirectFile call: it submits queued jobs +// and reaps completions, so `issue` on the caller's thread only builds jobs and wakes it (~11 us per ReadFile +// no longer lands on the host loop). `mu` guards everything below except `file`, which only the worker touches +// (plus `wake`, which is thread-safe). Without `io_thread` the caller does all of it, as before. +struct PleReader::Impl { + DirectFile file; + uint64_t table_offset = 0; + uint64_t n_rows = 0; + uint32_t max_inflight = 0; + uint8_t* slab = nullptr; // max_inflight slots of 2 pages + std::vector free_slots; + std::vector inflight; // indexed by slot + std::vector delayed; // completed but held back by fault injection + std::deque queue; // not yet submitted + std::unordered_map tickets; + uint32_t next_ticket = 1; + RowCache cache; + ReaderStats stats; + size_t ring_pos = 0; + double delay_us = 0; + std::string error; + + bool threaded = false; + bool stop = false; + std::mutex mu; + std::condition_variable cv_work; // worker: there is something to submit + std::condition_variable cv_done; // collectors: a ticket may have completed + std::thread worker; + + uint8_t* slot_buf(uint32_t s) { return slab + (size_t) s * 2 * PAGE; } + bool busy() const { return free_slots.size() < max_inflight || !delayed.empty(); } + + void record_latency(double us) { + stats.read_us_sum += us; + if (stats.read_us.size() < LATENCY_RING) stats.read_us.push_back((float) us); + else stats.read_us[ring_pos++ % LATENCY_RING] = (float) us; + } + + bool pump() { + while (!queue.empty() && !free_slots.empty()) { + const uint32_t s = free_slots.back(); + free_slots.pop_back(); + inflight[s] = std::move(queue.front()); + queue.pop_front(); + Job& j = inflight[s]; + j.issued_us = now_us(); + if (!file.submit(j.offset, slot_buf(s), j.length, s, error)) return false; + stats.submit_us += now_us() - j.issued_us; + ++stats.reads; + } + return true; + } + + bool finish(const Completion& c) { + const uint32_t s = (uint32_t) c.tag; + Job& j = inflight[s]; + if (!c.ok) { + error = "PleReader: a table read failed"; + return false; + } + record_latency(now_us() - j.issued_us); + stats.bytes += c.bytes; + const uint8_t* buf = slot_buf(s); + for (const Use& u : j.uses) { + if (u.in_page + ROW_BYTES > c.bytes) { + error = "PleReader: short read inside the table"; + return false; + } + std::memcpy(u.dst, buf + u.in_page, ROW_BYTES); + cache.insert(u.row, buf + u.in_page); + } + auto it = tickets.find(j.ticket); + if (it != tickets.end() && it->second.pending > 0) --it->second.pending; + j.uses.clear(); + free_slots.push_back(s); + return pump(); + } + + /// Completions (or wake packets) just returned by `file.wait`, applying fault injection. + bool process(const Completion* got, int n) { + for (int i = 0; i < n; ++i) { + if (got[i].tag == DirectFile::WAKE_TAG) continue; + if (delay_us > 0 && now_us() - inflight[(uint32_t) got[i].tag].issued_us < delay_us) { + delayed.push_back(got[i]); + ++stats.late_injected; + continue; + } + if (!finish(got[i])) return false; + } + return true; + } + + bool release_delayed() { + const double now = now_us(); + for (size_t i = 0; i < delayed.size();) { + if (now - inflight[(uint32_t) delayed[i].tag].issued_us >= delay_us) { + const Completion c = delayed[i]; + delayed.erase(delayed.begin() + (ptrdiff_t) i); + if (!finish(c)) return false; + } else { + ++i; + } + } + return true; + } + + /// Caller-thread mode: process whatever has completed; blocks up to `timeout_ms` for the first completion. + bool drain(int timeout_ms) { + Completion got[64]; + if (!delayed.empty()) { + if (!release_delayed()) return false; + timeout_ms = 0; // keep polling the held completions + } + const int n = file.wait(got, 64, timeout_ms); + return process(got, n); + } + + void worker_loop() { + std::unique_lock lk(mu); + for (;;) { + cv_work.wait(lk, [&] { return stop || !queue.empty() || busy(); }); + if (stop && !busy()) break; + if (error.empty() && !pump() && error.empty()) error = "PleReader: submit failed"; + if (!delayed.empty() && !release_delayed() && error.empty()) error = "PleReader: read failed"; + if (!error.empty()) { + cv_done.notify_all(); + if (!busy()) { cv_work.wait(lk, [&] { return stop; }); break; } + } + if (!busy()) { cv_done.notify_all(); continue; } + // Block in the port WITHOUT the lock, so `issue` can queue work; `issue` wakes us with a packet. + const int timeout = delayed.empty() ? -1 : 0; + lk.unlock(); + Completion got[64]; + const int n = file.wait(got, 64, timeout); + lk.lock(); + if (!process(got, n) && error.empty()) error = "PleReader: read failed"; + cv_done.notify_all(); + } + } +}; + +PleReader::PleReader() : impl_(new Impl) {} +PleReader::~PleReader() { + close(); + delete impl_; +} + +bool PleReader::open(const std::string& path, uint64_t table_offset, uint64_t n_rows, uint32_t max_inflight, + uint64_t cache_rows, std::string& err, bool io_thread) { + close(); + if (max_inflight == 0 || max_inflight > 1024) { err = "PleReader: max_inflight must be 1..1024"; return false; } + if (!impl_->file.open(path, err)) return false; + if (table_offset + n_rows * (uint64_t) ROW_BYTES > impl_->file.size()) { + err = "PleReader: the table extends past the end of " + path; + close(); + return false; + } + impl_->table_offset = table_offset; + impl_->n_rows = n_rows; + impl_->max_inflight = max_inflight; + impl_->slab = (uint8_t*) DirectFile::alloc_aligned((size_t) max_inflight * 2 * PAGE); + if (impl_->slab == nullptr) { err = "PleReader: cannot allocate read buffers"; close(); return false; } + impl_->inflight.assign(max_inflight, Job{}); + impl_->free_slots.clear(); + for (uint32_t s = max_inflight; s-- > 0;) impl_->free_slots.push_back(s); + impl_->cache.init(cache_rows); + impl_->error.clear(); + reset_stats(); + impl_->stop = false; + impl_->threaded = io_thread; + if (io_thread) impl_->worker = std::thread([this] { impl_->worker_loop(); }); + return true; +} + +void PleReader::close() { + Impl& m = *impl_; + if (m.worker.joinable()) { + { + std::lock_guard lk(m.mu); + m.stop = true; + m.queue.clear(); // unsubmitted work is dropped; in-flight reads still drain + } + m.cv_work.notify_all(); + m.file.wake(); + m.worker.join(); + } + // Outstanding reads must finish before their buffers are released (caller-thread mode, or a worker that + // stopped on an error). + if (m.file.is_open()) { + while (m.free_slots.size() < m.max_inflight) { + Completion c[64]; + const int n = m.file.wait(c, 64, -1); + if (n == 0) break; + for (int i = 0; i < n; ++i) + if (c[i].tag != DirectFile::WAKE_TAG) m.free_slots.push_back((uint32_t) c[i].tag); + } + } + m.file.close(); + DirectFile::free_aligned(m.slab); + m.slab = nullptr; + m.queue.clear(); + m.tickets.clear(); + m.delayed.clear(); + m.inflight.clear(); + m.free_slots.clear(); + m.cache.init(0); + m.threaded = false; + m.stop = false; +} + +bool PleReader::is_open() const { return impl_->file.is_open(); } + +PleReader::Ticket PleReader::issue(const uint32_t* rows, size_t n, uint8_t* out_raw) { + Impl& m = *impl_; + std::unique_lock lk(m.mu, std::defer_lock); + if (m.threaded) lk.lock(); + const uint32_t id = m.next_ticket++; + if (m.next_ticket == 0) m.next_ticket = 1; + TicketState& ts = m.tickets[id]; + std::unordered_map by_page; // aligned offset -> index in `jobs` + std::vector jobs; + for (size_t i = 0; i < n; ++i) { + uint8_t* dst = out_raw + i * ROW_BYTES; + ++m.stats.requests; + if (rows[i] >= m.n_rows) { + std::memset(dst, 0, ROW_BYTES); + continue; + } + if (const uint8_t* hit = m.cache.find(rows[i])) { + std::memcpy(dst, hit, ROW_BYTES); + ++m.stats.cache_hits; + continue; + } + const uint64_t at = m.table_offset + (uint64_t) rows[i] * ROW_BYTES; + const uint64_t first = at / PAGE * PAGE; + const uint32_t length = (uint32_t) ((at + ROW_BYTES - 1) / PAGE * PAGE - first + PAGE); + auto f = by_page.find(first); + if (f != by_page.end()) { + Job& j = jobs[f->second]; + j.length = std::max(j.length, length); + j.uses.push_back(Use{rows[i], (uint32_t) (at - first), dst}); + ++m.stats.dedup_rows; + continue; + } + by_page.emplace(first, jobs.size()); + Job j; + j.offset = first; + j.length = length; + j.ticket = id; + j.uses.push_back(Use{rows[i], (uint32_t) (at - first), dst}); + jobs.push_back(std::move(j)); + } + // Sorted by offset: prefill chunks then read the SSD in near-sequential order. + std::sort(jobs.begin(), jobs.end(), [](const Job& a, const Job& b) { return a.offset < b.offset; }); + ts.pending = (uint32_t) jobs.size(); + for (Job& j : jobs) m.queue.push_back(std::move(j)); + if (m.threaded) { + lk.unlock(); + if (ts.pending > 0) { + m.cv_work.notify_one(); + m.file.wake(); // in case the worker is blocked in the port + } + } else if (!m.pump() && m.error.empty()) { + m.error = "PleReader: submit failed"; + } + return Ticket{id}; +} + +bool PleReader::collect(Ticket t, std::string& err) { + Impl& m = *impl_; + const double start = now_us(); + if (m.threaded) { + std::unique_lock lk(m.mu); + auto it = m.tickets.find(t.id); + if (it == m.tickets.end()) { err = "PleReader: unknown ticket"; return false; } + m.cv_done.wait(lk, [&] { return !m.error.empty() || m.tickets[t.id].pending == 0; }); + if (!m.error.empty()) { err = m.error; return false; } + m.stats.wait_us += now_us() - start; + m.tickets.erase(t.id); + return true; + } + auto it = m.tickets.find(t.id); + if (it == m.tickets.end()) { err = "PleReader: unknown ticket"; return false; } + while (it->second.pending > 0) { + if (!m.error.empty() || !m.drain(-1)) { + err = m.error.empty() ? "PleReader: read failed" : m.error; + return false; + } + it = m.tickets.find(t.id); + } + m.stats.wait_us += now_us() - start; + m.tickets.erase(it); + return true; +} + +void PleReader::set_injected_delay_us(double delay_us) { + std::lock_guard lk(impl_->mu); + impl_->delay_us = delay_us < 0 ? 0 : delay_us; +} +const ReaderStats& PleReader::stats() const { return impl_->stats; } +void PleReader::reset_stats() { + impl_->stats = ReaderStats{}; + impl_->ring_pos = 0; +} +uint64_t PleReader::cache_capacity() const { return impl_->cache.sets * WAYS; } +uint64_t PleReader::cache_size() const { return impl_->cache.used; } + +} // namespace strata::ngram diff --git a/src/ngram/ple_reader_test.cpp b/src/ngram/ple_reader_test.cpp new file mode 100644 index 00000000..37e55f3b --- /dev/null +++ b/src/ngram/ple_reader_test.cpp @@ -0,0 +1,237 @@ +// src/ngram/ple_reader_test.cpp - plan v0.3 P2: the direct SSD row reader against ground truth. +// +// ple_reader_test --selftest [--dir D] synthetic table file; CPU and disk only, no model, no GPU +// ple_reader_test --gguf SHARD2 [--rows N] the real table: Direct vs Mmap bytes for N random rows (+ the +// 16 rows of every token in --tokens FILE), with read latencies +// +// Every row the synthetic table holds encodes its own index, so a wrong offset, a straddle mishandled or a +// dedup slot mixed up shows as a mismatch rather than as plausible data. +#include "strata/kernels/ngram.hpp" +#include "strata/ngram/ple_reader.hpp" +#include "strata/platform/direct_file.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ng = strata::ngram; +namespace k = strata::kernels; +using strata::platform::now_us; + +namespace { + +int g_fail = 0; +#define CHECK(c, ...) \ + do { \ + if (!(c)) { \ + std::fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + std::fprintf(stderr, __VA_ARGS__); \ + std::fprintf(stderr, "\n"); \ + ++g_fail; \ + } \ + } while (0) + +constexpr uint64_t HEADER = 192; // the real shard's data offset, so rows are misaligned the same way + +void expected_row(uint32_t row, uint8_t* out) { + for (uint32_t b = 0; b < ng::ROW_BYTES; ++b) out[b] = (uint8_t) ((row * 2654435761u + b * 97u) >> 7); + std::memcpy(out, &row, 4); +} + +bool make_table(const std::string& path, uint32_t rows) { + std::ofstream f(path, std::ios::binary); + std::vector head(HEADER, 0xAB); + f.write((const char*) head.data(), (std::streamsize) head.size()); + uint8_t r[ng::ROW_BYTES]; + for (uint32_t i = 0; i < rows; ++i) { + expected_row(i, r); + f.write((const char*) r, ng::ROW_BYTES); + } + return (bool) f; +} + +bool check_rows(ng::PleReader& rd, const std::vector& rows, uint32_t n_rows, const char* what) { + std::vector out(rows.size() * ng::ROW_BYTES, 0xCC); + std::string err; + const auto t = rd.issue(rows.data(), rows.size(), out.data()); + if (!rd.collect(t, err)) { CHECK(false, "%s: collect failed: %s", what, err.c_str()); return false; } + uint8_t want[ng::ROW_BYTES]; + for (size_t i = 0; i < rows.size(); ++i) { + if (rows[i] >= n_rows) std::memset(want, 0, sizeof want); + else expected_row(rows[i], want); + if (std::memcmp(want, &out[i * ng::ROW_BYTES], ng::ROW_BYTES) != 0) { + CHECK(false, "%s: row %u (index %zu) differs", what, rows[i], i); + return false; + } + } + return true; +} + +int selftest(const std::string& dir) { + const uint32_t N = 500000; // 45 MB: large enough for thousands of distinct pages + const std::string path = dir + "/ple_reader_selftest.bin"; + if (!make_table(path, N)) { std::fprintf(stderr, "cannot write %s\n", path.c_str()); return 2; } + std::mt19937 rng(7); + for (bool thr : {false, true}) + for (uint64_t cache : {0ull, 4096ull}) { + for (uint32_t inflight : {1u, 8u, 64u}) { + ng::PleReader rd; + std::string err; + CHECK(rd.open(path, HEADER, N, inflight, cache, err, thr), "open: %s", err.c_str()); + // decode-shaped tickets: 16 random rows + for (int t = 0; t < 200; ++t) { + std::vector rows(16); + for (auto& r : rows) r = rng() % N; + check_rows(rd, rows, N, "decode"); + } + // rows that straddle a 4 KiB boundary: byte offset of row r is HEADER + 90 r + std::vector straddle; + for (uint32_t r = 0; r < N && straddle.size() < 64; ++r) { + const uint64_t a = HEADER + (uint64_t) r * ng::ROW_BYTES; + if (a / 4096 != (a + ng::ROW_BYTES - 1) / 4096) straddle.push_back(r); + } + check_rows(rd, straddle, N, "straddle"); + // duplicates, neighbours on one page, the first and last rows, and out-of-range rows + check_rows(rd, {5, 5, 6, 7, 5, 0, N - 1, N, 0xFFFFFFFFu, 44, 45}, N, "dedup/edges"); + // a prefill-shaped ticket much larger than the in-flight window + std::vector bulk(20000); + for (auto& r : bulk) r = rng() % N; + check_rows(rd, bulk, N, "bulk"); + // two tickets in flight at once, collected in reverse order + std::vector a(16), b(16); + for (auto& r : a) r = rng() % N; + for (auto& r : b) r = rng() % N; + std::vector oa(16 * ng::ROW_BYTES), ob(16 * ng::ROW_BYTES); + const auto ta = rd.issue(a.data(), 16, oa.data()); + const auto tb = rd.issue(b.data(), 16, ob.data()); + CHECK(rd.collect(tb, err) && rd.collect(ta, err), "two tickets: %s", err.c_str()); + uint8_t want[ng::ROW_BYTES]; + for (int i = 0; i < 16; ++i) { + expected_row(a[i], want); + CHECK(!std::memcmp(want, &oa[i * ng::ROW_BYTES], ng::ROW_BYTES), "ticket a row %d", i); + expected_row(b[i], want); + CHECK(!std::memcmp(want, &ob[i * ng::ROW_BYTES], ng::ROW_BYTES), "ticket b row %d", i); + } + if (cache > 0) CHECK(rd.stats().cache_hits > 0, "the row cache never hit"); + CHECK(rd.cache_size() <= rd.cache_capacity(), "row cache exceeded its bound"); + } + } + // fault injection: a 3 ms delay must be observed, and must not change the bytes + for (bool thr : {false, true}) { + ng::PleReader rd; + std::string err; + CHECK(rd.open(path, HEADER, N, 16, 0, err, thr), "open: %s", err.c_str()); + rd.set_injected_delay_us(3000); + std::vector rows(16); + for (auto& r : rows) r = rng() % N; + const double t0 = now_us(); + check_rows(rd, rows, N, "delayed"); + CHECK(now_us() - t0 >= 3000, "injected delay not observed (%.0f us)", now_us() - t0); + CHECK(rd.stats().late_injected > 0, "no read was held back"); + } + std::filesystem::remove(path); + std::printf("ple_reader selftest: %s\n", g_fail ? "FAILED" : "OK"); + return g_fail ? 1 : 0; +} + +int real(const std::string& gguf, int n_random, const std::string& tokens_path, uint32_t inflight, bool direct_first, + bool direct_only, bool sync_submit) { + k::PleTable mm, direct; + std::string err; + k::PleIoOptions mo; + mo.mode = k::PleIo::Mmap; + k::PleIoOptions dopt; + dopt.cache_rows = 0; // measure the SSD, not the cache + dopt.max_inflight = inflight; + dopt.io_thread = !sync_submit; + // `--direct-only` measures the direct path with NO mapping of the file alive anywhere in the process: a + // live section on the same file forces the file system to keep cached and non-cached views coherent. + if (!direct_only && !mm.open(gguf, err, mo)) { std::fprintf(stderr, "mmap open: %s\n", err.c_str()); return 2; } + if (!direct.open(gguf, err, dopt)) { std::fprintf(stderr, "direct open: %s\n", err.c_str()); return 2; } + std::vector> tickets; + std::mt19937_64 rng(11); + for (int t = 0; t < n_random / 16; ++t) { + std::vector r(16); + for (auto& x : r) x = (uint32_t) (rng() % direct.rows()); + tickets.push_back(r); + } + if (!tokens_path.empty()) { + std::ifstream f(tokens_path); + std::string text((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + for (char& c : text) if (c == ',') c = ' '; + std::istringstream in(text); + std::vector ids; + for (int32_t v; in >> v;) ids.push_back(v); + const k::PleConsts C = k::ple_artifact_consts(); + for (size_t i = 0; i < ids.size(); ++i) { + const int32_t prev[2] = {i >= 2 ? ids[i - 2] : k::TOKEN_NULL, i >= 1 ? ids[i - 1] : k::TOKEN_NULL}; + std::vector r(16); + k::ngram_rows(&ids[i], prev, 1, C, r.data()); + tickets.push_back(r); + } + } + std::vector a(k::NG_N_EMBD), b(k::NG_N_EMBD); + double t_mm = 0, t_dir = 0, t_issue = 0; + for (const auto& r : tickets) { + for (int pass = 0; pass < 2; ++pass) { + const bool do_direct = (pass == 0) == direct_first; + const double t0 = now_us(); + if (!do_direct) { + if (direct_only) continue; + mm.gather(r.data(), a.data()); + t_mm += now_us() - t0; + } else { + const double ti = now_us(); + const bool issued = direct.issue(r.data()); + t_issue += now_us() - ti; + if (!issued || !direct.collect(b.data(), err)) { + std::fprintf(stderr, "direct: %s\n", err.c_str()); + return 1; + } + t_dir += now_us() - t0; + } + } + if (!direct_only) + CHECK(!std::memcmp(a.data(), b.data(), a.size() * sizeof(float)), "token rows differ between mmap and direct"); + } + std::printf("tokens %zu: mmap %.1f us/token, direct %.1f us/token (issue on this thread %.1f us)\n%s\n", + tickets.size(), t_mm / (double) tickets.size(), t_dir / (double) tickets.size(), + t_issue / (double) tickets.size(), direct.io_report().c_str()); + std::printf("ple_reader real-table check: %s\n", g_fail ? "FAILED" : "OK (bit-identical)"); + return g_fail ? 1 : 0; +} + +} // namespace + +int main(int argc, char** argv) { + std::string gguf, dir = std::filesystem::temp_directory_path().string(), tokens; + int rows = 20000; + uint32_t inflight = 64; + bool direct_first = false; + bool direct_only = false; + bool sync_submit = false; + bool self = false; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--selftest") self = true; + else if (a == "--dir" && i + 1 < argc) dir = argv[++i]; + else if (a == "--gguf" && i + 1 < argc) gguf = argv[++i]; + else if (a == "--rows" && i + 1 < argc) rows = std::atoi(argv[++i]); + else if (a == "--tokens" && i + 1 < argc) tokens = argv[++i]; + else if (a == "--inflight" && i + 1 < argc) inflight = (uint32_t) std::atoi(argv[++i]); + else if (a == "--direct-first") direct_first = true; + else if (a == "--direct-only") direct_only = true; + else if (a == "--sync") sync_submit = true; + else { std::fprintf(stderr, "usage: ple_reader_test --selftest [--dir D] | --gguf SHARD2 [--rows N] [--tokens F]\n"); return 2; } + } + if (self) return selftest(dir); + if (!gguf.empty()) return real(gguf, rows, tokens, inflight, direct_first, direct_only, sync_submit); + std::fprintf(stderr, "nothing to do\n"); + return 2; +} diff --git a/src/plan/plan_main.cpp b/src/plan/plan_main.cpp new file mode 100644 index 00000000..5cf1fda3 --- /dev/null +++ b/src/plan/plan_main.cpp @@ -0,0 +1,73 @@ +// src/plan/plan_main.cpp - the `strata-plan` CLI: print the memory plan, or say why it does not close. +// +// The engine must adapt to the GPU it is running on, so the plan is computed from measured free VRAM and the +// requested context rather than configured by hand. This tool is that computation on its own, which is what +// makes it testable without a GPU. +#include "strata/plan/plan.hpp" + +#include +#include +#include +#include + +int main(int argc, char** argv) { + uint64_t ctx = 20480; + uint64_t free_vram = 12ull * 1000 * 1000 * 1000; // RTX 5070 nominal; overridden by --vram + strata::plan::Costs costs; + bool state_given = false; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto next = [&](uint64_t& out) { + if (i + 1 >= argc) { + std::fprintf(stderr, "%s needs a value\n", a.c_str()); + std::exit(2); + } + out = std::strtoull(argv[++i], nullptr, 10); + }; + if (a == "--max-context") + next(ctx); + else if (a == "--vram") + next(free_vram); + else if (a == "--dense") + next(costs.dense_bytes); + else if (a == "--embd") + next(costs.embd_bytes); + else if (a == "--workspace") + next(costs.workspace_bytes); + else if (a == "--state") { + next(costs.state_bytes); + state_given = true; + } else if (a == "--pool") { + uint64_t v = 0; + next(v); + free_vram = v; + } else if (a == "--help" || a == "-h") { + std::printf("usage: strata-plan [--max-context N] [--vram BYTES] [--dense B] [--embd B]\n" + " [--workspace B] [--state B] [--pool B]\n" + " --state defaults to the geometry's own GDN recurrence + conv history, NOT to 0:\n" + " the flag used to default to zero and nothing passed it, so the planner silently\n" + " planned without 117.7 MB (85 expert slots) of non-evictable state.\n"); + return 0; + } else { + std::fprintf(stderr, "unknown argument: %s\n", a.c_str()); + return 2; + } + } + // DEFAULT THE STATE FROM THE GEOMETRY. A `--state` that defaults to 0 and is never passed is not a + // default, it is an omission - and this one was worth 85 cache slots of overcommit. + if (!state_given) costs.state_bytes = strata::plan::state_bytes(strata::plan::Geometry{}); + // The pool is what the KV cache and the expert cache share. Derived from the card's free VRAM less the + // resident dense weights; `--pool` overrides it for reproducing the standard-tier numbers exactly. + const uint64_t pool = free_vram > costs.dense_bytes + costs.embd_bytes + ? free_vram - costs.dense_bytes - costs.embd_bytes + : 0; + try { + const strata::plan::Plan p = strata::plan::make_plan(ctx, strata::plan::Geometry{}, costs, + pool ? pool : strata::plan::vram_pool_bytes()); + std::printf("%s", strata::plan::to_string(p).c_str()); + return 0; + } catch (const strata::plan::DoesNotClose& e) { + std::fprintf(stderr, "strata-plan: %s\n", e.what()); + return 1; + } +} diff --git a/src/platform/direct_file.cpp b/src/platform/direct_file.cpp new file mode 100644 index 00000000..0e460174 --- /dev/null +++ b/src/platform/direct_file.cpp @@ -0,0 +1,241 @@ +// src/platform/direct_file.cpp - see include/strata/platform/direct_file.hpp. +#include "strata/platform/direct_file.hpp" + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#else +#include +#include +#include +#endif + +namespace strata::platform { + +double now_us() { + using namespace std::chrono; + return (double) duration_cast(steady_clock::now().time_since_epoch()).count() / 1000.0; +} + +void* DirectFile::alloc_aligned(size_t bytes) { +#if defined(_WIN32) + return VirtualAlloc(nullptr, bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); +#else + void* p = nullptr; + return posix_memalign(&p, alignment(), bytes) == 0 ? p : nullptr; +#endif +} + +void DirectFile::free_aligned(void* p) { + if (p == nullptr) return; +#if defined(_WIN32) + VirtualFree(p, 0, MEM_RELEASE); +#else + std::free(p); +#endif +} + +#if defined(_WIN32) +// ------------------------------------------------------------------------------------------------ Windows +namespace { +/// One in-flight request. The OVERLAPPED must be the first member so a completion packet's OVERLAPPED* +/// converts back to the request. +struct Req { + OVERLAPPED ov; + uint64_t tag; +}; +} // namespace + +struct DirectFile::Impl { + HANDLE file = INVALID_HANDLE_VALUE; + HANDLE port = nullptr; + uint64_t size = 0; + std::deque free_reqs; + std::vector all_reqs; + std::deque immediate; // requests that failed after being counted as queued + + Req* take() { + if (free_reqs.empty()) { + Req* r = new Req(); + all_reqs.push_back(r); + return r; + } + Req* r = free_reqs.front(); + free_reqs.pop_front(); + return r; + } +}; + +DirectFile::DirectFile() : impl_(new Impl) {} +DirectFile::~DirectFile() { + close(); + for (Req* r : impl_->all_reqs) delete r; + delete impl_; +} + +bool DirectFile::open(const std::string& path, std::string& err) { + close(); + const int wlen = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0); + std::wstring wpath((size_t) (wlen > 0 ? wlen : 1), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, wpath.data(), wlen); + // NO_BUFFERING: the cache manager never holds these pages, so the table cannot grow into RAM. + // RANDOM_ACCESS: no read-ahead. OVERLAPPED: many reads in flight, completed through the port below. + impl_->file = CreateFileW(wpath.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED | FILE_FLAG_RANDOM_ACCESS, nullptr); + if (impl_->file == INVALID_HANDLE_VALUE) { + err = "DirectFile: cannot open " + path + " (error " + std::to_string(GetLastError()) + ")"; + return false; + } + LARGE_INTEGER sz; + if (!GetFileSizeEx(impl_->file, &sz)) { + err = "DirectFile: cannot size " + path; + close(); + return false; + } + impl_->size = (uint64_t) sz.QuadPart; + impl_->port = CreateIoCompletionPort(impl_->file, nullptr, 0, 1); + if (impl_->port == nullptr) { + err = "DirectFile: CreateIoCompletionPort failed (error " + std::to_string(GetLastError()) + ")"; + close(); + return false; + } + // Completions of reads that finish synchronously must still be queued to the port, so every submit + // produces exactly one packet and `wait` is the only completion path. + return true; +} + +void DirectFile::close() { + if (impl_->port != nullptr) CloseHandle(impl_->port); + if (impl_->file != INVALID_HANDLE_VALUE) CloseHandle(impl_->file); + impl_->port = nullptr; + impl_->file = INVALID_HANDLE_VALUE; + impl_->size = 0; + impl_->immediate.clear(); +} + +bool DirectFile::is_open() const { return impl_->file != INVALID_HANDLE_VALUE; } +uint64_t DirectFile::size() const { return impl_->size; } + +bool DirectFile::submit(uint64_t offset, void* buffer, uint32_t length, uint64_t tag, std::string& err) { + if (!is_open()) { err = "DirectFile: not open"; return false; } + if (offset % alignment() || length % alignment() || ((uintptr_t) buffer) % alignment() || length == 0) { + err = "DirectFile: unaligned request"; + return false; + } + Req* r = impl_->take(); + std::memset(&r->ov, 0, sizeof r->ov); + r->ov.Offset = (DWORD) (offset & 0xFFFFFFFFull); + r->ov.OffsetHigh = (DWORD) (offset >> 32); + r->tag = tag; + if (!ReadFile(impl_->file, buffer, length, nullptr, &r->ov)) { + const DWORD e = GetLastError(); + if (e != ERROR_IO_PENDING) { + impl_->free_reqs.push_back(r); + if (e == ERROR_HANDLE_EOF) { // at or past end of file: a zero-byte completion + impl_->immediate.push_back(Completion{tag, 0, true}); + return true; + } + err = "DirectFile: ReadFile failed (error " + std::to_string(e) + ")"; + return false; + } + } + return true; +} + +int DirectFile::wait(Completion* out, int max, int timeout_ms) { + int n = 0; + while (n < max && !impl_->immediate.empty()) { + out[n++] = impl_->immediate.front(); + impl_->immediate.pop_front(); + } + if (n == max || !is_open()) return n; + OVERLAPPED_ENTRY entries[64]; + const ULONG want = (ULONG) (max - n < 64 ? max - n : 64); + ULONG got = 0; + const DWORD t = timeout_ms < 0 ? INFINITE : (DWORD) timeout_ms; + if (!GetQueuedCompletionStatusEx(impl_->port, entries, want, &got, n > 0 ? 0 : t, FALSE)) return n; + for (ULONG i = 0; i < got; ++i) { + if (entries[i].lpOverlapped == nullptr) { // a wake() packet, not a read + out[n++] = Completion{WAKE_TAG, 0, true}; + continue; + } + Req* r = (Req*) entries[i].lpOverlapped; + const uint32_t status = (uint32_t) r->ov.Internal; // an NTSTATUS + Completion c; + c.tag = r->tag; + c.bytes = entries[i].dwNumberOfBytesTransferred; + // STATUS_END_OF_FILE (0xC0000011) is a legal short read at the table's last page. + c.ok = status == 0 || status == 0xC0000011u; + out[n++] = c; + impl_->free_reqs.push_back(r); + } + return n; +} + +void DirectFile::wake() { + if (impl_->port != nullptr) PostQueuedCompletionStatus(impl_->port, 0, 0, nullptr); +} + +#else +// ------------------------------------------------------------------------------------------------ POSIX +// Phase L replaces this with io_uring. Until then a read completes inside `submit`, which is correct and +// keeps the tree compiling on Linux (plan v0.3 section 5.1 rule 4). +struct DirectFile::Impl { + int fd = -1; + uint64_t size = 0; + std::deque done; +}; + +DirectFile::DirectFile() : impl_(new Impl) {} +DirectFile::~DirectFile() { close(); delete impl_; } + +bool DirectFile::open(const std::string& path, std::string& err) { + close(); + impl_->fd = ::open(path.c_str(), O_RDONLY | O_DIRECT); + if (impl_->fd < 0) { err = "DirectFile: cannot open " + path; return false; } + struct stat st; + if (fstat(impl_->fd, &st) != 0) { err = "DirectFile: cannot size " + path; close(); return false; } + impl_->size = (uint64_t) st.st_size; + return true; +} + +void DirectFile::close() { + if (impl_->fd >= 0) ::close(impl_->fd); + impl_->fd = -1; + impl_->size = 0; + impl_->done.clear(); +} + +bool DirectFile::is_open() const { return impl_->fd >= 0; } +uint64_t DirectFile::size() const { return impl_->size; } + +bool DirectFile::submit(uint64_t offset, void* buffer, uint32_t length, uint64_t tag, std::string& err) { + if (offset % alignment() || length % alignment() || ((uintptr_t) buffer) % alignment() || length == 0) { + err = "DirectFile: unaligned request"; + return false; + } + const ssize_t got = pread(impl_->fd, buffer, length, (off_t) offset); + impl_->done.push_back(Completion{tag, got < 0 ? 0u : (uint32_t) got, got >= 0}); + return true; +} + +void DirectFile::wake() {} // reads complete inside submit; nothing ever blocks in wait + +int DirectFile::wait(Completion* out, int max, int) { + int n = 0; + while (n < max && !impl_->done.empty()) { + out[n++] = impl_->done.front(); + impl_->done.pop_front(); + } + return n; +} +#endif + +} // namespace strata::platform diff --git a/src/platform/memory.cpp b/src/platform/memory.cpp new file mode 100644 index 00000000..317c6d83 --- /dev/null +++ b/src/platform/memory.cpp @@ -0,0 +1,76 @@ +// src/platform/memory.cpp - see include/strata/platform/memory.hpp. +#include "strata/platform/memory.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#else +#include +#endif + +namespace strata::platform { + +#if defined(_WIN32) +LockResult lock_resident(void* p, uint64_t bytes) { + LockResult r; + if (p == nullptr || bytes == 0) { r.note = "nothing to lock"; return r; } + HANDLE self = GetCurrentProcess(); + SIZE_T min_ws = 0, max_ws = 0; + DWORD flags = 0; + if (!GetProcessWorkingSetSizeEx(self, &min_ws, &max_ws, &flags)) { + r.note = "GetProcessWorkingSetSizeEx failed (error " + std::to_string(GetLastError()) + ")"; + return r; + } + // Locked pages count against the minimum working set, so it must grow by the region plus headroom for the + // rest of the process. Soft limits: the maximum is not enforced, only the minimum is raised. + const SIZE_T margin = (SIZE_T) 512 << 20; + const SIZE_T new_min = min_ws + (SIZE_T) bytes + margin; + const SIZE_T new_max = max_ws > new_min + margin ? max_ws : new_min + margin; + if (!SetProcessWorkingSetSizeEx(self, new_min, new_max, + QUOTA_LIMITS_HARDWS_MIN_DISABLE | QUOTA_LIMITS_HARDWS_MAX_DISABLE)) { + r.note = "SetProcessWorkingSetSizeEx(" + std::to_string((unsigned long long) (new_min >> 20)) + + " MiB) failed (error " + std::to_string(GetLastError()) + ")"; + return r; + } + const uint64_t chunk = 1ull << 30; + uint8_t* base = (uint8_t*) p; + for (uint64_t off = 0; off < bytes; off += chunk) { + const uint64_t n = bytes - off < chunk ? bytes - off : chunk; + if (!VirtualLock(base + off, (SIZE_T) n)) { + r.note = "VirtualLock stopped at " + std::to_string((unsigned long long) (off >> 20)) + " of " + + std::to_string((unsigned long long) (bytes >> 20)) + " MiB (error " + + std::to_string(GetLastError()) + ")"; + r.ok = off > 0; + return r; + } + r.locked_bytes = off + n; + } + r.ok = true; + r.note = "locked " + std::to_string((unsigned long long) (bytes >> 20)) + " MiB via working-set minimum + VirtualLock"; + return r; +} + +void unlock_resident(void* p, uint64_t bytes) { + if (p == nullptr || bytes == 0) return; + const uint64_t chunk = 1ull << 30; + for (uint64_t off = 0; off < bytes; off += chunk) + VirtualUnlock((uint8_t*) p + off, (SIZE_T) (bytes - off < chunk ? bytes - off : chunk)); +} +#else +LockResult lock_resident(void* p, uint64_t bytes) { + LockResult r; + if (p == nullptr || bytes == 0) { r.note = "nothing to lock"; return r; } + if (mlock(p, bytes) != 0) { r.note = "mlock failed (raise ulimit -l)"; return r; } + r.ok = true; + r.locked_bytes = bytes; + r.note = "mlock"; + return r; +} + +void unlock_resident(void* p, uint64_t bytes) { + if (p != nullptr && bytes != 0) munlock(p, bytes); +} +#endif + +} // namespace strata::platform diff --git a/src/platform/memory_test.cpp b/src/platform/memory_test.cpp new file mode 100644 index 00000000..e5b4cde7 --- /dev/null +++ b/src/platform/memory_test.cpp @@ -0,0 +1,35 @@ +// src/platform/memory_test.cpp - plan v0.3 P0.1: lock_resident on a 256 MiB region (CPU only, no GPU, no model). +#include "strata/platform/memory.hpp" + +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#pragma comment(lib, "psapi.lib") +#endif + +int main() { + const uint64_t bytes = 256ull << 20; + void* p = std::malloc(bytes); + if (p == nullptr) return 2; + std::memset(p, 1, bytes); + const strata::platform::LockResult r = strata::platform::lock_resident(p, bytes); + std::printf("lock_resident: ok=%d locked=%llu MiB (%s)\n", (int) r.ok, (unsigned long long) (r.locked_bytes >> 20), + r.note.c_str()); + int fail = !(r.ok && r.locked_bytes == bytes); +#if defined(_WIN32) + PROCESS_MEMORY_COUNTERS pmc{}; + GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc); + std::printf("working set %llu MiB\n", (unsigned long long) (pmc.WorkingSetSize >> 20)); + fail |= pmc.WorkingSetSize < bytes; +#endif + strata::platform::unlock_resident(p, bytes); + std::free(p); + std::printf("platform_memory_test: %s\n", fail ? "FAILED" : "OK"); + return fail; +} diff --git a/src/prefill/gemm.cu b/src/prefill/gemm.cu new file mode 100644 index 00000000..2ecca2c6 --- /dev/null +++ b/src/prefill/gemm.cu @@ -0,0 +1,107 @@ +// src/prefill/gemm.cu - see include/strata/prefill/gemm.hpp. +#include "strata/prefill/gemm.hpp" +#include "strata/kernels/dequant_bf16.hpp" + +#include +#include + +#include +#include + +namespace strata::prefill { +namespace { + +void ck(cublasStatus_t s, const char* what) { + if (s != CUBLAS_STATUS_SUCCESS) { + std::fprintf(stderr, "prefill gemm: %s: cuBLAS status %d\n", what, (int) s); + std::exit(1); + } +} + +} // namespace + +Gemm::~Gemm() { + if (handle_) cublasDestroy((cublasHandle_t) handle_); + if (!external_) { + if (scratch_) cudaFree(scratch_); + if (workspace_) cudaFree(workspace_); + } +} + +bool Gemm::init_external(void* stream, uint16_t* scratch, int64_t scratch_elems, void* workspace, size_t ws_bytes, + std::string& err) { + cublasHandle_t h = nullptr; + if (cublasCreate(&h) != CUBLAS_STATUS_SUCCESS) { err = "prefill gemm: cublasCreate failed"; return false; } + handle_ = h; + stream_ = stream; + external_ = true; + cublasSetStream(h, (cudaStream_t) stream); + workspace_ = workspace; + cublasSetWorkspace(h, workspace_, ws_bytes); + cublasSetMathMode(h, CUBLAS_DEFAULT_MATH); + scratch_ = scratch; + scratch_elems_ = scratch_elems; + return true; +} + +bool Gemm::init(void* stream, int64_t scratch_elems, std::string& err) { + cublasHandle_t h = nullptr; + if (cublasCreate(&h) != CUBLAS_STATUS_SUCCESS) { err = "prefill gemm: cublasCreate failed"; return false; } + handle_ = h; + stream_ = stream; + cublasSetStream(h, (cudaStream_t) stream); + // A fixed workspace so the handle never allocates on the way (and graphs could capture it later). + const size_t ws = 32u << 20; + if (cudaMalloc(&workspace_, ws) != cudaSuccess) { err = "prefill gemm: workspace"; return false; } + cublasSetWorkspace(h, workspace_, ws); + cublasSetMathMode(h, CUBLAS_DEFAULT_MATH); + if (scratch_elems > 0 && cudaMalloc((void**) &scratch_, (size_t) scratch_elems * 2) != cudaSuccess) { + err = "prefill gemm: dequant scratch of " + std::to_string(scratch_elems * 2 >> 20) + " MiB"; + return false; + } + scratch_elems_ = scratch_elems; + return true; +} + +void Gemm::bf16(const uint16_t* X, const uint16_t* W, float* Y, int64_t T, int64_t N, int64_t K, int64_t ldy, + float beta) { + if (T <= 0 || N <= 0) return; + if (ldy <= 0) ldy = N; + const float alpha = 1.0f; + // Column-major view: Y^T[N, T] = W[N, K] (stored K x N col-major, transposed) . X^T[K, T]. + ck(cublasGemmEx((cublasHandle_t) handle_, CUBLAS_OP_T, CUBLAS_OP_N, (int) N, (int) T, (int) K, &alpha, W, + CUDA_R_16BF, (int) K, X, CUDA_R_16BF, (int) K, &beta, Y, CUDA_R_32F, (int) ldy, + CUBLAS_COMPUTE_32F, CUBLAS_GEMM_DEFAULT), + "cublasGemmEx"); +} + +void Gemm::f16(const uint16_t* X, const uint16_t* W, float* Y, int64_t T, int64_t N, int64_t K, int64_t ldy, + float beta) { + if (T <= 0 || N <= 0) return; + if (ldy <= 0) ldy = N; + const float alpha = 1.0f; + ck(cublasGemmEx((cublasHandle_t) handle_, CUBLAS_OP_T, CUBLAS_OP_N, (int) N, (int) T, (int) K, &alpha, W, + CUDA_R_16F, (int) K, X, CUDA_R_16F, (int) K, &beta, Y, CUDA_R_32F, (int) ldy, + CUBLAS_COMPUTE_32F, CUBLAS_GEMM_DEFAULT), + "cublasGemmEx f16"); +} + +void Gemm::native(const uint16_t* X, int ggml_type, const void* W_blocks, float* Y, int64_t T, int64_t N, int64_t K, + int64_t ldy, float beta) { + if (N * K > scratch_elems_) { + // Too large for the scratch at once: in row slices. + const int64_t rows = scratch_elems_ / K; + if (rows <= 0) { std::fprintf(stderr, "prefill gemm: scratch too small for K=%lld\n", (long long) K); std::exit(1); } + if (ldy <= 0) ldy = N; + for (int64_t r0 = 0; r0 < N; r0 += rows) { + const int64_t n = (N - r0 < rows) ? N - r0 : rows; + strata::kernels::dequant_f16(ggml_type, W_blocks, r0, n, K, scratch_, stream_); + f16(X, scratch_, Y + r0, T, n, K, ldy, beta); + } + return; + } + strata::kernels::dequant_f16(ggml_type, W_blocks, 0, N, K, scratch_, stream_); + f16(X, scratch_, Y, T, N, K, ldy, beta); +} + +} // namespace strata::prefill diff --git a/src/prefill/kernels.cu b/src/prefill/kernels.cu new file mode 100644 index 00000000..29b29a85 --- /dev/null +++ b/src/prefill/kernels.cu @@ -0,0 +1,482 @@ +// src/prefill/kernels.cu - see include/strata/prefill/kernels.hpp. +#include "strata/prefill/kernels.hpp" +#include "strata/kernels/mrope.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace strata::prefill { +namespace { + +constexpr int N = 2560, HC = 4, D = N * HC, LR = 320; +constexpr int S = 128, HK = 16, HV = 48, C = 10240; + +__device__ __forceinline__ float warp_sum(float v) { +#pragma unroll + for (int o = 16; o > 0; o >>= 1) v += __shfl_xor_sync(0xffffffffu, v, o); + return v; +} +__device__ __forceinline__ float warp_max(float v) { +#pragma unroll + for (int o = 16; o > 0; o >>= 1) v = fmaxf(v, __shfl_xor_sync(0xffffffffu, v, o)); + return v; +} +__device__ __forceinline__ uint16_t bf(float f) { + uint32_t u = __float_as_uint(f); + u += 0x7fffu + ((u >> 16) & 1u); + return (uint16_t) (u >> 16); +} +__device__ __forceinline__ float sigm(float x) { return 1.0f / (1.0f + __expf(-x)); } +__device__ __forceinline__ uint16_t hf(float f) { return __half_as_ushort(__float2half_rn(f)); } +// block-wide sum for blockDim.x <= 1024, result broadcast +__device__ float block_sum(float v, float* sh) { + const int lane = threadIdx.x & 31, w = threadIdx.x >> 5; + v = warp_sum(v); + __syncthreads(); + if (lane == 0) sh[w] = v; + __syncthreads(); + const int nw = (blockDim.x + 31) >> 5; + float t = (threadIdx.x < nw) ? sh[threadIdx.x] : 0.0f; + if (w == 0) t = warp_sum(t); + if (threadIdx.x == 0) sh[0] = t; + __syncthreads(); + return sh[0]; +} +void check(const char* what) { + const cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "prefill %s: %s\n", what, cudaGetErrorString(e)); std::exit(1); } +} +unsigned blocks_for(int64_t n, int t = 256) { return (unsigned) ((n + t - 1) / t); } + +// ---------------------------------------------------------------- hyper-connection +__global__ void gr_norm_kernel(const float* __restrict__ R, const float* __restrict__ w, float eps, + float* __restrict__ xn, uint16_t* __restrict__ xn16) { + __shared__ float sh[32]; + const int64_t row = blockIdx.x; // t * 4 + c + const int c = (int) (row % HC); + const float* r = R + row * N; + float ss = 0.0f; + for (int d = threadIdx.x; d < N; d += blockDim.x) ss += r[d] * r[d]; + const float rs = rsqrtf(block_sum(ss, sh) / (float) N + eps); + for (int d = threadIdx.x; d < N; d += blockDim.x) { + const float v = r[d] * rs * w[c * N + d]; + xn[row * N + d] = v; + xn16[row * N + d] = bf(v); + } +} +__global__ void gr_silu_kernel(const float* __restrict__ lo, uint16_t* __restrict__ lo16, int64_t n) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n) return; + const float x = lo[i] / (float) HC; + lo16[i] = bf(x / (1.0f + __expf(-x))); +} +__global__ void gr_mix_kernel(const float* __restrict__ xn, const float* __restrict__ g, float* __restrict__ mixed, + uint16_t* __restrict__ mixed16, int64_t T, uint16_t* __restrict__ mixed_h) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= T * N) return; + const int64_t t = i / N, d = i % N; + float s = 0.0f; +#pragma unroll + for (int c = 0; c < HC; ++c) { + const int64_t j = t * D + c * N + d; + s = fmaf(xn[j], sigm(g[j]), s); + } + s /= (float) HC; + mixed[i] = s; + if (mixed16) mixed16[i] = bf(s); + if (mixed_h) mixed_h[i] = hf(s); +} +__global__ void gr_write_kernel(float* __restrict__ R, const float* __restrict__ bo, const float* __restrict__ inj, + int64_t inj_ld, int64_t T) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= T * D) return; + const int64_t t = i / D, c = (i % D) / N, d = i % N; + R[i] = fmaf(bo[t * N + d], 2.0f * sigm(inj[t * inj_ld + c] / (float) HC), R[i]); +} +__global__ void gr_broadcast_kernel(const float* __restrict__ e, float* __restrict__ R, int64_t T) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= T * D) return; + const int64_t t = i / D, d = i % N; + R[i] = e[t * N + d]; +} + +// ---------------------------------------------------------------- GDN +__global__ void gdn_gates_kernel(const float* __restrict__ ab, const float* __restrict__ dt, + const float* __restrict__ ssm_a, float* __restrict__ gate, float* __restrict__ beta, + int64_t T) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= T * HV) return; + const int64_t t = i / HV, h = i % HV; + const float v = ab[t * 2 * HV + h] + dt[h]; + gate[i] = (v > 20.0f ? v : log1pf(__expf(v))) * ssm_a[h]; + beta[i] = sigm(ab[t * 2 * HV + HV + h]); +} +// one thread per channel, walks the chunk; then a second kernel normalises +__global__ void gdn_conv_kernel(float* __restrict__ hist, const float* __restrict__ qkv, const float* __restrict__ w, + float* __restrict__ h, int64_t T) { + const int c = blockIdx.x * blockDim.x + threadIdx.x; + if (c >= C) return; + float v0 = hist[c * 3], v1 = hist[c * 3 + 1], v2 = hist[c * 3 + 2]; + const float w0 = w[c * 4], w1 = w[c * 4 + 1], w2 = w[c * 4 + 2], w3 = w[c * 4 + 3]; + for (int64_t t = 0; t < T; ++t) { + const float x = qkv[t * C + c]; + const float s = v0 * w0 + v1 * w1 + v2 * w2 + x * w3; + h[t * C + c] = s / (1.0f + __expf(-s)); + v0 = v1; v1 = v2; v2 = x; + } + hist[c * 3] = v0; hist[c * 3 + 1] = v1; hist[c * 3 + 2] = v2; +} +__global__ void gdn_l2_kernel(float* __restrict__ h, float eps) { + // block (t, head) over the 32 q/k heads, 128 threads + const int64_t t = blockIdx.y; + const int head = blockIdx.x; + float* x = h + t * C + head * S; + const float v = x[threadIdx.x]; + float sq = warp_sum(v * v); + __shared__ float part[4]; + if ((threadIdx.x & 31) == 0) part[threadIdx.x >> 5] = sq; + __syncthreads(); + const float ss = part[0] + part[1] + part[2] + part[3]; + x[threadIdx.x] = v * rsqrtf(ss + eps); +} +constexpr int RG = 4, RPG = S / RG; +__global__ void __launch_bounds__(S * RG) gdn_rec_kernel(float* __restrict__ state, const float* __restrict__ h, + const float* __restrict__ gate, + const float* __restrict__ beta, const float* __restrict__ z, + const float* __restrict__ gamma, float eps, + float* __restrict__ y, uint16_t* __restrict__ y16, int64_t T) { + __shared__ float sk[S], sq[S], red[RG][S], wsum[16]; + const int head = blockIdx.x, col = threadIdx.x, rg = threadIdx.y, tid = rg * S + col; + const int qh = head % HK; + float s[RPG]; + float* base = state + ((size_t) (rg * RPG) * HV + head) * S + col; + const size_t rs = (size_t) HV * S; +#pragma unroll + for (int r = 0; r < RPG; ++r) s[r] = base[r * rs]; + const float g_col = gamma[col]; + for (int64_t t = 0; t < T; ++t) { + const float* ht = h + t * C; + __syncthreads(); + if (tid < S) { sq[tid] = ht[qh * S + tid]; sk[tid] = ht[HK * S + qh * S + tid]; } + __syncthreads(); + const float g = __expf(gate[t * HV + head]); + float kv = 0.0f; +#pragma unroll + for (int r = 0; r < RPG; ++r) kv = fmaf(s[r], sk[rg * RPG + r], kv); + red[rg][col] = kv; + __syncthreads(); + const float kv_col = red[0][col] + red[1][col] + red[2][col] + red[3][col]; + const float delta = (ht[2 * HK * S + head * S + col] - g * kv_col) * beta[t * HV + head]; + float o = 0.0f; +#pragma unroll + for (int r = 0; r < RPG; ++r) { + s[r] = fmaf(g, s[r], sk[rg * RPG + r] * delta); + o = fmaf(s[r], sq[rg * RPG + r], o); + } + __syncthreads(); + red[rg][col] = o; + __syncthreads(); + float oc = 0.0f, sp = 0.0f; + if (rg == 0) { + oc = (red[0][col] + red[1][col] + red[2][col] + red[3][col]) * rsqrtf((float) S); + sp = oc * oc; + } + sp = warp_sum(sp); + if ((tid & 31) == 0) wsum[tid >> 5] = sp; + __syncthreads(); + if (rg == 0) { + const float ss = wsum[0] + wsum[1] + wsum[2] + wsum[3]; + const float v = oc * rsqrtf(ss / (float) S + eps) * g_col * sigm(z[t * HV * S + head * S + col]); + y[t * HV * S + head * S + col] = v; + y16[t * HV * S + head * S + col] = hf(v); + } + } +#pragma unroll + for (int r = 0; r < RPG; ++r) base[r * rs] = s[r]; +} + +// ---------------------------------------------------------------- MoE +__global__ void route_kernel(const float* __restrict__ logits, int32_t* __restrict__ ids, float* __restrict__ wout, + int64_t T) { + const int64_t t = (int64_t) blockIdx.x * (blockDim.x >> 5) + (threadIdx.x >> 5); + if (t >= T) return; + const int lane = threadIdx.x & 31; + const float* lg = logits + t * 512; + float v[16]; +#pragma unroll + for (int i = 0; i < 16; ++i) v[i] = lg[lane + i * 32]; + float mx = -INFINITY; +#pragma unroll + for (int i = 0; i < 16; ++i) mx = fmaxf(mx, v[i]); + mx = warp_max(mx); + float sum = 0.0f; +#pragma unroll + for (int i = 0; i < 16; ++i) { v[i] = expf(v[i] - mx); sum += v[i]; } + const float rcp = 1.0f / warp_sum(sum); +#pragma unroll + for (int i = 0; i < 16; ++i) { v[i] *= rcp; if (isnan(v[i])) v[i] = -FLT_MAX; } + float selected = 0.0f, selected_sum = 0.0f; + for (int rank = 0; rank < 10; ++rank) { + float best = v[0]; + int ex = lane; +#pragma unroll + for (int i = 1; i < 16; ++i) if (v[i] > best) { best = v[i]; ex = lane + i * 32; } +#pragma unroll + for (int m = 16; m; m >>= 1) { + const float ob = __shfl_xor_sync(0xffffffffu, best, m); + const int oi = __shfl_xor_sync(0xffffffffu, ex, m); + if (ob > best || (ob == best && oi < ex)) { best = ob; ex = oi; } + } + if ((ex & 31) == lane) { v[ex / 32] = -INFINITY; selected_sum += best; } + if (lane == 0) ids[t * 10 + rank] = ex; + if (rank == lane) selected = best; + } + selected_sum = fmaxf(warp_sum(selected_sum), 6.103515625e-5f); + if (lane < 10) wout[t * 10 + lane] = selected / selected_sum; +} +// Strata blob: gate/up codes [1280][640 B], down codes [2560][160 B], gate/up scales [1280][40] f16, down scales [2560][10] f16 +template +__global__ void blob_dequant_kernel(const uint8_t* __restrict__ blob, uint16_t* __restrict__ gu16, + uint16_t* __restrict__ d16) { + constexpr size_t O_D_CODES = (size_t) 1280 * 640, O_GU_SC = O_D_CODES + (size_t) 2560 * 160, + O_D_SC = O_GU_SC + (size_t) 1280 * 40 * 2; + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; // one thread per 4 weights (one code byte) + const int64_t n_gu = 1280LL * 640, n_d = 2560LL * 160; + if (i < n_gu) { + const int64_t row = i / 640, byte = i % 640; + const uint8_t c = blob[row * 640 + byte]; + const uint8_t* sp = blob + O_GU_SC + (size_t) (row * 40 + (byte * 4) / 64) * 2; + const float d = __half2float(__ushort_as_half((uint16_t) (sp[0] | (sp[1] << 8)))); + uint16_t* o = gu16 + row * 2560 + byte * 4; +#pragma unroll + for (int k = 0; k < 4; ++k) { const float v = (float) (((c >> (2 * k)) & 3) - 1) * d; o[k] = HALF ? hf(v) : bf(v); } + } else if (i < n_gu + n_d) { + const int64_t j = i - n_gu, row = j / 160, byte = j % 160; + const uint8_t c = blob[O_D_CODES + row * 160 + byte]; + const uint8_t* sp = blob + O_D_SC + (size_t) (row * 10 + (byte * 4) / 64) * 2; + const float d = __half2float(__ushort_as_half((uint16_t) (sp[0] | (sp[1] << 8)))); + uint16_t* o = d16 + row * 640 + byte * 4; +#pragma unroll + for (int k = 0; k < 4; ++k) { const float v = (float) (((c >> (2 * k)) & 3) - 1) * d; o[k] = HALF ? hf(v) : bf(v); } + } +} +__global__ void swiglu_il_kernel(const float* __restrict__ gu, uint16_t* __restrict__ h16, int64_t n) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n * 640) return; + const int64_t r = i / 640, k = i % 640; + const float g = gu[r * 1280 + 2 * k], u = gu[r * 1280 + 2 * k + 1]; + h16[i] = hf(g / (1.0f + __expf(-g)) * u); +} +__global__ void swiglu_pair_kernel(const float* __restrict__ g, const float* __restrict__ u, uint16_t* __restrict__ h16, + int64_t n) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n * 640) return; + const float a = g[i]; + h16[i] = hf(a / (1.0f + __expf(-a)) * u[i]); +} +__global__ void gather_rows16_kernel(const uint16_t* __restrict__ x, const int32_t* __restrict__ src, + uint16_t* __restrict__ dst, int64_t n, int64_t width) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; // one uint4 (8 bf16) + const int64_t per = width / 8; + if (i >= n * per) return; + const int64_t r = i / per, j = i % per; + reinterpret_cast(dst)[r * per + j] = reinterpret_cast(x)[(int64_t) src[r] * per + j]; +} +__global__ void moe_combine_kernel(const float* __restrict__ Dm, const int32_t* __restrict__ slot, + const float* __restrict__ w, const float* __restrict__ shared, + const float* __restrict__ sg, float* __restrict__ bo, int64_t T) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= T * N) return; + const int64_t t = i / N, d = i % N; + float s = 0.0f; +#pragma unroll + for (int k = 0; k < 10; ++k) s = fmaf(w[t * 10 + k], Dm[(int64_t) slot[t * 10 + k] * N + d], s); + bo[i] = s + shared[i] * sigm(sg[t]); +} + +// ---------------------------------------------------------------- QSA helpers +__global__ void rms_rows_kernel(float* __restrict__ x, const float* __restrict__ w, int64_t cols, int64_t ld, float eps) { + __shared__ float sh[32]; + float* r = x + (int64_t) blockIdx.x * ld; + float ss = 0.0f; + for (int64_t c = threadIdx.x; c < cols; c += blockDim.x) ss += r[c] * r[c]; + const float s = rsqrtf(block_sum(ss, sh) / (float) cols + eps); + __syncthreads(); + for (int64_t c = threadIdx.x; c < cols; c += blockDim.x) r[c] = s * r[c] * w[c]; +} +__global__ void rope_kernel(float* __restrict__ x, int64_t heads, int64_t dim, int64_t ld, int64_t pos0, + float theta_scale, const int32_t* __restrict__ mtab) { + const int64_t row = blockIdx.x; // t * heads + h + const int pair = threadIdx.x; // 0..31 + const int64_t t = row / heads, h = row % heads; + float* p = x + t * ld + h * dim; + const float theta = (float) strata::kernels::mrope_pos(mtab, (int) (pos0 + t), pair) * powf(theta_scale, (float) pair); + const float c = cosf(theta), s = sinf(theta); + const float a = p[pair], b = p[pair + 32]; + p[pair] = a * c - b * s; + p[pair + 32] = a * s + b * c; +} +__global__ void split_q_kernel(const float* __restrict__ qf, float* __restrict__ q, int64_t T) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= T * 24 * 256) return; + const int64_t t = i / (24 * 256), h = (i / 256) % 24, d = i % 256; + q[i] = qf[t * 24 * 512 + h * 512 + d]; +} +__global__ void gate_attn_kernel(const float* __restrict__ a, const float* __restrict__ qf, uint16_t* __restrict__ o16, + int64_t T) { + const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; + if (i >= T * 24 * 256) return; + const int64_t t = i / (24 * 256), h = (i / 256) % 24, d = i % 256; + o16[i] = hf(a[i] * (1.0f / (1.0f + expf(-qf[t * 24 * 512 + h * 512 + 256 + d])))); +} + +// one block per (token, kv head, 64-value group); 64 threads +__global__ void kv_append_kernel(const float* __restrict__ K, const float* __restrict__ V, int64_t pos0, + const int32_t* __restrict__ table, int64_t page_size, uint16_t* k_pool, + uint16_t* v_pool, int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale) { + const int64_t t = blockIdx.x; + const int kvh = blockIdx.y, g = blockIdx.z >> 1; + const bool is_v = (blockIdx.z & 1) != 0; + const int d = g * 64 + threadIdx.x; + const float x = (is_v ? V : K)[t * 512 + kvh * 256 + d]; + const int64_t pos = pos0 + t; + const int64_t page = table[pos / page_size]; + const int64_t row = (page * 2 + kvh) * page_size + pos % page_size; + if (k_pool != nullptr) { + (is_v ? v_pool : k_pool)[row * 256 + d] = hf(x); + return; + } + float a = fabsf(x); + for (int o = 16; o > 0; o >>= 1) a = fmaxf(a, __shfl_xor_sync(0xffffffffu, a, o)); + __shared__ float wm[2]; + if ((threadIdx.x & 31) == 0) wm[threadIdx.x >> 5] = a; + __syncthreads(); + const float amax = fmaxf(wm[0], wm[1]); + const uint16_t sb = hf(amax / 127.0f); + const float sf = __half2float(__ushort_as_half(sb)); + int q = 0; + if (sf > 0.0f) { q = __float2int_rn(x / sf); q = q < -127 ? -127 : (q > 127 ? 127 : q); } + (is_v ? v_q : k_q)[row * 256 + d] = (int8_t) q; + if (threadIdx.x == 0) (is_v ? v_scale : k_scale)[row * 4 + g] = sb; +} +__global__ void to_f16_kernel(const float* __restrict__ x, uint16_t* __restrict__ y, int64_t n) { + for (int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; i < n; i += (int64_t) gridDim.x * blockDim.x) + y[i] = hf(x[i]); +} +__global__ void to_bf16_kernel(const float* __restrict__ x, uint16_t* __restrict__ y, int64_t n) { + for (int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; i < n; i += (int64_t) gridDim.x * blockDim.x) + y[i] = bf(x[i]); +} + +} // namespace + +void kv_append(const float* K, const float* V, int64_t T, int64_t pos0, const int32_t* page_table, int64_t page_size, + uint16_t* k_pool, uint16_t* v_pool, int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale, + void* stream) { + if (T <= 0) return; + kv_append_kernel<<>>(K, V, pos0, page_table, page_size, k_pool, + v_pool, k_q, v_q, k_scale, v_scale); + check("kv_append"); +} +void to_f16(const float* x, uint16_t* y, int64_t n, void* stream) { + if (n <= 0) return; + to_f16_kernel<<<(unsigned) ((n + 255) / 256 < 4096 ? (n + 255) / 256 : 4096), 256, 0, (cudaStream_t) stream>>>(x, y, n); + check("to_f16"); +} +void to_bf16(const float* x, uint16_t* y, int64_t n, void* stream) { + if (n <= 0) return; + to_bf16_kernel<<<(unsigned) ((n + 255) / 256 < 4096 ? (n + 255) / 256 : 4096), 256, 0, (cudaStream_t) stream>>>(x, y, n); + check("to_bf16"); +} + +void gr_norm(const float* R, const float* w_norm, float eps, float* xn, uint16_t* xn16, int64_t T, void* stream) { + gr_norm_kernel<<<(unsigned) (T * HC), 256, 0, (cudaStream_t) stream>>>(R, w_norm, eps, xn, xn16); + check("gr_norm"); +} +void gr_silu(const float* lo, uint16_t* lo16, int64_t T, void* stream) { + gr_silu_kernel<<>>(lo, lo16, T * LR); + check("gr_silu"); +} +void gr_mix(const float* xn, const float* gated, float* mixed, uint16_t* mixed16, int64_t T, void* stream, + uint16_t* mixed_h) { + gr_mix_kernel<<>>(xn, gated, mixed, mixed16, T, mixed_h); + check("gr_mix"); +} +void gr_write(float* R, const float* bo, const float* inj, int64_t inj_ld, int64_t T, void* stream) { + gr_write_kernel<<>>(R, bo, inj, inj_ld, T); + check("gr_write"); +} +void gr_broadcast(const float* e, float* R, int64_t T, void* stream) { + gr_broadcast_kernel<<>>(e, R, T); + check("gr_broadcast"); +} +void gdn_gates(const float* ab, const float* dt, const float* ssm_a, float* gate, float* beta, int64_t T, void* stream) { + gdn_gates_kernel<<>>(ab, dt, ssm_a, gate, beta, T); + check("gdn_gates"); +} +void gdn_conv(float* history, const float* qkv, const float* conv_w, float* h, int64_t T, float eps, void* stream) { + gdn_conv_kernel<<>>(history, qkv, conv_w, h, T); + gdn_l2_kernel<<>>(h, eps); + check("gdn_conv"); +} +void gdn_recurrence(float* state, const float* h, const float* gate, const float* beta, const float* z, + const float* gamma, float eps, float* y, uint16_t* y16, int64_t T, void* stream) { + gdn_rec_kernel<<>>(state, h, gate, beta, z, gamma, eps, y, y16, T); + check("gdn_recurrence"); +} +void route(const float* logits, int32_t* ids, float* weights, int64_t T, void* stream) { + route_kernel<<<(unsigned) ((T + 7) / 8), 256, 0, (cudaStream_t) stream>>>(logits, ids, weights, T); + check("route"); +} +void blob_dequant(const uint8_t* blob, uint16_t* gu16, uint16_t* down16, void* stream) { + blob_dequant_kernel<<>>(blob, gu16, down16); + check("blob_dequant"); +} +void blob_dequant_f16(const uint8_t* blob, uint16_t* gu16, uint16_t* down16, void* stream) { + blob_dequant_kernel<<>>(blob, gu16, down16); + check("blob_dequant_f16"); +} +void swiglu_interleaved(const float* gu, uint16_t* h16, int64_t n, void* stream) { + if (n <= 0) return; + swiglu_il_kernel<<>>(gu, h16, n); + check("swiglu_interleaved"); +} +void swiglu_pair(const float* g, const float* u, uint16_t* h16, int64_t n, void* stream) { + swiglu_pair_kernel<<>>(g, u, h16, n); + check("swiglu_pair"); +} +void gather_rows16(const uint16_t* x16, const int32_t* src, uint16_t* dst16, int64_t n, int64_t width, void* stream) { + if (n <= 0) return; + gather_rows16_kernel<<>>(x16, src, dst16, n, width); + check("gather_rows16"); +} +void moe_combine(const float* Dm, const int32_t* slot, const float* w, const float* shared, const float* sg, float* bo, + int64_t T, void* stream) { + moe_combine_kernel<<>>(Dm, slot, w, shared, sg, bo, T); + check("moe_combine"); +} +void rms_rows(float* x, const float* w, int64_t rows, int64_t cols, int64_t ld, float eps, void* stream) { + if (rows <= 0) return; + rms_rows_kernel<<<(unsigned) rows, 256, 0, (cudaStream_t) stream>>>(x, w, cols, ld, eps); + check("rms_rows"); +} +void rope(float* x, int64_t T, int64_t heads, int64_t dim, int64_t ld, int64_t pos0, float freq_base, void* stream) { + const float theta_scale = powf(freq_base, -2.0f / 64.0f); + rope_kernel<<<(unsigned) (T * heads), 32, 0, (cudaStream_t) stream>>>(x, heads, dim, ld, pos0, theta_scale, strata::kernels::mrope_table()); + check("rope"); +} +void split_q(const float* q_full, float* q, int64_t T, void* stream) { + split_q_kernel<<>>(q_full, q, T); + check("split_q"); +} +void gate_attn(const float* attn, const float* q_full, uint16_t* out16, int64_t T, void* stream) { + gate_attn_kernel<<>>(attn, q_full, out16, T); + check("gate_attn"); +} + +} // namespace strata::prefill diff --git a/src/prefill/prefill.cpp b/src/prefill/prefill.cpp new file mode 100644 index 00000000..6c9aa5e7 --- /dev/null +++ b/src/prefill/prefill.cpp @@ -0,0 +1,537 @@ +// src/prefill/prefill.cpp - see include/strata/prefill/prefill.hpp. +#include "strata/prefill/prefill.hpp" + +#include "strata/core/layout.hpp" +#include "strata/kernels/cpu/expert.hpp" +#include "strata/kernels/native_qsa_indexer.hpp" +#include "strata/kernels/ngram.hpp" +#include "strata/kernels/ple.hpp" +#include "strata/kernels/iq_kernels.hpp" +#include "strata/kernels/cpu/expert_layout.hpp" +#include "strata/kernels/qsa.hpp" +#include "strata/kernels/qsa_decode_attn.hpp" +#include "strata/kernels/qsa_select.hpp" +#include "strata/prefill/gemm.hpp" +#include "strata/prefill/kernels.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace strata::prefill { +namespace { + +using Clock = std::chrono::steady_clock; +constexpr float EPS = 1e-6f; +constexpr int64_t N = 2560, HC = 4, D = N * HC, LR = 320, K = 10, NE = 512; +constexpr int64_t C = 10240, ZV = 6144, HV = 48; +// plan v0.3 P6: staging holds the largest blob of the pack (a native pack's blobs differ per layer) +inline int64_t MAXBLOB() { return (int64_t) strata::kernels::cpu::expert_layout().max_blob; } +constexpr int STAGE = 8; // host->device expert staging ring +constexpr int DQ = 2; // dequantized-expert ring (FP16 gate/up + down) + +double ms_since(Clock::time_point t) { return std::chrono::duration(Clock::now() - t).count(); } + +// Either cudaMalloc (owned, freed with the object) or a bump allocation from a borrowed region; with no base and +// no region it only counts, which is how `bytes_needed` sizes the region. +struct Alloc { + uint8_t* base = nullptr; + uint64_t cap = 0, used = 0; + bool count_only = false; + std::vector* owned = nullptr; + template T* take(size_t n, bool& ok) { + const uint64_t bytes = ((uint64_t) n * sizeof(T) + 256 + 255) & ~255ull; + if (count_only) { used += bytes; return nullptr; } + if (base != nullptr) { + if (used + bytes > cap) { ok = false; return nullptr; } + T* p = (T*) (base + used); + used += bytes; + return p; + } + void* p = nullptr; + if (cudaMalloc(&p, bytes) != cudaSuccess) { ok = false; return nullptr; } + owned->push_back(p); + used += bytes; + return (T*) p; + } +}; + +} // namespace + +struct Prefill::Impl { + const core::WeightTable* wt = nullptr; + const core::ModelGeometry* g = nullptr; + core::SessionState* ss = nullptr; + core::ExpertSource* src = nullptr; + const core::ExpertCache* cache = nullptr; + const int32_t* host_res = nullptr; + int64_t T = 0; + cudaStream_t cs = nullptr, copy = nullptr; + Gemm gemm; + std::vector owned; + // chunk buffers + float *emb = nullptr, *R = nullptr, *xn = nullptr, *lo = nullptr, *gated = nullptr, *inj = nullptr; + uint16_t *xn16 = nullptr, *lo16 = nullptr; + float* mixed = nullptr; + uint16_t *mixed_bf = nullptr, *mixed_h = nullptr; + float* bo = nullptr; + // GDN + float *qkv = nullptr, *z = nullptr, *ab = nullptr, *gate = nullptr, *beta = nullptr, *hbuf = nullptr, *y = nullptr; + uint16_t* y_h = nullptr; + // QSA + float *Kc = nullptr, *Vc = nullptr, *Qf = nullptr, *q = nullptr, *idx_raw = nullptr, *q_idx = nullptr, *attn = nullptr; + uint16_t* attn_h = nullptr; + int32_t* steps_dev = nullptr; + std::vector steps_host; + int32_t* sel_ids = nullptr; + float* sel_scores = nullptr; // [sel_batch, max_blocks] + int64_t sel_batch = 256, max_blocks = 0; + float* attn_scratch = nullptr; + int64_t attn_batch = 32, cap = 0; + // MoE + float *logits = nullptr, *w = nullptr, *GU = nullptr, *Dm = nullptr, *sgate = nullptr, *sup = nullptr, + *shared = nullptr, *sg = nullptr; + int32_t *ids = nullptr, *slot_dev = nullptr, *src_dev = nullptr; + uint16_t *Xs = nullptr, *Hh = nullptr, *sh_h = nullptr; + std::vector ids_host, slot_host, src_host, cnt, off; + uint16_t* dq_gu[DQ] = {}; + uint16_t* dq_d[DQ] = {}; + uint8_t* stage_dev[STAGE] = {}; + uint8_t* stage_host[STAGE] = {}; + cudaEvent_t copied[STAGE] = {}, used[STAGE] = {}; + bool stage_live[STAGE] = {}; + // PLE + float* ple_emb = nullptr; + std::vector ple_emb_host; + std::vector ple_rows; + float* ple_norm = nullptr; + PrefillStats* stats = nullptr; +}; + +Prefill::Prefill() : impl_(new Impl) {} +Prefill::~Prefill() { + if (!impl_) return; + if (impl_->cs) cudaStreamSynchronize(impl_->cs); + for (int i = 0; i < STAGE; ++i) { + if (impl_->copied[i]) cudaEventDestroy(impl_->copied[i]); + if (impl_->used[i]) cudaEventDestroy(impl_->used[i]); + if (impl_->stage_host[i]) cudaFreeHost(impl_->stage_host[i]); + } + if (impl_->copy) cudaStreamDestroy(impl_->copy); + for (void* p : impl_->owned) cudaFree(p); +} + +namespace { +constexpr int64_t GEMM_SCRATCH = 32ll << 20; // FP16 elements for the largest dequantized dense weight +constexpr size_t GEMM_WS = 32u << 20; // cuBLAS workspace +} + +bool Prefill::init(const core::WeightTable& wt, const core::ModelGeometry& g, core::SessionState& ss, + core::ExpertSource* src, const core::ExpertCache* cache, const int32_t* host_res, int64_t chunk, + void* stream, std::string& err, void* borrow, uint64_t borrow_bytes) { + Impl& m = *impl_; + m.wt = &wt; m.g = &g; m.ss = &ss; m.src = src; m.cache = cache; m.host_res = host_res; + m.T = chunk; m.cs = (cudaStream_t) stream; m.stats = &stats_; + if (g.n_embd != N || g.hc != HC || g.hc_lr != LR || g.n_expert != NE || ss.k != K) { + err = "prefill: geometry differs from the artifact's"; return false; + } + if (cudaStreamCreateWithFlags(&m.copy, cudaStreamNonBlocking) != cudaSuccess) { err = "prefill: copy stream"; return false; } + const size_t T = (size_t) chunk; + bool ok = true; + Alloc o; + o.base = (uint8_t*) borrow; + o.cap = borrow_bytes; + o.owned = &m.owned; + { + uint16_t* gs = o.take((size_t) GEMM_SCRATCH, ok); + void* ws = o.take(GEMM_WS, ok); + if (!ok) { err = "prefill: GEMM scratch does not fit"; return false; } + if (!m.gemm.init_external(stream, gs, GEMM_SCRATCH, ws, GEMM_WS, err)) return false; + } + m.emb = o.take(T * N, ok); m.R = o.take(T * D, ok); m.xn = o.take(T * D, ok); + m.xn16 = o.take(T * D, ok); m.lo = o.take(T * LR, ok); m.lo16 = o.take(T * LR, ok); + m.gated = o.take(T * D, ok); m.inj = o.take(T * HC, ok); + m.mixed = o.take(T * N, ok); m.mixed_bf = o.take(T * N, ok); + m.mixed_h = o.take(T * N, ok); m.bo = o.take(T * N, ok); + m.qkv = o.take(T * C, ok); m.z = o.take(T * ZV, ok); m.ab = o.take(T * 2 * HV, ok); + m.gate = o.take(T * HV, ok); m.beta = o.take(T * HV, ok); m.hbuf = o.take(T * C, ok); + m.y = o.take(T * ZV, ok); m.y_h = o.take(T * ZV, ok); + m.Kc = o.take(T * 512, ok); m.Vc = o.take(T * 512, ok); m.Qf = o.take(T * 12288, ok); + m.q = o.take(T * ZV, ok); m.idx_raw = o.take(T * 128, ok); m.q_idx = o.take(T * 512, ok); + m.attn = o.take(T * ZV, ok); m.attn_h = o.take(T * ZV, ok); + m.steps_dev = o.take(T * strata::kernels::kStepCount, ok); + m.steps_host.resize(T * strata::kernels::kStepCount); + strata::kernels::QsaShapes s = strata::kernels::qsa_real_shapes(); + s.n_head = g.n_head; s.n_head_kv = g.n_head_kv; s.head_dim = g.head_dim; s.idx_n_head = g.idx_q_heads; + s.idx_dim = g.idx_key_dim; + m.cap = strata::kernels::qsa_selection_width(strata::kernels::kTopkMaxCells, s); + m.sel_ids = o.take(T * (size_t) m.cap, ok); + m.max_blocks = ss.qsa_states[0].max_cells / s.idx_block + 2; + m.sel_scores = o.take((size_t) m.sel_batch * (size_t) m.max_blocks, ok); + m.attn_scratch = o.take((size_t) m.attn_batch * strata::kernels::qsa_decode_attn_scratch_floats(m.cap, s), ok); + m.logits = o.take(T * NE, ok); m.w = o.take(T * K, ok); m.ids = o.take(T * K, ok); + m.slot_dev = o.take(T * K, ok); m.src_dev = o.take(T * K, ok); + m.Xs = o.take(T * K * N, ok); m.GU = o.take(T * K * 1280, ok); + m.Hh = o.take(T * K * 640, ok); m.Dm = o.take(T * K * N, ok); + m.sgate = o.take(T * 640, ok); m.sup = o.take(T * 640, ok); m.sh_h = o.take(T * 640, ok); + m.shared = o.take(T * N, ok); m.sg = o.take(T, ok); + for (int i = 0; i < DQ; ++i) { m.dq_gu[i] = o.take(1280 * 2560, ok); m.dq_d[i] = o.take(2560 * 640, ok); } + for (int i = 0; i < STAGE; ++i) { + m.stage_dev[i] = o.take((size_t) MAXBLOB(), ok); + if (cudaHostAlloc((void**) &m.stage_host[i], (size_t) MAXBLOB(), cudaHostAllocDefault) != cudaSuccess) ok = false; + if (cudaEventCreateWithFlags(&m.copied[i], cudaEventDisableTiming) != cudaSuccess) ok = false; + if (cudaEventCreateWithFlags(&m.used[i], cudaEventDisableTiming) != cudaSuccess) ok = false; + } + m.ple_emb = o.take(T * N, ok); + m.ple_norm = o.take((size_t) strata::kernels::NG_HC_DIM, ok); + m.ids_host.resize(T * K); m.slot_host.resize(T * K); m.src_host.resize(T * K); m.cnt.resize(NE); m.off.resize(NE + 1); + m.ple_emb_host.resize(T * N); m.ple_rows.resize(T * strata::kernels::PLE_N_HEADS); + if (!ok) { err = "prefill: device buffers for a chunk of " + std::to_string(chunk) + " tokens do not fit"; return false; } + return true; +} + +uint64_t Prefill::bytes_needed(const core::ModelGeometry& g, const core::SessionState& ss, int64_t chunk) { + // the same allocation sequence as `init`, counted + (void) g; + const size_t T = (size_t) chunk; + bool ok = true; + Alloc o; + o.count_only = true; + o.take((size_t) GEMM_SCRATCH, ok); + o.take(GEMM_WS, ok); + auto f = [&](size_t n) { o.take(n, ok); }; + f(T * N); f(T * D); f(T * D); o.take(T * D, ok); f(T * LR); o.take(T * LR, ok); + f(T * D); f(T * HC); f(T * N); o.take(T * N, ok); o.take(T * N, ok); f(T * N); + f(T * C); f(T * ZV); f(T * 2 * HV); f(T * HV); f(T * HV); f(T * C); f(T * ZV); o.take(T * ZV, ok); + f(T * 512); f(T * 512); f(T * 12288); f(T * ZV); f(T * 128); f(T * 512); f(T * ZV); o.take(T * ZV, ok); + o.take(T * strata::kernels::kStepCount, ok); + strata::kernels::QsaShapes s = strata::kernels::qsa_real_shapes(); + const int64_t cap = strata::kernels::qsa_selection_width(strata::kernels::kTopkMaxCells, s); + o.take(T * (size_t) cap, ok); + const int64_t max_blocks = ss.qsa_states[0].max_cells / s.idx_block + 2; + f(256 * (size_t) max_blocks); + f(32 * strata::kernels::qsa_decode_attn_scratch_floats(cap, s)); + f(T * NE); f(T * K); o.take(T * K, ok); o.take(T * K, ok); o.take(T * K, ok); + o.take(T * K * N, ok); f(T * K * 1280); o.take(T * K * 640, ok); f(T * K * N); + f(T * 640); f(T * 640); o.take(T * 640, ok); f(T * N); f(T); + for (int i = 0; i < DQ; ++i) { o.take(1280 * 2560, ok); o.take(2560 * 640, ok); } + for (int i = 0; i < STAGE; ++i) o.take((size_t) MAXBLOB(), ok); + f(T * N); + f((size_t) strata::kernels::NG_HC_DIM); + return o.used + (8u << 20); // alignment slack +} + +namespace { + +const core::WeightRef* need(const core::LayerView& v, const char* suffix, std::string& err) { + const core::WeightRef* r = v.get(suffix); + if (!r) err = v.name(suffix) + " is missing"; + return r; +} +bool native_proj(Gemm& gm, const core::WeightRef* w, const uint16_t* X, float* Y, int64_t T, const std::string& name, + std::string& err, int64_t ldy = 0) { + if (!w->native_data) { err = "prefill: " + name + " has no native GGUF blocks (run with --native)"; return false; } + gm.native(X, w->native_type, w->native_data, Y, T, w->ne1, w->ne0, ldy); + return true; +} +bool bf16_proj(Gemm& gm, const core::WeightRef* w, const uint16_t* X, float* Y, int64_t T, const std::string& name, + std::string& err, int64_t ldy = 0) { + if (w->kind != core::WeightKind::Bf16InF32 || !w->data) { err = "prefill: " + name + " is not a resident BF16 tensor"; return false; } + gm.bf16(X, (const uint16_t*) w->data, Y, T, w->ne1 > 0 ? w->ne1 : 1, w->ne0, ldy); + return true; +} + +} // namespace + +bool Prefill::run(const int64_t* tokens, int64_t n, int64_t pos0, std::string& err) { + Impl& m = *impl_; + const core::ModelGeometry& g = *m.g; + core::SessionState& ss = *m.ss; + const auto t_start = Clock::now(); + strata::kernels::QsaShapes s = strata::kernels::qsa_real_shapes(); + s.n_head = g.n_head; s.n_head_kv = g.n_head_kv; s.head_dim = g.head_dim; s.idx_n_head = g.idx_q_heads; + s.idx_dim = g.idx_key_dim; + const uint64_t gdn_floats = (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size + + (uint64_t) g.ssm_conv_channels * (g.ssm_d_conv - 1); + int32_t prev[2] = {ss.ple_prev[0], ss.ple_prev[1]}; + + for (int64_t c0 = 0; c0 < n; c0 += m.T) { + const int64_t T = std::min(m.T, n - c0), p0 = pos0 + c0; + ++stats_.chunks; + // ---- embeddings, broadcast to the four streams + for (int64_t t = 0; t < T; ++t) { + const float* row = embd_rows ? embd_rows[p0 + t] : nullptr; + if (row) { + if (cudaMemcpyAsync(m.emb + t * N, row, (size_t) N * 4, cudaMemcpyHostToDevice, m.cs) != cudaSuccess) { + err = "prefill: the image embedding upload failed"; + return false; + } + } else if (!core::embed_row(*m.wt, g, tokens[c0 + t], m.emb + t * N, m.cs, err)) { + return false; + } + } + gr_broadcast(m.emb, m.R, T, m.cs); + // ---- the PLE rows of the whole chunk, one batched SSD request + const bool ple_on = ss.ple.ready(); + if (ple_on) { + const auto tp = Clock::now(); + for (int64_t t = 0; t < T; ++t) { + const int32_t tok = (int32_t) tokens[c0 + t]; + strata::kernels::ngram_rows(&tok, prev, 1, ss.ple.consts, m.ple_rows.data() + t * strata::kernels::PLE_N_HEADS); + prev[0] = prev[1]; + prev[1] = tok; + } + if (!ss.ple.table->gather_batch(m.ple_rows.data(), (size_t) T, m.ple_emb_host.data(), err)) return false; + cudaMemcpyAsync(m.ple_emb, m.ple_emb_host.data(), (size_t) T * N * 4, cudaMemcpyHostToDevice, m.cs); + stats_.ms_ple += ms_since(tp); + } else { + for (int64_t t = 0; t < T; ++t) { prev[0] = prev[1]; prev[1] = (int32_t) tokens[c0 + t]; } + } + // ---- the QSA step records of every position in the chunk + for (int64_t t = 0; t < T; ++t) strata::kernels::qsa_step_fill(m.steps_host.data() + t * strata::kernels::kStepCount, p0 + t, s); + cudaMemcpyAsync(m.steps_dev, m.steps_host.data(), (size_t) T * strata::kernels::kStepCount * 4, + cudaMemcpyHostToDevice, m.cs); + + int64_t qsa_index = 0, gdn_index = 0; + for (int64_t l = 0; l < g.n_layers; ++l) { + const core::LayerView v(*m.wt, l); + // ---- the PLE block at layer 1, token by token (its conv reads the previous tokens' rows) + if (l == 1 && ple_on) { + const auto tp = Clock::now(); + for (int64_t t = 0; t < T; ++t) { + strata::kernels::PleOut po; + po.normalized = m.ple_norm; + po.result = m.R + t * D; + try { + strata::kernels::ple_block(m.ple_emb + t * N, m.R + t * D, ss.ple.hist, ss.ple.w, po, + ss.ple.scratch, m.cs); + } catch (const std::exception& e) { err = std::string("prefill PLE: ") + e.what(); return false; } + strata::kernels::ple_history_advance(ss.ple.hist, m.ple_norm, m.cs); + } + stats_.ms_ple += ms_since(tp); + } + for (int half = 0; half < 2; ++half) { + // ---- the hyper-connection read of this half + const char* pre = half == 0 ? "hc_attn_" : "hc_ffn_"; + const std::string sn = std::string(pre) + "norm.weight", sd = std::string(pre) + "down.weight", + su = std::string(pre) + "up.weight", si = std::string(pre) + "inject.weight"; + const core::WeightRef *wn = need(v, sn.c_str(), err), *wd = need(v, sd.c_str(), err), + *wu = need(v, su.c_str(), err), *wi = need(v, si.c_str(), err); + if (!wn || !wd || !wu || !wi) return false; + gr_norm(m.R, (const float*) wn->data, EPS, m.xn, m.xn16, T, m.cs); + if (!bf16_proj(m.gemm, wd, m.xn16, m.lo, T, sd, err)) return false; + gr_silu(m.lo, m.lo16, T, m.cs); + if (!bf16_proj(m.gemm, wu, m.lo16, m.gated, T, su, err)) return false; + if (!bf16_proj(m.gemm, wi, m.xn16, m.inj, T, si, err)) return false; + gr_mix(m.xn, m.gated, m.mixed, m.mixed_bf, T, m.cs, m.mixed_h); + + if (half == 0 && !core::is_qsa_layer(g, l)) { + // ======================= GDN ======================= + const core::WeightRef *wqkv = need(v, "attn_qkv.weight", err), *wg = need(v, "attn_gate.weight", err), + *wo = need(v, "ssm_out.weight", err), *wa = need(v, "ssm_alpha.weight", err), + *wb = need(v, "ssm_beta.weight", err), *wc = need(v, "ssm_conv1d.weight", err), + *wnm = need(v, "ssm_norm.weight", err), *wdt = need(v, "ssm_dt.bias", err), + *wsa = need(v, "ssm_a", err); + if (!wqkv || !wg || !wo || !wa || !wb || !wc || !wnm || !wdt || !wsa) return false; + float* state = ss.gdn_state + (size_t) gdn_index * gdn_floats; + float* conv = state + (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size; + if (!native_proj(m.gemm, wqkv, m.mixed_h, m.qkv, T, v.name("attn_qkv.weight"), err)) return false; + if (!native_proj(m.gemm, wg, m.mixed_h, m.z, T, v.name("attn_gate.weight"), err)) return false; + if (!bf16_proj(m.gemm, wa, m.mixed_bf, m.ab, T, v.name("ssm_alpha.weight"), err, 2 * HV)) return false; + if (!bf16_proj(m.gemm, wb, m.mixed_bf, m.ab + HV, T, v.name("ssm_beta.weight"), err, 2 * HV)) return false; + gdn_gates(m.ab, (const float*) wdt->data, (const float*) wsa->data, m.gate, m.beta, T, m.cs); + gdn_conv(conv, m.qkv, (const float*) wc->data, m.hbuf, T, EPS, m.cs); + gdn_recurrence(state, m.hbuf, m.gate, m.beta, m.z, (const float*) wnm->data, EPS, m.y, m.y_h, T, m.cs); + if (!native_proj(m.gemm, wo, m.y_h, m.bo, T, v.name("ssm_out.weight"), err)) return false; + ++gdn_index; + } else if (half == 0) { + // ======================= QSA ======================= + const core::QsaState& st = ss.qsa_states[qsa_index]; + const core::WeightRef *wq = need(v, "attn_q.weight", err), *wk = need(v, "attn_k.weight", err), + *wv = need(v, "attn_v.weight", err), *wo = need(v, "attn_output.weight", err), + *wik = need(v, "indexer.k_proj.weight", err), + *wiq = need(v, "indexer.q_proj.weight", err), + *wqn = need(v, "attn_q_norm.weight", err), *wkn = need(v, "attn_k_norm.weight", err), + *wiqn = need(v, "indexer.q_norm.weight", err), + *wikn = need(v, "indexer.k_norm.weight", err); + if (!wq || !wk || !wv || !wo || !wik || !wiq || !wqn || !wkn || !wiqn || !wikn) return false; + if (!native_proj(m.gemm, wk, m.mixed_h, m.Kc, T, v.name("attn_k.weight"), err)) return false; + if (!native_proj(m.gemm, wv, m.mixed_h, m.Vc, T, v.name("attn_v.weight"), err)) return false; + if (!native_proj(m.gemm, wq, m.mixed_h, m.Qf, T, v.name("attn_q.weight"), err)) return false; + if (!bf16_proj(m.gemm, wik, m.mixed_bf, m.idx_raw, T, v.name("indexer.k_proj.weight"), err)) return false; + if (!bf16_proj(m.gemm, wiq, m.mixed_bf, m.q_idx, T, v.name("indexer.q_proj.weight"), err)) return false; + rms_rows(m.Kc, (const float*) wkn->data, T * 2, 256, 256, EPS, m.cs); + rope(m.Kc, T, 2, 256, 512, p0, (float) strata::kernels::qsa_freq_base(), m.cs); + kv_append(m.Kc, m.Vc, T, p0, st.page_table, s.page_size, st.kv_int8 ? nullptr : st.k_pool, + st.kv_int8 ? nullptr : st.v_pool, st.k_q, st.v_q, st.k_scale, st.v_scale, m.cs); + split_q(m.Qf, m.q, T, m.cs); + rms_rows(m.q, (const float*) wqn->data, T * 24, 256, 256, EPS, m.cs); + rope(m.q, T, 24, 256, 6144, p0, (float) strata::kernels::qsa_freq_base(), m.cs); + rms_rows(m.q_idx, (const float*) wiqn->data, T * 4, 128, 128, EPS, m.cs); + rope(m.q_idx, T, 4, 128, 512, p0, (float) strata::kernels::qsa_freq_base(), m.cs); + // the indexer appends, token by token; then scores + selection for many queries at once: + // a query reads completed blocks (final once completed) and `dead` for its own tail block + const strata::kernels::QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos}; + for (int64_t t = 0; t < T; ++t) { + const int32_t* step_t = m.steps_dev + t * strata::kernels::kStepCount; + try { + strata::kernels::native_qsa_indexer_append(m.idx_raw + t * 128, step_t + strata::kernels::kStepPos, 0, + (const float*) wikn->data, EPS, ib, s, st.max_cells, + (float) strata::kernels::qsa_freq_base(), m.cs); + } catch (const std::exception& e) { err = std::string("prefill indexer: ") + e.what(); return false; } + } + for (int64_t t0 = 0; t0 < T; t0 += m.sel_batch) { + const int64_t nb = std::min(m.sel_batch, T - t0); + const int32_t* steps0 = m.steps_dev + t0 * strata::kernels::kStepCount; + strata::kernels::qsa_block_scores(st.idx_pooled, st.idx_dead, m.q_idx + t0 * 512, steps0, nb, + m.max_blocks, s, m.sel_scores, m.cs); + strata::kernels::qsa_block_topk(m.sel_scores, steps0, nb, m.max_blocks, m.cap, s, + m.sel_ids + t0 * m.cap, m.cs); + } + strata::kernels::QsaAttnPools pools; + pools.page_table = st.page_table; + if (st.kv_int8) { pools.k_q = st.k_q; pools.v_q = st.v_q; pools.k_scale = st.k_scale; pools.v_scale = st.v_scale; } + else { pools.k_pool = st.k_pool; pools.v_pool = st.v_pool; } + for (int64_t t0 = 0; t0 < T; t0 += m.attn_batch) { + const int64_t nb = std::min(m.attn_batch, T - t0); + strata::kernels::qsa_decode_attn_batch(m.q + t0 * ZV, pools, m.sel_ids + t0 * m.cap, + m.steps_dev + t0 * strata::kernels::kStepCount, m.cap, s, + m.attn_scratch, m.attn + t0 * ZV, nb, m.cs); + } + gate_attn(m.attn, m.Qf, m.attn_h, T, m.cs); + if (!native_proj(m.gemm, wo, m.attn_h, m.bo, T, v.name("attn_output.weight"), err)) return false; + ++qsa_index; + } else { + // ======================= MoE ======================= + const core::WeightRef *wr = need(v, "ffn_gate_inp.weight", err), + *wgi = need(v, "ffn_gate_inp_shexp.weight", err), + *wsg = need(v, "ffn_gate_shexp.weight", err), + *wsu = need(v, "ffn_up_shexp.weight", err), + *wsd = need(v, "ffn_down_shexp.weight", err); + if (!wr || !wgi || !wsg || !wsu || !wsd) return false; + if (!bf16_proj(m.gemm, wr, m.mixed_bf, m.logits, T, v.name("ffn_gate_inp.weight"), err)) return false; + route(m.logits, m.ids, m.w, T, m.cs); + // the shared expert and its scalar gate + if (!native_proj(m.gemm, wsg, m.mixed_h, m.sgate, T, v.name("ffn_gate_shexp.weight"), err)) return false; + if (!native_proj(m.gemm, wsu, m.mixed_h, m.sup, T, v.name("ffn_up_shexp.weight"), err)) return false; + swiglu_pair(m.sgate, m.sup, m.sh_h, T, m.cs); + if (!native_proj(m.gemm, wsd, m.sh_h, m.shared, T, v.name("ffn_down_shexp.weight"), err)) return false; + if (wgi->kind != core::WeightKind::Bf16InF32) { err = "prefill: shared gate is not BF16"; return false; } + m.gemm.bf16(m.mixed_bf, (const uint16_t*) wgi->data, m.sg, T, 1, N); + // group the (token, k) pairs by expert on the host + cudaMemcpyAsync(m.ids_host.data(), m.ids, (size_t) T * K * 4, cudaMemcpyDeviceToHost, m.cs); + cudaStreamSynchronize(m.cs); + std::fill(m.cnt.begin(), m.cnt.end(), 0); + for (int64_t i = 0; i < T * K; ++i) { + const int32_t e = m.ids_host[(size_t) i]; + if (e < 0 || e >= NE) { err = "prefill: routed id out of range"; return false; } + ++m.cnt[(size_t) e]; + } + m.off[0] = 0; + for (int64_t e = 0; e < NE; ++e) m.off[(size_t) e + 1] = m.off[(size_t) e] + m.cnt[(size_t) e]; + std::vector fill(m.off.begin(), m.off.end() - 1); + for (int64_t i = 0; i < T * K; ++i) { + const int32_t e = m.ids_host[(size_t) i]; + const int32_t p = fill[(size_t) e]++; + m.slot_host[(size_t) i] = p; + m.src_host[(size_t) p] = (int32_t) (i / K); + } + cudaMemcpyAsync(m.slot_dev, m.slot_host.data(), (size_t) T * K * 4, cudaMemcpyHostToDevice, m.cs); + cudaMemcpyAsync(m.src_dev, m.src_host.data(), (size_t) T * K * 4, cudaMemcpyHostToDevice, m.cs); + gather_rows16(m.mixed_h, m.src_dev, m.Xs, T * K, N, m.cs); + // the experts, in id order: resident ones from VRAM, the others through the staging ring + std::vector order; + for (int32_t e = 0; e < NE; ++e) if (m.cnt[(size_t) e] > 0) order.push_back(e); + const strata::kernels::cpu::ExpertLayout& lay = strata::kernels::cpu::expert_layout(); + // Stage ahead: the copy stream moves blobs host -> device while the compute stream works. + int stage_next = 0; + std::vector stage_of(order.size(), -1); + auto stage_one = [&](size_t j) -> bool { + const int32_t e = order[j]; + const bool resident = m.host_res && m.cache && m.host_res[(size_t) l * NE + e] >= 0; + if (resident) return true; + const int sl = stage_next; + stage_next = (stage_next + 1) % STAGE; + const auto th = Clock::now(); + const uint8_t* b = m.src->blob(l, e); + if (!b) { err = "prefill: expert source has no blob"; return false; } + if (m.src->pinned(l, e)) { + // DMA straight from the page-locked arena: the copy stream only waits for the slot + if (m.stage_live[sl]) cudaStreamWaitEvent(m.copy, m.used[sl], 0); + cudaMemcpyAsync(m.stage_dev[sl], b, (size_t) lay.blob_bytes(l), cudaMemcpyHostToDevice, m.copy); + ++stats_.experts_dma; + } else { + if (m.stage_live[sl]) cudaEventSynchronize(m.used[sl]); // its previous blob was dequantized + std::memcpy(m.stage_host[sl], b, (size_t) lay.blob_bytes(l)); + cudaMemcpyAsync(m.stage_dev[sl], m.stage_host[sl], (size_t) lay.blob_bytes(l), cudaMemcpyHostToDevice, m.copy); + } + cudaEventRecord(m.copied[sl], m.copy); + m.stage_live[sl] = true; + stage_of[j] = sl; + stats_.ms_experts_host += ms_since(th); + ++stats_.experts_streamed; + return true; + }; + size_t staged = 0; + const size_t lookahead = STAGE - 1; + for (size_t j = 0; j < order.size(); ++j) { + while (staged < order.size() && staged <= j + lookahead) { + if (!stage_one(staged)) return false; + ++staged; + } + const int32_t e = order[j]; + const uint8_t* blob_dev = nullptr; + if (stage_of[j] < 0) { + blob_dev = m.cache->device_slot(m.host_res[(size_t) l * NE + e]); + ++stats_.experts_resident; + } else { + cudaStreamWaitEvent(m.cs, m.copied[stage_of[j]], 0); + blob_dev = m.stage_dev[stage_of[j]]; + } + const int q = (int) (j % DQ); + if (lay.native) { + // plan v0.3 P6: a native pack's layer, dequantized by llama.cpp's own formulas + const auto& f = lay.fmt[(size_t) l]; + strata::kernels::iq_dequant_gu_f16(f.gu_type, blob_dev, blob_dev + f.up_off, f.n_ff, f.n_embd, + m.dq_gu[q], m.cs); + strata::kernels::iq_dequant_f16(f.d_type, blob_dev + f.down_off, f.n_embd * f.n_ff, m.dq_d[q], m.cs); + } else { + blob_dequant_f16(blob_dev, m.dq_gu[q], m.dq_d[q], m.cs); + } + if (stage_of[j] >= 0) cudaEventRecord(m.used[stage_of[j]], m.cs); + const int64_t o0 = m.off[(size_t) e], ne = m.cnt[(size_t) e]; + m.gemm.f16(m.Xs + o0 * N, m.dq_gu[q], m.GU + o0 * 1280, ne, 1280, N); + swiglu_interleaved(m.GU + o0 * 1280, m.Hh + o0 * 640, ne, m.cs); + m.gemm.f16(m.Hh + o0 * 640, m.dq_d[q], m.Dm + o0 * N, ne, N, 640); + } + moe_combine(m.Dm, m.slot_dev, m.w, m.shared, m.sg, m.bo, T, m.cs); + } + // ---- the hyper-connection write of this half + gr_write(m.R, m.bo, m.inj, HC, T, m.cs); + } + } + stats_.tokens += T; + if (on_chunk) { + if (cudaStreamSynchronize(m.cs) != cudaSuccess) { + err = std::string("prefill: ") + cudaGetErrorString(cudaGetLastError()); + return false; + } + if (!on_chunk(m.R, T, p0, err)) return false; + } + } + ss.ple_prev[0] = prev[0]; + ss.ple_prev[1] = prev[1]; + if (cudaStreamSynchronize(m.cs) != cudaSuccess) { + err = std::string("prefill: ") + cudaGetErrorString(cudaGetLastError()); + return false; + } + stats_.ms_total += ms_since(t_start); + return true; +} + +} // namespace strata::prefill diff --git a/src/program/generate.cpp b/src/program/generate.cpp new file mode 100644 index 00000000..e116abe5 --- /dev/null +++ b/src/program/generate.cpp @@ -0,0 +1,2818 @@ +// src/program/generate.cpp - P2.S6: `strata generate`. +// +// THE DRIVER, and the first program in this project that answers a question. Everything below it is a +// component; this is the thing that composes them into a token: +// +// embed_row(token) -> 48 captured layer graphs (the CPU expert pool behind the doorbell) -> +// lm_head(R) -> sample -> embed_row(next) -> ... +// +// WHAT IT IS NOT. There is no tokenizer here. `pack/full/tokenizer/` and `tools/strata_tokenizer.py` exist, +// and a C++ BPE is Phase 1's deliverable rather than this program's, so the prompt arrives as IDS via +// `--tokens`. That is not a placeholder: it is exactly what Gate C1 needs, because C1 compares logits against +// llama.cpp on the SAME ids, and a tokenizer on only one side of that comparison is a second variable. +// +// AND IT IS PHASE 2, so hit rate is `h = 0` and the number it prints is slow on purpose +// (`phase-2-correct-engine.md:5-9`). What it is FOR is the honest tok/s figure and the logit dump. + +#include "strata/core/expert_cache.hpp" +#include "strata/core/expert_source.hpp" +#include "strata/core/layer.hpp" +#include "strata/core/layout.hpp" +#include "strata/core/session.hpp" +#include "strata/core/weights.hpp" +#include "strata/kernels/cpu/expert.hpp" +#include "strata/kernels/cpu/pool.hpp" +#include "strata/kernels/cpu/expert_layout.hpp" +#include "strata/kernels/ngram.hpp" +#include "strata/kernels/s2_expert_grouped.hpp" +#include "strata/kernels/sampler.hpp" +#include "strata/kernels/shared_expert.hpp" +#include "strata/kernels/native_moe.hpp" +#include "strata/kernels/native_gdn.hpp" +#include "strata/kernels/native_router.hpp" +#include "strata/kernels/native_qsa.hpp" +#include "strata/kernels/native_qsa_indexer.hpp" +#include "strata/kernels/native_rope.hpp" +#include "strata/kernels/mrope.hpp" +#include "strata/kernels/qsa.hpp" +#include "strata/core/native_head.hpp" +#include "strata/core/verify.hpp" +#include "strata/core/mtp.hpp" +#include "strata/prefill/prefill.hpp" +#include "strata/core/native_dense.hpp" +#include "strata/program/logits_selection.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using Clock = std::chrono::steady_clock; + +struct Options { + std::string pack = "pack/full"; + std::vector tokens; // the prompt, PRE-TOKENIZED + int64_t max_new = 16; + int64_t max_context = 4096; + bool greedy = true; + uint64_t seed = 0; + int top_k = 20; + float top_p = 0.95f; + float temperature = 1.0f; + std::string dump_logits; // one line of logits per generated position + int64_t logits_stride = 1; // storage selection; all prompt tokens remain conditioned + /// **THE RESIDUAL, SO THE HEAD CAN BE CHECKED WITHOUT THE LAYERS.** + /// + /// C1 fails (LEDGER L116) and the pipeline is `embed -> 48 layers -> head`. Dumping `R` splits it in half: + /// the head is one norm, two bf16 projections and one 794 MB GEMV, all of which can be recomputed in Python + /// from the manifest. If Python agrees with the engine on the same `R`, the head is right and the layers + /// are wrong; if it disagrees, the head is wrong. Nothing else in the engine can be split that cheaply. + std::string ple_gguf; // the ORIGINAL second GGUF shard: the PLE table is not in the pack + bool no_ple = false; // explicit diagnostic ablation; never a normal inference default + bool stream_token = false; // R2.6 experiment: ordered work on the session stream + bool check_logits = false; // optional full-vocabulary finite scan + bool gr_fp32_activations = false; // pinned CUDA single-token BF16 activation contract + bool gr_native_mmvf = false; // pinned projection reduction tree as well as FP32 inputs + bool native_bf16 = false; // SSM gates, router and indexer projections only + bool native_bf16_extra = false; // PLE value and shared expert scalar gate + bool native_ple_key = false; // unchanged Q2_0 key and CUDA Q8_1 activations + bool native_moe_combine = false; // pinned fused CUDA weighted reduction + bool native_gdn = false; // pinned CUDA recurrence and preprocessing + bool native_flash_attn_short = false; // diagnostic pinned attention, context <=256 + bool native_qsa_indexer = false; // pinned F16 key cache and F32 pooling + bool native_qsa = false; // pinned F32 QSA norms and gate + bool native_rope = false; // pinned text-only CUDA rotary arithmetic + bool native_ple_postops = false; // pinned PLE postprojection arithmetic + bool native_router = false; // pinned fused 512-expert top-10 router + bool cpu_oracle_q8_0 = false; // pinned x86 activation scales/codes at both expert stages + std::string native_head_gguf; // native output.weight experiment; same model shard as the pack + std::vector native_dense_gguf; // repeat for native GDN/QSA projection shards + /// Plan v0.3 P1: the whole native arithmetic set as ONE switch (model shard 1). It enables exactly the + /// combination recorded in bench/results/2026-09-23-attention-ple plus the native indexer, and never the + /// <=256-token attention adapter. It becomes the default once P0 shows it is not slower. + std::string native_preset; + /// Plan v0.3 P2: how the n-gram table is read. Direct (default) = unbuffered SSD reads, table never in RAM. + std::string ple_io = "direct"; + int64_t ple_row_cache = 1 << 20; ///< bounded row cache (rows of 90 B); 0 disables + int ple_inflight = 64; + double ple_delay_us = 0; ///< fault injection: every row read completes no earlier than this + bool ple_sync_submit = false; ///< A/B arm: submit reads on the token thread, no I/O worker + std::string kv = "fp16"; ///< plan v0.3 P7: KV storage, fp16 (default) or int8 (half the VRAM) + std::string dump_residual; + /// The head input, `bb.mixed`. It exists so the head can be SPLIT: steps 1-4 (the per-stream norm, the two + /// bf16 projections and the stream mean) recompute cheaply in Python, and only the 794 MB GEMV does not. + std::string dump_mixed; + /// One residual snapshot per layer per position: `n_layers * hc * n_embd` floats per position, appended in + /// position order. This is the C1 BISECTION LADDER - it is what `llama-debug --tensor-filter l_last` prints + /// for the reference, so the first layer whose `sum` diverges is the layer that holds the bug. It needs the + /// captured path (the expert pool only exists there), so it is refused with `--no-capture`. + std::string dump_layers; + /// `2 * n_embd + 2 * hc` floats per layer per position: the attention half's block output, the MoE half's, + /// and the two injection vectors. It separates `linear_attn_out-` from `ffn_out-`, which the residual + /// ladder cannot. **CAPTURED INTO THE LAYER GRAPHS**, so it must be armed before `session_capture`. + std::string dump_halves; + /// P0.S8's routing trace, and a prerequisite the Phase 3 plan names explicitly. One record per layer per + /// position: `int32 layer, int32 k, k int32 ids, k float weights`. It is what a hit-rate curve for a + /// candidate VRAM expert cache is computed from, and it needs no new kernels - the doorbell already + /// publishes exactly this much to pinned memory. + std::string dump_routing; + bool no_capture = false; // run the layers directly instead of replaying graphs + bool no_pool = false; // skip the CPU expert pool: the GPU-only floor + bool sync_every_layer = false; + /// Per-stage CUDA-event timings inside the layer halves. `--no-capture` only: an event recorded inside a + /// stream capture is silently dropped, so the captured path cannot carry this. + bool stage_timing = false; + /// Launch the 48 captured `pre` graphs back to back with no host work between them and report the pure GPU + /// time per token. This is the only measurement that separates host-bound from GPU-bound, because the + /// stage events include every gap where the GPU waited for the host. + bool graph_only = false; + bool gpu_only_full = false; ///< R0.3: pre + post + head, the true per-token GPU floor + int pool_workers = 0; ///< R2.2: 0 = "all physical cores minus the host's"; >0 overrides + /// R2.2's first half, as an A/B arm. **ON by default**, because the measurement that justifies it is the + /// pool's own drain: 33.7 GB/s against 5/6 x 44.14 = 36.8 for five workers, on a machine whose sixth core + /// is reserved for a host thread that has nothing to do while the drain runs. + bool no_host_worker = false; + bool mmap_experts = false; ///< R2.1: opt OUT of the resident arena, back to MapViewOfFile + /// R4: slots of VRAM-resident experts. **0 = off, and off is the default.** + /// **THE COMMENT THAT USED TO BE HERE WAS FALSE AND ROUND 328 MEASURED IT.** It said "the cache has no + /// consumer yet - `moe_hit_grouped_s2` does not exist - so switching it on costs the fill traffic and + /// saves nothing". The kernel exists (`src/kernels/cuda/s2_expert_grouped.cu`), it is wired at line ~660 + /// via `expert_hit_run`, and switching the cache on **does** move work off the CPU pool: the drain fell + /// **19.076 -> 10.312 ms/token** at 4096 per-layer slots, for **-2.7 ms/token** end to end. What was + /// true is that the ADMISSION POLICY gave every slot to the first position, which is why the earlier + /// measurement found nothing - see `expert_cache_per_layer`. + int expert_cache = 0; + bool expert_cache_cpu_order = false; + /// **R4.2g. ROUND 328 MEASURED THAT THE GLOBAL ADMISSION POLICY CANNOT WORK, AND THIS IS THE FIX.** + /// The default policy hands out slots in arrival order from one counter shared by all 48 layers, so the + /// first `n_slots` distinct pairs - about 26 LAYERS OF POSITION 0 - take every slot and hits are confined + /// to them. Measured at 256 slots: **1781 of 60000 = 2.97%**, against **21.4%** for 8 slots per layer and + /// **70.4%** for 64, from `Memory/cache_allocation.py` on the same run's routing. Off by default. + bool expert_cache_per_layer = false; + /// The PLE gather's prefetch, as an A/B arm. The gather measured 2.10-2.61 ms/token because its sixteen + /// row reads are sixteen SEPARATE page faults into a 26.8 GB mapping; see `ple_prefetch_enable`. + bool no_ple_prefetch = false; + /// R4.2e: a `profile.bin` from `tools/make_profile.py`. **When given, it decides residency instead of the + /// compulsory-miss policy**, which is the whole point: a profile ranked by routing frequency over a whole + /// trace is what the plan's `h = 0.6447` refers to, and compulsory-miss measured 0.4864 because it fills + /// with whatever the prompt touched FIRST. Empty means no profile. + std::string expert_profile; + /// R4.2d: **ON by default**, because the measurement is unambiguous and the alternative is known-broken. + /// Without it, 17 of 10,562 layers had the hit work done when the pool returned; with it, 9,190. The + /// A/B arm is `--no-hit-poke`. + bool no_hit_poke = false; + /// R0.9: capture each layer as THREE graphs and time them from outside the capture, which is the only + /// valid way to get a per-stage table on the real graph. Prints and exits; it is a measurement, not a run. + bool gpu_stages = false; + bool stats = false; + bool shared_late = false; ///< plan v0.3 P3 A/B: shared expert inside post[l] (old order) + bool keep_canonical = false; ///< plan v0.3 P1 A/B: load canonical copies of natively served tensors + bool no_token_graph = false; ///< plan v0.3 P3 A/B: two graphs per layer instead of one per token + bool no_fused_gr = false; ///< plan v0.3 P3 A/B: the six-kernel native gr_read + separate gr_write + bool no_fast_attn = false; ///< plan v0.3 P3 A/B: gather + one-block-per-head QSA attention + bool no_publish_kernel = false; ///< plan v0.3 P3 A/B: memcpy nodes for the doorbell and QSA step + bool no_fused_gdn = false; ///< plan v0.3 P3 A/B: llama.cpp-layout GDN step + separate out norm + bool no_fast_select = false; ///< plan v0.3 P7 A/B: FP64 row scores + bit-serial cell top-k + /// Plan v0.3 P4: `--expert-cache auto` sizes the VRAM tier from what is free after the weights, the session + /// and the KV state, minus this reserve for the graphs, the hit scratch and the head. + int vram_reserve_mib = 700; + /// Plan v0.3 P5: batched prompt processing in chunks of this many tokens (0 = the token path). + int64_t prefill_chunk = 0; + bool no_split_rows = false; ///< plan v0.3 P4 A/B: one whole expert per pool thread + /// Plan v0.3 P5: the prompt path borrows the top expert-cache slots for its buffers and refills them after + /// the prompt (default); `--no-prefill-borrow` reserves the buffers' VRAM for the whole session instead. + bool no_prefill_borrow = false; + /// Plan v0.3 P5 validation: batch only positions [0, P) and run the rest of the prompt through the token path + /// (teacher-forced), so the logits of positions >= P - which depend on the batched state - can be scored + /// against the oracle at many positions. 0 = the whole prompt but the last position. + int64_t prefill_until = 0; + /// Plan v0.3 P6: after every processed position, append the residual after the last layer (hc x n_embd + /// floats, the MTP draft head's input) to this file. Token path only. + std::string dump_final_r; + /// Plan v0.3 P6: speculative decoding with a verify window of this many tokens (the last accepted token and + /// spec-1 drafts); 0 = plain decode. `spec_oracle` drafts from a token file (the expected continuation, for + /// the exactness test); `spec_corrupt` N > 0 replaces every Nth draft with a wrong token. + int spec = 0; + std::string spec_oracle; + int spec_corrupt = 0; + /// Plan v0.3 P6: the MTP draft layer's runtime directory (tools/mtp_rt.py); drafts come from it. + std::string mtp; + int64_t mtp_window = 32768; ///< the draft layer attends to the last N cells (0 = every cell) + /// Plan v0.3 P6: the share (0..1) of each layer's distinct missed experts the GPU reads over PCIe from the + /// pinned arena while the CPU computes the rest (verify windows). + double pcie_frac = -1.0; ///< < 0: the model's default (0.2 direct for the Q2_0 pack, 0.55 DMA for native packs) + std::string pcie_mode = "auto"; ///< auto | dma | kernel | direct + /// Plan v0.3 P6: every `adapt_every` rounds, swap up to `adapt_swaps` of the most-routed missing experts into + /// the VRAM tier in place of the least-routed resident ones (decayed counts). 0 = static residency. + int adapt_every = 4; + /// Plan v0.3 P6: a draft enters the verify window only while every draft before it (and itself) has at least + /// this probability under the draft layer; 0 = always --spec-1 drafts. + double spec_min_p = 0.0; + /// Stop when the model emits an end-of-turn token (<|endoftext|> 248044, <|im_end|> 248046, or --eos-ids). + bool stop_eos = false; + std::vector eos_ids = {248044, 248046}; + bool spec_split = false; ///< opt-in split verify window (the overlap study: exact, ~7% slower) + /// Plan v0.3 P8: stay resident and take requests on stdin (see the --serve block in main). + bool serve = false; + /// The vision path: keep a per-cell (t, h, w) rotary position table so --serve can take GENI requests. + bool vision = false; + int adapt_swaps = 96; +}; + +void usage() { + std::fprintf(stderr, + "strata generate --pack DIR --tokens \"1,2,3\" [options]\n" + "\n" + " --pack DIR the pack directory (default pack/full)\n" + " --tokens LIST the prompt as comma-separated token IDS (required)\n" + " --tokens-file PATH pretokenized prompt, commas or whitespace (alternative to --tokens)\n" + " --ple-gguf PATH required PLE table (original second GGUF shard)\n" + " --no-ple explicit diagnostic ablation of the PLE layer\n" + " --ple-io direct|mmap n-gram table reads (plan v0.3 P2). direct (default): unbuffered SSD\n" + " reads, the table never enters RAM or the file cache; mmap: A/B arm\n" + " --ple-row-cache N bounded cache of fetched rows, 90 B each (default 1048576; 0 = off)\n" + " --ple-inflight N outstanding SSD reads (default 64)\n" + " --ple-delay-us U fault injection: each row read completes no earlier than U us\n" + " --ple-sync-submit A/B arm: submit table reads on the token thread (default: an I/O thread)\n" + " --kv fp16|int8 KV storage (plan v0.3 P7): int8 codes + fp16 scale per 64 values, half the\n" + " VRAM; default fp16 until gate G-C accepts int8\n" + " --stream-token enqueue token work on the session stream (experimental)\n" + " --check-logits copy and check all logits in the stream-token path\n" + " --gr-fp32-activations experimental CUDA-oracle GR activation precision\n" + " --gr-native-mmvf experimental pinned GR norm/projections; implies FP32 activations\n" + " --native-bf16 experimental CUDA-oracle SSM/router/indexer BF16 projections\n" + " --native-bf16-extra experimental CUDA-oracle PLE/shared gate BF16 projections\n" + " --native-ple-key experimental native PLE key; requires --native-dense-gguf\n" + " --native-moe-combine experimental pinned CUDA routed/shared combination\n" + " --native-gdn experimental pinned CUDA GDN norms/gates/recurrence\n" + " --native-flash-attn-short diagnostic pinned vector attention; --max-context <=256\n" + " --native-qsa-indexer experimental pinned indexer key cache and pooling\n" + " --native-qsa experimental pinned QSA normalization and output gate\n" + " --native-rope experimental pinned text-only CUDA rotary arithmetic\n" + " --native-ple-postops experimental pinned PLE postprojection arithmetic\n" + " --native-router experimental pinned CUDA 512-expert top-10 routing\n" + " --cpu-oracle-q8-0 experimental pinned CPU expert quantization and dot reduction\n" + " --native SHARD1 every full-context native path at once (plan v0.3 P1): stream-token,\n" + " GR MMVF, BF16, head, dense + PLE key, MoE combine, GDN, router, QSA,\n" + " indexer, RoPE, PLE postops, and the CPU q8_0 contract unless the\n" + " expert cache is on. Individual --native-* flags stay for A/B.\n" + " --native-head-gguf PATH native Q5_K head from model shard 1; requires --stream-token\n" + " --native-dense-gguf PATH native GDN/QSA/shared projections; repeat for each source model shard\n" + " --expert-cache-cpu-order experimental GPU expert reduction matching CPU order\n" + " --max-new N tokens to generate (default 16)\n" + " --max-context N KV/state capacity (default 4096)\n" + " --greedy argmax (the default)\n" + " --seed S enable sampling with this Philox seed\n" + " --top-k N --top-p F --temperature F\n" + " --dump-logits PATH write one line of raw logits per position\n" + " --logits-stride N store every Nth row plus final input (default 1); N>1 requires --max-new 1\n" + " --dump-residual PATH write the final R (hc x n_embd, f32) for head bisection\n" + " --dump-layers PATH write R after EVERY layer, per position: the C1 bisection ladder\n" + " --dump-halves PATH write both halves' block_out and inject per layer: the half bisection\n" + " --dump-routing PATH write the routed expert ids and weights per layer per position (P0.S8)\n" + " --no-capture run the layers directly instead of replaying graphs\n" + " --shared-late A/B: shared expert after the CPU pool (default: overlapped with it)\n" + " --keep-canonical A/B: also load canonical copies of natively served tensors (more VRAM)\n" + " --vision --serve takes images too (GENI requests; embeddings from strata-vision)\n" + " --no-token-graph A/B: two graphs per layer (the host launches each) instead of one per token\n" + " --no-fused-gr A/B: the six-kernel hyper-connection read and a separate write (native)\n" + " --prefill CHUNK batched prompt processing in chunks of CHUNK tokens (needs --native)\n" + " --no-pool skip the CPU expert pool (the GPU-only floor)\n" + " --sync-every-layer debug: synchronise after every layer\n" + " --ple-gguf PATH the n-gram/PLE shard. WITHOUT IT LAYER 1's PLE IS SILENTLY SKIPPED,\n" + " which changes every number downstream - pass it for any real run\n" + " --dump-mixed PATH write the post-attention residual (n_embd, f32)\n" + " --stage-timing per-stage KERNEL-COUNT shares. NOT a time profile: an uncaptured\n" + " event interval includes host gaps, so run with --gpu-only-full first\n" + " --graph-only MEASURE: replay the 48 `pre` graphs only. OMITS the 48 `post` graphs\n" + " and the LM head, so it is NOT the GPU floor (R0.3, Memory/ERRORS.md A4)\n" + " --gpu-only-full MEASURE: replay pre+post for all 48 layers plus the LM head, no pool.\n" + " THE TRUE PER-TOKEN GPU FLOOR. Quote this one, not --graph-only.\n" + " --stats print the per-stage breakdown\n" + " --gpu-stages R0.9: capture the layer as three graphs (mixer / ffn+router / post)\n" + " and time them from OUTSIDE the capture. The per-stage table on the\n" + " real graph that --stage-timing cannot give. Prints and exits.\n" + " --expert-profile P R4.2e: pre-load the VRAM tier from a `profile.bin` (see\n" + " tools/make_profile.py) instead of admitting on first use.\n" + " --no-hit-poke R4.2d's A/B arm. The hit path pokes the driver once right after its\n" + " launch so the GPU starts while the CPU pool runs; without it the work\n" + " waits for the next driver entry and does not overlap at all.\n" + " --no-ple-prefetch A/B arm: read the PLE table's sixteen rows one at a time, instead of\n" + " issuing them in one PrefetchVirtualMemory call.\n" + " --expert-cache N R4: keep N expert blobs resident in VRAM and compute their rows on the\n" + " GPU via `moe_hit_grouped_s2`. DEFAULT 0. Measured at 4096 slots\n" + " with --expert-cache-per-layer: 54.4%% hits, CPU pool drain 19.1 -> 10.3\n" + " ms/token, -2.7 ms/token end to end.\n" + " --expert-cache-per-layer R4.2g: give each layer its OWN slots instead of letting the first\n" + " position take all of them. The default policy fills in arrival order\n" + " from one shared counter, so 256 slots went to ~26 layers of position 0\n" + " and measured **2.97%%**. Per-layer, the same routing gives 21.4%% at 8\n" + " slots/layer and 70.4%% at 64.\n" + " --no-host-worker R2.2: the A/B arm. By default the HOST THREAD joins the drain, so the\n" + " pool is six threads on six cores instead of five plus an idle core;\n" + " this flag restores the five-worker form for comparison on `pool phases`.\n" + " --pool-workers N R2.2: CPU expert pool worker count. Default 0 = every physical core\n" + " except the one the host loop spins on. A sweep is how the pool's\n" + " deviation from `cpu_s2` is attributed.\n" + " --mmap-experts R2.1: opt OUT of the resident expert arena, back to MapViewOfFile.\n" + " The A/B arm: the mmap's rate depends on the OS page cache holding\n" + " 34 GB, and measured 71.97 vs 34.78 ms/token cold vs warm.\n"); +} + +bool parse_i64_list(const char* s, std::vector& out, std::string& err) { + out.clear(); + std::string text(s); + for (char& c : text) if (c == ',') c = ' '; + std::istringstream input(text); + std::string token; + while (input >> token) { + int32_t id = 0; + const auto parsed = std::from_chars(token.data(), token.data() + token.size(), id); + if (parsed.ec != std::errc{} || parsed.ptr != token.data() + token.size() || id < 0) { + err = "invalid token id: expected an integer in [0, 2147483647]"; + out.clear(); + return false; + } + out.push_back(id); + } + if (out.empty()) { err = "token list was empty"; return false; } + return true; +} + +/// The pool's adapter plus the wall-clock it spent, so the report can say how much of the token was the CPU. +struct Drive { + strata::core::ExpertDispatch d; + double cpu_ms = 0; + int64_t calls = 0; + /// THE ROUTING TRACE, which is P0.S8 and a stated prerequisite of Phase 3. `drive_pool` is called once + /// per layer from the main loop - the workers live inside `expert_pool_dispatch` - so a single FILE* here + /// needs no locking. `d.layers` is the CURRENT layer on entry (the adapter increments it as it walks the + /// blob), which is why the layer index comes from there rather than from a counter of our own. + std::FILE* routing = nullptr; +}; + +void drive_pool(void* user, const float* x_f, const int32_t* ids, const float* weights, int64_t n_embd, int64_t k, + float* out) { + Drive* t = (Drive*) user; + const Clock::time_point a = Clock::now(); + strata::core::expert_pool_dispatch(&t->d, x_f, ids, weights, n_embd, k, out); + t->cpu_ms += std::chrono::duration(Clock::now() - a).count(); + ++t->calls; + // THE ROUTING TRACE. Written AFTER the dispatch so the layer index is still this layer's: `d.layers` is + // advanced by the adapter as it consumes the blob, and reading it after the call is the same value the + // dispatch used. Record = int32 layer, int32 k, k int32 ids, k float weights. + if (t->routing != nullptr) { + // **`d.layers` HAS ALREADY BEEN ADVANCED BY THE TIME THIS RUNS, AND THE FIRST TRACE WAS OFF BY ONE + // BECAUSE OF IT.** The adapter walks the blob by incrementing `d.layers` as it consumes each layer's + // experts, so after the dispatch it holds the NEXT layer's index. `tools/make_profile.py` caught it + // with a bounds check when the trace turned out to span 1..48 instead of 0..47. The hit-rate CURVE was + // unaffected - it is a per-layer split, and shifting every layer by one preserves both metrics - but + // anything keyed on the layer index, which is exactly what a cache profile is, would have been wrong. + const int32_t layer_idx = (int32_t) (t->d.layers - 1); + if (layer_idx < 0 || layer_idx >= 48) { + std::fprintf(stderr, "strata generate: the routing trace saw layer %d, outside 0..47\n", layer_idx); + return; + } + const int32_t rec[2] = {layer_idx, (int32_t) k}; + std::fwrite(rec, sizeof rec, 1, t->routing); + std::fwrite(ids, sizeof(int32_t), (size_t) k, t->routing); + std::fwrite(weights, sizeof(float), (size_t) k, t->routing); + } +} + +/// Plan v0.3 P6: the pool for a verify window. +void drive_pool_multi(void* user, const float* x_f, const int32_t* ids, int64_t n_tok, int64_t k, float* out, + int64_t layer) { + Drive* t = (Drive*) user; + t->d.layers = layer; + const Clock::time_point a = Clock::now(); + strata::core::expert_pool_dispatch_multi(t->d, x_f, ids, n_tok, k, out); + t->cpu_ms += std::chrono::duration(Clock::now() - a).count(); + ++t->calls; +} + +int argmax(const std::vector& v) { + int best = 0; + for (size_t i = 1; i < v.size(); ++i) + if (v[i] > v[best]) best = (int) i; + return best; +} + +} // namespace + +int main(int argc, char** argv) { + // **UNBUFFERED, BECAUSE THE INTERESTING OUTPUT IS THE OUTPUT BEFORE A CRASH.** `stdout` redirected to a + // pipe or a file is block-buffered, so a program that dies loses every line it had already printed - which + // turns "it crashed at step 7" into "it crashed somewhere", and the difference is a debugging session. + std::setvbuf(stdout, nullptr, _IONBF, 0); + Options o; + bool have_tokens = false; + bool have_logits_stride = false; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto next = [&](const char* what) -> const char* { + if (i + 1 >= argc) { std::fprintf(stderr, "%s needs a value\n", what); std::exit(2); } + return argv[++i]; + }; + if (a == "--help" || a == "-h") { usage(); return 0; } + else if (a == "--pack") o.pack = next("--pack"); + else if (a == "--tokens") { + if (have_tokens) { std::fprintf(stderr, "supply one token input only\n"); return 2; } + std::string e; + if (!parse_i64_list(next("--tokens"), o.tokens, e)) { std::fprintf(stderr, "%s\n", e.c_str()); return 2; } + have_tokens = true; + } + else if (a == "--tokens-file") { + if (have_tokens) { std::fprintf(stderr, "supply one token input only\n"); return 2; } + std::ifstream input(next("--tokens-file")); + if (!input) { std::fprintf(stderr, "cannot open token file\n"); return 2; } + std::string text((std::istreambuf_iterator(input)), std::istreambuf_iterator()); + if (input.bad()) { std::fprintf(stderr, "cannot read token file\n"); return 2; } + std::string error; + if (text.find('\0') != std::string::npos || !parse_i64_list(text.c_str(), o.tokens, error)) { + std::fprintf(stderr, "malformed token file: %s\n", error.c_str()); return 2; + } + have_tokens = true; + } + else if (a == "--max-new") o.max_new = std::atoll(next("--max-new")); + else if (a == "--max-context") o.max_context = std::atoll(next("--max-context")); + else if (a == "--greedy") o.greedy = true; + else if (a == "--seed") { o.seed = (uint64_t) std::atoll(next("--seed")); o.greedy = false; } + else if (a == "--top-k") o.top_k = std::atoi(next("--top-k")); + else if (a == "--top-p") o.top_p = (float) std::atof(next("--top-p")); + else if (a == "--temperature") o.temperature = (float) std::atof(next("--temperature")); + else if (a == "--dump-logits") o.dump_logits = next("--dump-logits"); + else if (a == "--logits-stride") { + if (have_logits_stride) { std::fprintf(stderr, "--logits-stride must be supplied only once\n"); return 2; } + if (!strata::program::logits_selection::parse_stride(next("--logits-stride"), o.logits_stride)) { + std::fprintf(stderr, "--logits-stride requires a positive decimal int64\n"); return 2; + } + have_logits_stride = true; + } + else if (a == "--dump-residual") o.dump_residual = next("--dump-residual"); + else if (a == "--dump-mixed") o.dump_mixed = next("--dump-mixed"); + else if (a == "--dump-layers") o.dump_layers = next("--dump-layers"); + else if (a == "--dump-halves") o.dump_halves = next("--dump-halves"); + else if (a == "--dump-routing") o.dump_routing = next("--dump-routing"); + else if (a == "--ple-gguf") o.ple_gguf = next("--ple-gguf"); + else if (a == "--no-ple") o.no_ple = true; + else if (a == "--ple-io") o.ple_io = next("--ple-io"); + else if (a == "--ple-row-cache") o.ple_row_cache = std::atoll(next("--ple-row-cache")); + else if (a == "--ple-inflight") o.ple_inflight = std::atoi(next("--ple-inflight")); + else if (a == "--ple-delay-us") o.ple_delay_us = std::atof(next("--ple-delay-us")); + else if (a == "--ple-sync-submit") o.ple_sync_submit = true; + else if (a == "--kv") o.kv = next("--kv"); + else if (a == "--stream-token") o.stream_token = true; + else if (a == "--check-logits") o.check_logits = true; + else if (a == "--gr-fp32-activations") o.gr_fp32_activations = true; + else if (a == "--gr-native-mmvf") o.gr_native_mmvf = true; + else if (a == "--native-bf16") o.native_bf16 = true; + else if (a == "--native-bf16-extra") o.native_bf16_extra = true; + else if (a == "--native-ple-key") o.native_ple_key = true; + else if (a == "--native-moe-combine") o.native_moe_combine = true; + else if (a == "--native-gdn") o.native_gdn = true; + else if (a == "--native-flash-attn-short") o.native_flash_attn_short = true; + else if (a == "--native-qsa-indexer") o.native_qsa_indexer = true; + else if (a == "--native-qsa") o.native_qsa = true; + else if (a == "--native-rope") o.native_rope = true; + else if (a == "--native-ple-postops") o.native_ple_postops = true; + else if (a == "--native-router") o.native_router = true; + else if (a == "--cpu-oracle-q8-0") o.cpu_oracle_q8_0 = true; + else if (a == "--native") o.native_preset = next("--native"); + else if (a == "--native-head-gguf") o.native_head_gguf = next("--native-head-gguf"); + else if (a == "--native-dense-gguf") o.native_dense_gguf.push_back(next("--native-dense-gguf")); + else if (a == "--no-capture") o.no_capture = true; + else if (a == "--no-pool") o.no_pool = true; + else if (a == "--sync-every-layer") o.sync_every_layer = true; + else if (a == "--stage-timing") o.stage_timing = true; + else if (a == "--graph-only") o.graph_only = true; + else if (a == "--gpu-only-full") o.gpu_only_full = true; + else if (a == "--pool-workers") o.pool_workers = std::atoi(next("--pool-workers")); + else if (a == "--no-host-worker") o.no_host_worker = true; + else if (a == "--no-ple-prefetch") o.no_ple_prefetch = true; + else if (a == "--expert-cache") { + const std::string v = next("--expert-cache"); + o.expert_cache = (v == "auto") ? -1 : std::atoi(v.c_str()); + } + else if (a == "--vram-reserve-mib") o.vram_reserve_mib = std::atoi(next("--vram-reserve-mib")); + else if (a == "--prefill") o.prefill_chunk = std::atoll(next("--prefill")); + else if (a == "--no-split-rows") o.no_split_rows = true; + else if (a == "--no-prefill-borrow") o.no_prefill_borrow = true; + else if (a == "--prefill-until") o.prefill_until = std::atoll(next("--prefill-until")); + else if (a == "--dump-final-r") o.dump_final_r = next("--dump-final-r"); + else if (a == "--spec") o.spec = std::atoi(next("--spec")); + else if (a == "--spec-oracle") o.spec_oracle = next("--spec-oracle"); + else if (a == "--spec-corrupt") o.spec_corrupt = std::atoi(next("--spec-corrupt")); + else if (a == "--mtp") o.mtp = next("--mtp"); + else if (a == "--mtp-window") o.mtp_window = std::atoll(next("--mtp-window")); + else if (a == "--pcie-frac") o.pcie_frac = std::atof(next("--pcie-frac")); + else if (a == "--adapt-every") o.adapt_every = std::atoi(next("--adapt-every")); + else if (a == "--spec-min-p") o.spec_min_p = std::atof(next("--spec-min-p")); + else if (a == "--stop-eos") o.stop_eos = true; + else if (a == "--spec-split") o.spec_split = true; + else if (a == "--pcie-mode") o.pcie_mode = next("--pcie-mode"); + else if (a == "--serve") o.serve = true; + else if (a == "--vision") o.vision = true; + else if (a == "--no-spec-split") o.spec_split = false; + else if (a == "--eos-ids") { + std::string e; + if (!parse_i64_list(next("--eos-ids"), o.eos_ids, e)) { std::fprintf(stderr, "--eos-ids: %s\n", e.c_str()); return 2; } + o.stop_eos = true; + } + else if (a == "--adapt-swaps") o.adapt_swaps = std::atoi(next("--adapt-swaps")); + else if (a == "--expert-cache-cpu-order") o.expert_cache_cpu_order = true; + else if (a == "--expert-cache-per-layer") o.expert_cache_per_layer = true; + else if (a == "--no-hit-poke") o.no_hit_poke = true; + else if (a == "--expert-profile") o.expert_profile = next("--expert-profile"); + else if (a == "--gpu-stages") o.gpu_stages = true; + else if (a == "--mmap-experts") o.mmap_experts = true; + else if (a == "--stats") o.stats = true; + else if (a == "--shared-late") o.shared_late = true; + else if (a == "--keep-canonical") o.keep_canonical = true; + else if (a == "--no-token-graph") o.no_token_graph = true; + else if (a == "--no-fused-gr") o.no_fused_gr = true; + else if (a == "--no-fast-attn") o.no_fast_attn = true; + else if (a == "--no-publish-kernel") o.no_publish_kernel = true; + else if (a == "--no-fused-gdn") o.no_fused_gdn = true; + else if (a == "--no-fast-select") o.no_fast_select = true; + else { + // An unknown flag is an ERROR and not a warning: a typo'd `--max-neww` that silently generated 16 + // tokens would look like a working run. + std::fprintf(stderr, "unknown argument: %s\n", a.c_str()); + usage(); + return 2; + } + } + if (!have_tokens && o.serve) { // plan v0.3 P8: requests bring their own tokens + o.tokens = {248045}; + o.max_new = 1; + have_tokens = true; + o.stop_eos = true; + } + if (!have_tokens) { + std::fprintf(stderr, "strata generate: --tokens is required (this build has no tokenizer; see the " + "header of src/program/generate.cpp)\n"); + usage(); + return 2; + } + + if ((o.ple_io != "direct" && o.ple_io != "mmap") || o.ple_row_cache < 0 || o.ple_inflight < 1 || + o.ple_inflight > 1024 || !(o.ple_delay_us >= 0)) { + std::fprintf(stderr, "strata generate: invalid --ple-io/--ple-row-cache/--ple-inflight/--ple-delay-us\n"); + return 2; + } + if (o.kv != "fp16" && o.kv != "int8") { + std::fprintf(stderr, "strata generate: --kv must be fp16 or int8\n"); + return 2; + } + strata::core::qsa_set_kv_int8(o.kv == "int8"); + strata::core::layer_set_shared_early(!o.shared_late); + if (!o.native_preset.empty()) { + if (o.no_ple || o.ple_gguf.empty()) { + std::fprintf(stderr, "strata generate: --native requires --ple-gguf (the PLE key is native too)\n"); + return 2; + } + o.stream_token = true; + o.gr_native_mmvf = true; + o.native_bf16 = o.native_bf16_extra = true; + o.native_ple_key = o.native_moe_combine = o.native_gdn = o.native_router = true; + o.native_qsa = o.native_qsa_indexer = o.native_rope = o.native_ple_postops = true; + if (o.native_head_gguf.empty()) o.native_head_gguf = o.native_preset; + if (o.native_dense_gguf.empty()) o.native_dense_gguf = {o.native_preset, o.ple_gguf}; + // Plan v0.3 (24 Sep): the CPU experts stay on the VNNI kernel. The llama.cpp-CPU-exact q8_0 contract + // cost 27.0 vs 17.2 ms/token of pool time and G-C does not need it; `--cpu-oracle-q8-0` still selects it. + } + if (o.logits_stride > 1 && (o.max_new != 1 || o.dump_logits.empty())) { + std::fprintf(stderr, "strata generate: --logits-stride > 1 requires --max-new 1 and --dump-logits\n"); + return 2; + } + if (o.no_ple && !o.ple_gguf.empty()) { + std::fprintf(stderr, "strata generate: --no-ple and --ple-gguf are mutually exclusive\n"); + return 2; + } + if (o.native_ple_postops && o.no_ple) { + std::fprintf(stderr, "strata generate: --native-ple-postops requires PLE enabled\n"); + return 2; + } + if (!o.no_ple && o.ple_gguf.empty()) { + std::fprintf(stderr, "strata generate: --ple-gguf is required; --no-ple explicitly enables a diagnostic ablation\n"); + return 2; + } + // P7 audit: positions, cells and pooled-block indices are cast to int32 on the device path. + if (o.max_context > 2147483647LL - 8) { + std::fprintf(stderr, "strata generate: --max-context must be below 2^31\n"); + return 2; + } + if (o.max_new <= 0 || o.max_context <= 0 || o.max_new > o.max_context || + o.tokens.size() > (size_t) (o.max_context - o.max_new)) { + std::fprintf(stderr, "strata generate: positive --max-new and --max-context must fit the prompt and generation\n"); + return 2; + } + if (!std::isfinite(o.temperature) || o.temperature < 0 || !std::isfinite(o.top_p) || + o.top_p <= 0 || o.top_p > 1 || o.top_k < 0 || o.expert_cache < -1 || o.pool_workers < 0) { + std::fprintf(stderr, "strata generate: invalid sampling or resource parameter\n"); + return 2; + } + + if (o.native_flash_attn_short && o.max_context > 256) { + std::fprintf(stderr, "strata generate: --native-flash-attn-short requires --max-context <=256\n"); + return 2; + } + if (o.native_flash_attn_short && (o.gpu_only_full || o.graph_only || o.gpu_stages)) { + std::fprintf(stderr, "strata generate: --native-flash-attn-short requires the normal decode loop for status validation\n"); + return 2; + } + if (o.native_ple_key && (o.native_dense_gguf.empty() || o.no_ple)) { + std::fprintf(stderr, "strata generate: --native-ple-key requires PLE and --native-dense-gguf\n"); + return 2; + } + if (o.cpu_oracle_q8_0 && (o.expert_cache != 0 || !o.expert_profile.empty())) { + std::fprintf(stderr, "strata generate: --cpu-oracle-q8-0 cannot be combined with --expert-cache or --expert-profile until the GPU expert contract matches\n"); + return 2; + } + + // **BEFORE ANYTHING ELSE.** The CPU expert kernel is AVX-512 (VNNI + VBMI) and its translation unit is + // compiled `/arch:AVX512`, so on a CPU without those features it does not fail - it executes an illegal + // instruction at some unpredictable token. Refusing at second zero is the whole point of P2.S3's check. + strata::kernels::cpu::expert_set_oracle_q8_0(o.cpu_oracle_q8_0); + + std::string err; + if (!o.native_head_gguf.empty() && !o.stream_token) { + std::fprintf(stderr, "--native-head-gguf requires --stream-token\n"); + return 2; + } + // Plan v0.3 P6: where the experts live. A native pack (tools/iq_pack.py: the IQ2_XS / IQ3_XXS files) keeps + // every quantized tensor in its GGUF form, so it needs --native (the dense projections, head and embedding + // come from the model file) and runs its experts in verify windows only (--spec). + { + const strata::core::ModelGeometry g0; + if (!strata::kernels::cpu::expert_layout_load(o.pack, g0.n_layers, g0.n_expert, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + } + const bool native_pack = strata::kernels::cpu::expert_layout().native; + // plan v0.3 P6: the PCIe share of the missed experts, measured per kind of pack (the paper, finding on PCIe) + if (o.pcie_frac < 0.0) o.pcie_frac = native_pack ? 0.55 : 0.2; + // the canonical Q2_0 pack's CPU kernels are AVX-512 only; a native pack runs on AVX2 CPUs as well + if (!native_pack) strata::kernels::cpu::cpu_require_expert_support(); + else if (!strata::kernels::cpu::cpu_avx512_ok()) + std::fprintf(stderr, "strata generate: this CPU has no AVX-512: the expert kernels run on AVX2\n"); + strata::core::NativeEmbed native_embed; + if (native_pack) { + if (o.native_preset.empty() || o.spec < 2 || o.keep_canonical || + (o.prefill_chunk <= 0 && o.tokens.size() > 1)) { + std::fprintf(stderr, "strata generate: %s is a native (IQ) pack: it needs --native SHARD1, --spec T (T >= 2) " + "and --prefill CHUNK\n", o.pack.c_str()); + return 2; + } + const strata::core::ModelGeometry g0; + if (!native_embed.load(o.native_preset, g0.n_embd, 248320, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + strata::core::set_native_embed(&native_embed); + std::fprintf(stderr, "strata generate: native pack: %s experts (largest blob %.2f MB), token embedding " + "type %d in mapped host memory (%.0f MiB)\n", + o.pack.c_str(), (double) strata::kernels::cpu::expert_layout().max_blob / 1e6, + native_embed.type(), (double) native_embed.bytes() / 1048576.0); + } + // Plan v0.3 P1: tensors served in native form are not also loaded in canonical form (~2.7 GB of VRAM back + // to the expert cache with --native). `--keep-canonical` loads both, as before. + std::set skip; + if (!o.keep_canonical) { + if (!o.native_dense_gguf.empty() && + !strata::core::NativeDense::served_names(o.native_dense_gguf, o.native_ple_key, skip, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + if (!o.native_head_gguf.empty()) skip.insert("output.weight"); + // the PLE module validates its canonical key at construction (8 MB); a native pack has none to load + if (!native_pack) skip.erase("blk.1.ple_key.weight"); + if (native_pack) skip.insert("token_embd.weight"); + } + uint64_t pool_bytes = 0; + if (!strata::core::WeightTable::pool_bytes(o.pack, pool_bytes, err, skip.empty() ? nullptr : &skip)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + void* arena = nullptr; + if (cudaMalloc(&arena, pool_bytes) != cudaSuccess) { + std::fprintf(stderr, "strata generate: cudaMalloc(%llu) for the weight arena failed\n", + (unsigned long long) pool_bytes); + return 1; + } + strata::core::WeightTable wt; + if (!wt.load(o.pack, arena, pool_bytes, err, skip.empty() ? nullptr : &skip)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + std::fprintf(stderr, "strata generate: %llu MiB of weights loaded from %s (%zu canonical tensors skipped: " + "served natively)\n", + (unsigned long long) (pool_bytes >> 20), o.pack.c_str(), skip.size()); + + strata::core::NativeDense native_dense; + if (!o.native_dense_gguf.empty()) { + if (!native_dense.load(o.native_dense_gguf, wt, err, o.native_ple_key)) { + std::fprintf(stderr, "strata generate: native dense projections: %s\n", err.c_str()); + return 1; + } + std::fprintf(stderr, "strata generate: %zu native projection matrices, %.2f MiB of weights\n", + native_dense.tensor_count(), (double) native_dense.weight_bytes() / (1024.0 * 1024.0)); + } + + strata::kernels::gr_set_fp32_activations(o.gr_fp32_activations); + strata::kernels::gr_set_native_mmvf(o.gr_native_mmvf); + // Plan v0.3 P3: the fused hyper-connection read rides the native (FP32-activation) contract; the per-stage + // and dump measurements need the unfused layout of R, so they keep the old kernels. + strata::core::layer_set_fast_attn(!o.no_fast_attn); + strata::core::layer_set_publish_kernel(!o.no_publish_kernel); + strata::core::layer_set_fused_gdn(!o.no_fused_gdn); + strata::core::layer_set_fast_select(!o.no_fast_select); + strata::core::layer_set_fused_gr(o.gr_native_mmvf && !o.no_fused_gr && !o.gpu_stages && o.dump_layers.empty() && + o.dump_halves.empty() && !o.stage_timing); + strata::core::layer_set_native_bf16(o.native_bf16); + strata::core::layer_set_native_flash_attn_short(o.native_flash_attn_short); + strata::kernels::ple_set_native_bf16(o.native_bf16_extra); + strata::kernels::shared_expert_set_native_bf16(o.native_bf16_extra); + strata::kernels::native_moe_combine_set_enabled(o.native_moe_combine); + strata::kernels::native_gdn_set_enabled(o.native_gdn); + strata::kernels::native_router_set_enabled(o.native_router); + strata::kernels::native_qsa_set_enabled(o.native_qsa); + strata::kernels::native_qsa_indexer_set_enabled(o.native_qsa_indexer); + strata::kernels::native_rope_set_enabled(o.native_rope); + // The vision path: every rope kernel reads a cell's (t, h, w) from this table (strata/kernels/mrope.hpp). It is + // the identity until an image request, and it is set here, before any CUDA graph captures a rope kernel. + int32_t* d_mrope = nullptr; + std::vector mrope_host; + if (o.vision) { + const int64_t cells = o.max_context + 64; + mrope_host.resize((size_t) cells * 3); + for (int64_t c = 0; c < cells; ++c) + mrope_host[(size_t) c * 3] = mrope_host[(size_t) c * 3 + 1] = mrope_host[(size_t) c * 3 + 2] = (int32_t) c; + if (cudaMalloc(&d_mrope, mrope_host.size() * sizeof(int32_t)) != cudaSuccess || + cudaMemcpy(d_mrope, mrope_host.data(), mrope_host.size() * sizeof(int32_t), cudaMemcpyHostToDevice) != + cudaSuccess) { + std::fprintf(stderr, "strata generate: cannot allocate the image position table\n"); + return 1; + } + strata::kernels::mrope_table_set(d_mrope); + } + strata::kernels::ple_set_native_postops(o.native_ple_postops); + const strata::core::ModelGeometry g; + const int64_t K = 10; + if (o.max_context < (int64_t) o.tokens.size() + o.max_new) { + std::fprintf(stderr, "strata generate: --max-context %lld cannot hold %zu prompt + %lld new tokens\n", + (long long) o.max_context, o.tokens.size(), (long long) o.max_new); + return 2; + } + + void* sbuf = nullptr; + if (cudaMalloc(&sbuf, strata::core::session_bytes(g, o.max_context, K)) != cudaSuccess) { + std::fprintf(stderr, "strata generate: session state allocation failed\n"); + return 1; + } + strata::core::SessionState ss; + // **THE ENGINE RAN ON THE LEGACY DEFAULT STREAM, WHICH ON WDDM IS THE SLOW PATH.** All four session + // calls - `session_capture`, `session_replay`, `session_token` and `session_loop` - were handed `nullptr`, + // i.e. stream 0. `bench/micro/kernel_costs.cu` measures what that costs: EVERY kernel it launches through + // a wrapper comes back at 28-31 us REGARDLESS OF SIZE, `scale_inplace` on 2,048 floats and `silu_inplace` + // on 10,240 floats being indistinguishable, which is a fixed per-launch cost and not execution. + // `bench/micro/graph_node_cost.cu` measures the same kernels on a real stream at 3.63 us ungrapped and + // 0.805 us inside a graph. **That is an ~8x penalty on every launch in the engine.** + cudaStream_t main_stream = nullptr; + if (cudaStreamCreateWithFlags(&main_stream, cudaStreamNonBlocking) != cudaSuccess) { + std::fprintf(stderr, "strata generate: cannot create the main stream\n"); + return 1; + } + void* const main_cs = (void*) main_stream; + if (strata::core::session_init(g, o.max_context, K, sbuf, ss) == 0) { + std::fprintf(stderr, "strata generate: session_init failed\n"); + return 1; + } + + // ---- **THE HALF-LEVEL DUMP HAS TO BE ARMED BEFORE `session_capture`, AND THE LADDER MUST NOT BE.** The + // half copies are issued from inside `block_layer_pre`/`block_layer_post`, so they are only ever enqueued + // while a graph is being CAPTURED - arming `ss.block.dump` afterwards would produce a file of zeros that + // reads exactly like a wrong answer. The ladder is the opposite: `session_loop` enqueues it per token on + // the replay stream, so it must be armed after capture to stay out of the graph. + const uint64_t half_stride = (uint64_t) 2 * g.n_embd + (uint64_t) 2 * g.hc + + (uint64_t) g.n_head * g.head_dim + + (uint64_t) 5 * g.n_head_kv * g.head_dim + 8; + std::FILE* half_dump = nullptr; + float* half_stage = nullptr; + if (!o.dump_halves.empty()) { + half_dump = std::fopen(o.dump_halves.c_str(), "wb"); + if (half_dump == nullptr) { + std::fprintf(stderr, "strata generate: cannot write %s\n", o.dump_halves.c_str()); + return 1; + } + const size_t n = (size_t) g.n_layers * (size_t) half_stride; + if (cudaHostAlloc((void**) &half_stage, n * sizeof(float), cudaHostAllocDefault) != cudaSuccess) { + std::fprintf(stderr, "strata generate: cannot pin the half-dump staging buffer\n"); + return 1; + } + ss.block.dump = half_stage; + } + + strata::core::Doorbell db; + if (strata::core::doorbell_init(g, K, db) == 0) { + std::fprintf(stderr, "strata generate: doorbell_init failed\n"); + return 1; + } + ss.db = &db; + + // ================================ THE PLE ================================ + // + // **ITS ABSENCE IS WHY GATE C1 FAILED** (LEDGER L123): layer 1 carries six `blk.1.ple_*` tensors, the whole + // module was built and parity-tested, and nothing called it. Everything below is construction - the table + // is a mapping of the ORIGINAL second GGUF shard, the six weights are already loaded in the arena, and the + // three buffers are the only allocation. + strata::kernels::PleTable ple_table; + std::vector ple_emb_host((size_t) strata::kernels::NG_N_EMBD); + float* ple_emb_dev = nullptr; + float* ple_scratch = nullptr; + if (!o.ple_gguf.empty()) { + strata::kernels::PleIoOptions pio; + pio.mode = o.ple_io == "mmap" ? strata::kernels::PleIo::Mmap : strata::kernels::PleIo::Direct; + pio.max_inflight = (uint32_t) o.ple_inflight; + pio.cache_rows = (uint64_t) o.ple_row_cache; + pio.io_thread = !o.ple_sync_submit; + if (!ple_table.open(o.ple_gguf, err, pio)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + const strata::core::WeightRef* wk = wt.find("blk.1.ple_key.weight"); + const strata::core::WeightRef* wv = wt.find("blk.1.ple_value.weight"); + const strata::core::WeightRef* wnk = wt.find("blk.1.ple_norm_key.weight"); + const strata::core::WeightRef* wnq = wt.find("blk.1.ple_norm_query.weight"); + const strata::core::WeightRef* wnc = wt.find("blk.1.ple_norm_conv.weight"); + const strata::core::WeightRef* wc = wt.find("blk.1.ple_conv1d.weight"); + if (!wk || !wv || !wnk || !wnq || !wnc || !wc) { + std::fprintf(stderr, "strata generate: the pack has no blk.1.ple_* tensors, so the PLE cannot be " + "wired - and running without it is a DIFFERENT MODEL (LEDGER L123)\n"); + return 1; + } + // `ple_key` is S2 and the loader has already widened its scales to f32, so the two planes are located + // by the sizes the `WeightRef` records rather than re-derived - the same rule `plane_ptrs` follows. + if (!wk->quantized()) { + // plan v0.3 P6: the IQ model files' BF16 key (the pack's extra.bin, raw BF16) + ss.ple.w.key_bf16 = (const uint16_t*) wk->data; + } else if (wk->data != nullptr) { + ss.ple.w.key_codes = (const uint8_t*) wk->data; + ss.ple.w.key_scales = (const float*) ((const uint8_t*) wk->data + wk->codes_bytes); + } + if (o.native_ple_key && wk->quantized()) { + if (!wk->native_data || wk->native_type != 42 || !wk->native_q8_1) { + std::fprintf(stderr, "strata generate: native PLE key is absent or incompatible\n"); + return 1; + } + ss.ple.w.key_native_data = wk->native_data; + ss.ple.w.key_native_type = wk->native_type; + ss.ple.w.key_native_q8_1 = wk->native_q8_1; + } + ss.ple.w.value_bf16 = (const uint16_t*) wv->data; + ss.ple.w.norm_key = (const float*) wnk->data; + ss.ple.w.norm_query = (const float*) wnq->data; + ss.ple.w.norm_conv = (const float*) wnc->data; + ss.ple.w.conv1d_f16 = (const uint16_t*) wc->data; + ss.ple.consts = strata::kernels::ple_artifact_consts(); + if (o.ple_delay_us > 0) ple_table.set_injected_delay_us(o.ple_delay_us); + ss.ple.table = &ple_table; + ss.ple.token = &ss.ple_token; + ss.ple.prev = ss.ple_prev; + ss.ple.hist = ss.ple_hist; + ss.ple.emb_host = ple_emb_host.data(); + if (cudaMalloc((void**) &ple_emb_dev, (size_t) strata::kernels::NG_N_EMBD * 4) != cudaSuccess || + cudaMalloc((void**) &ple_scratch, strata::core::ple_run_scratch_bytes()) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the PLE buffers failed\n"); + return 1; + } + ss.ple.emb_dev = ple_emb_dev; + ss.ple.scratch = ple_scratch; + if (!ss.ple.ready()) { + std::fprintf(stderr, "strata generate: the PLE run is not ready after construction\n"); + return 1; + } + std::fprintf(stderr, "strata generate: PLE on, table %llu rows of %s\n", + (unsigned long long) ple_table.rows(), o.ple_gguf.c_str()); + } else { + std::fprintf(stderr, + "strata generate: PLE OFF by explicit --no-ple diagnostic request.\n" + " The tokens below are NOT this model's; this is only useful for A/B measurement.\n"); + } + + float* d_parts = nullptr; + if (cudaMalloc(&d_parts, (size_t) K * g.n_embd * 4) != cudaSuccess || + cudaMemset(d_parts, 0, (size_t) K * g.n_embd * 4) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the parts buffer failed\n"); + return 1; + } + + // ---- the CPU expert pool + // + // R2.1: the experts are loaded into a RESIDENT ARENA by default. The mmap path is kept behind + // `--mmap-experts` because it is the A/B arm, not because it is competitive. + // + // The reasoning is the review's C1 and it is now measured on both sides. `FileExpertSource` maps the 34 GB + // file, and mapped file pages are the first thing the OS reclaims; the engine's rate then depends on whether + // the standby list happens to hold `experts.bin`, which is why two consecutive runs of the SAME BINARY with + // the SAME FLAGS measured 71.97 and 34.78 ms/token in the pool (7.54 vs 12.18 tok/s). The arena is + // anonymous memory the engine owns, and the pool runs at 19.41 ms/token - 1.79x better than the warm mmap + // and 3.7x better than the cold one. + // + // IT IS NOT PINNED, and that is reported rather than hidden: `cudaHostRegister` on 31.64 GiB fails with + // "out of memory" (you cannot pin 34 of 63 GB) and the arena falls back to 4 KB anonymous pages. That is + // fine for the CPU pool - which is all that exists today - and NOT fine for Phase 3, whose cache fills and + // CPU/PCIe miss split need the GPU to DMA out of this arena. Read `note()` when that lands. + // + // The earlier "the arena does not fit" conclusion was WRONG and is worth recording: the failure was a stale + // CUDA error left set by the failed `cudaHostRegister` and read later by `gr_read`'s launch check. See the + // note in `pinned.cu`. + strata::core::FileExpertSource src; + strata::core::ArenaExpertSource arena_src; + strata::core::ExpertSource* srcp = nullptr; + if (o.mmap_experts) { + if (!src.open(o.pack, g.n_layers, g.n_expert, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + std::fprintf(stderr, "strata generate: experts via mmap (--mmap-experts; the A/B arm of R2.1)\n"); + srcp = &src; + } else { + arena_src.set_gguf(o.native_preset); // plan v0.3 P6: a native pack may take its experts from shard 1 + if (!arena_src.open(o.pack, g.n_layers, g.n_expert, /*threads=*/6, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + std::fprintf(stderr, "strata generate: expert arena: %s\n", arena_src.note().c_str()); + std::fprintf(stderr, "strata generate: loaded %.2f GiB at %.2f GiB/s\n", + (double) strata::kernels::cpu::expert_layout().total / (1024.0 * 1024 * 1024), + arena_src.load_gib_per_second()); + srcp = &arena_src; + } + // Plan v0.3 P6: the MTP draft layer, loaded before the VRAM expert tier is sized from what is left. + strata::core::MtpDrafter mtp; + if (!o.mtp.empty()) { + if (o.spec < 2) { + std::fprintf(stderr, "strata generate: --mtp is ignored without --spec T (T >= 2)\n"); + o.mtp.clear(); + } + if (!o.mtp.empty()) mtp.set_prompt_len((int64_t) o.tokens.size()); + if (!o.mtp.empty() && !mtp.load(o.mtp, g, ss, o.spec, err, o.mtp_window)) { std::fprintf(stderr, "strata generate: %s\n", err.c_str()); return 1; } + } + strata::kernels::cpu::ExpertPool pool(o.pool_workers, /*pin=*/true, /*host_works=*/!o.no_host_worker); + if (o.no_ple_prefetch) strata::kernels::ple_prefetch_enable(false); + // ---- R4's slot storage. Allocated AFTER the weights and the session, so `cudaMemGetInfo` inside `open` + // sees the memory this process actually has left rather than the card's idle figure - and refuses with both + // numbers if the slots do not fit, instead of handing back a cache smaller than it was asked for. + strata::core::ExpertCache xcache; + std::vector> profile; + if (!o.expert_profile.empty()) { + int64_t pslots = 0; + if (!strata::core::read_expert_profile(o.expert_profile, g.n_layers, g.n_expert, profile, pslots, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + // The profile knows how many slots it was built for. `--expert-cache 0` means "take the profile's"; + // an explicit smaller number is allowed and simply truncates the ranked list, which is the right + // behaviour for asking "what would 2,000 slots give" without rebuilding the file. + if (o.expert_cache == 0) o.expert_cache = (int) pslots; + std::fprintf(stderr, "strata generate: profile %s: %zu ranked pairs, built for %lld slots\n", + o.expert_profile.c_str(), profile.size(), (long long) pslots); + } + if (o.expert_cache < 0) { + size_t free_b = 0, total_b = 0; + cudaMemGetInfo(&free_b, &total_b); + // Plan v0.3 P5: the batched prompt path's chunk buffers are allocated later, so they are reserved here - + // under WDDM an over-subscribed allocation does not fail, it pages to system memory and crawls. + // (with borrowing - the default with a profile - the prompt path lends cache slots instead) + const bool borrow = !o.no_prefill_borrow && !o.expert_profile.empty(); + const int64_t prefill_mib = (o.prefill_chunk > 0 && !borrow) ? 160 + (o.prefill_chunk * 680) / 1024 : 0; + const int64_t reserve = ((int64_t) o.vram_reserve_mib + prefill_mib) << 20; + int64_t slots = ((int64_t) free_b - reserve) / (int64_t) strata::kernels::cpu::expert_layout().max_blob; + if (!profile.empty()) slots = std::min(slots, (int64_t) profile.size()); + o.expert_cache = (int) std::max(slots, 0); + std::fprintf(stderr, "strata generate: expert cache auto: %.2f GiB free, %d MiB reserved -> %d slots\n", + (double) free_b / 1073741824.0, o.vram_reserve_mib, o.expert_cache); + } + // plan v0.3 P6: a native pack's blobs differ per layer, so with a profile its slots are sized per pair: the + // same VRAM holds ~30% more IQ3_XXS experts than slots of the largest blob would + std::vector sized_slots; + if (native_pack && o.expert_cache > 0 && !profile.empty()) { + size_t free_b = 0, total_b = 0; + cudaMemGetInfo(&free_b, &total_b); + const auto& lay = strata::kernels::cpu::expert_layout(); + const uint64_t budget = (uint64_t) o.expert_cache * lay.max_blob; // what the uniform sizing granted + uint64_t used = 0; + size_t free_room = free_b > ((size_t) o.vram_reserve_mib << 20) ? free_b - ((size_t) o.vram_reserve_mib << 20) : 0; + const uint64_t cap = std::min(budget, (uint64_t) free_room); + for (const auto& pr : profile) { + const uint64_t b = (lay.blob_bytes(pr.first) + 255) / 256 * 256; + if (used + b > cap) break; + used += b; + sized_slots.push_back((int64_t) lay.blob_bytes(pr.first)); + } + o.expert_cache = (int) sized_slots.size(); + } + if (o.expert_cache > 0) { + const bool ok = sized_slots.empty() + ? xcache.open(o.expert_cache, g.n_layers, g.n_expert, (int64_t) strata::kernels::cpu::expert_layout().max_blob, err) + : xcache.open_sized(sized_slots, g.n_layers, g.n_expert, err); + if (!ok) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + std::fprintf(stderr, "strata generate: expert cache %lld slots, %.2f GiB of VRAM; policy is\n", + (long long) xcache.slots(), xcache.gib()); + xcache.set_per_layer_admission(o.expert_cache_per_layer); + // **ROUND 328: THE HIT PATH IS PROVABLY WRONG, AND THIS SAYS SO OUT LOUD RATHER THAN LETTING IT + // CORRUPT A RUN QUIETLY.** With the cache on, the generated tokens DIVERGE from the cache-off run: + // at 256 global slots (2.97% hits) the first difference is at **token 40**; at 4096 per-layer slots + // (54.4% hits) it is at **token 0**. The cache-off run is deterministic across repeated runs, so + // this is a real fault in `moe_hit_grouped_s2`'s inputs or the fill - not noise. It also explains + // what R4 recorded as "a better profile makes the token worse": more hits means more wrong rows, so + // the payoff is non-monotone BY CONSTRUCTION rather than by any memory-system effect. + // The cache stays opt-in and this warning is not a refusal, because the divergence IS the diagnostic. + std::fprintf(stderr, + "strata generate: *** WARNING: --expert-cache is enabled and the GPU hit path is NOT\n" + " CORRECT. The generated tokens diverge from a cache-off run (measured:\n" + " first difference at token 40 at 2.97%% hits, token 0 at 54.4%%). Any\n" + " timing from this run is real; any OUTPUT from it is not. ***\n"); + if (o.expert_cache_per_layer) { + int64_t lo = 0, hi = 0; + xcache.layer_slot_range(0, lo, hi); + std::fprintf(stderr, " R4.2g PER-LAYER: each layer owns %lld slots (%lld..%lld).\n", + (long long) (hi - lo), (long long) lo, (long long) (hi - 1)); + } else if (profile.empty()) { + std::fprintf(stderr, " compulsory-miss (fills with whatever the run routes first).\n"); + } else { + std::fprintf(stderr, " PROFILE, ranked by routing frequency, no eviction.\n"); + } + } + // ---- R4.2e: fill the tier from the profile. This is the only place the plan is applied, and it runs + // ONCE: with `slots` pairs and `slots` slots the cache is full when this returns, so the decode-time + // admission finds no room and every non-profiled expert stays a CPU miss. That is what makes the profile + // the policy rather than a hint. + int64_t prefilled = 0; + if (!profile.empty() && srcp != nullptr) { + const int64_t want = std::min((int64_t) profile.size(), xcache.slots()); + for (int64_t i = 0; i < want; ++i) { + const int32_t slot = xcache.admit(profile[(size_t) i].first, profile[(size_t) i].second); + if (slot == strata::core::kNotResident) break; + const uint8_t* b = srcp->blob(profile[(size_t) i].first, profile[(size_t) i].second); + if (b == nullptr || !xcache.fill_slot_blocking(slot, b, err, + (int64_t) strata::kernels::cpu::expert_layout().blob_bytes(profile[(size_t) i].first))) { + std::fprintf(stderr, "strata generate: the profile fill failed at pair %lld: %s\n", + (long long) i, err.c_str()); + return 1; + } + ++prefilled; + } + // **AND ONE SLOT IS READ BACK AND COMPARED.** A residency table that is right about indices and wrong + // about bytes produces a plausible token, which is this project's most expensive failure mode; the + // cache's own `verify_slot` is the check and it costs one 1.38 MB D2H at startup. + if (!xcache.verify_slot(xcache.slot_of(profile[0].first, profile[0].second), + srcp->blob(profile[0].first, profile[0].second), err, + (int64_t) strata::kernels::cpu::expert_layout().blob_bytes(profile[0].first))) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + std::fprintf(stderr, "strata generate: pre-filled %lld of %lld slots from the profile; slot 0 verified\n", + (long long) prefilled, (long long) want); + } + + Drive drive; + drive.d.hit_cpu_order = o.expert_cache_cpu_order; + drive.d.split_rows = !o.no_split_rows; + drive.d.pool = &pool; + drive.d.src = srcp; + drive.d.n_expert = g.n_expert; + drive.d.jobs.resize((size_t) K); + // ---- R4.2c: THE HIT PATH. Every one of these is required for `hits_ready()`, which is all-or-nothing on + // purpose: a half-configured hit path would compute some experts twice and others not at all, and a token + // built on that is wrong rather than refused. + void* hit_scratch = nullptr; + int32_t* d_hit_slot = nullptr; + int32_t* d_hit_dst = nullptr; + uint8_t* d_hit_q8 = nullptr; + float* d_hit_q8_scale = nullptr; ///< R4.2h: the fp32 activation scales the CPU path also uses + float* d_hit_out = nullptr; + if (o.expert_cache > 0 && !o.no_pool) { + const uint64_t sb = strata::kernels::moe_hit_grouped_scratch_bytes(K, g.n_embd, strata::kernels::cpu::FF); + if (cudaMalloc(&hit_scratch, (size_t) sb) != cudaSuccess || + cudaMalloc((void**) &d_hit_slot, (size_t) K * sizeof(int32_t)) != cudaSuccess || + cudaMalloc((void**) &d_hit_dst, (size_t) K * sizeof(int32_t)) != cudaSuccess || + cudaMalloc((void**) &d_hit_q8, (size_t) (g.n_embd / 32) * 34) != cudaSuccess || + // R4.2h: the fp32 activation scales. Without this the GPU's hits use the block's fp16 `d` + // while the CPU's misses use `ActQ::scale`, which is fp32 - a 4.761e-04 relative disagreement on + // every chunk, and the reason enabling the cache changed the tokens. + cudaMalloc((void**) &d_hit_q8_scale, (size_t) (g.n_embd / 32) * sizeof(float)) != cudaSuccess || + cudaMalloc((void**) &d_hit_out, (size_t) K * g.n_embd * 4) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the R4 hit path could not allocate its device buffers\n"); + return 1; + } + drive.d.cache = &xcache; + drive.d.cache_stream = main_cs; + drive.d.cache_base = (const uint8_t*) xcache.device_slot(0); + drive.d.cache_blob = (int64_t) strata::kernels::cpu::expert_layout().max_blob; + drive.d.cache_slot_off = xcache.slot_offsets(); + drive.d.hit_scratch = hit_scratch; + drive.d.parts_out = d_parts; + drive.d.hit_out = d_hit_out; + drive.d.parts_elems = K * g.n_embd; + drive.d.mixed = ss.block.mixed; + drive.d.x_q8_0_hit = d_hit_q8; + drive.d.x_q8_0_hit_scale = d_hit_q8_scale; + drive.d.d_slot = d_hit_slot; + drive.d.d_dst = d_hit_dst; + drive.d.h_slot.resize((size_t) K); + cudaEvent_t hit_done = nullptr; + if (cudaEventCreate(&hit_done) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the hit path could not create its probe event\n"); + return 1; + } + drive.d.hit_done = (void*) hit_done; + drive.d.hit_poke = !o.no_hit_poke; + drive.d.h_dst.resize((size_t) K); + std::fprintf(stderr, "strata generate: R4 hit path ON - resident experts are computed on the GPU\n"); + } + // ---- P0.S8: the routing trace. Only meaningful with the pool running, because the ids arrive through + // the doorbell that the pool consumes - so `--no-pool` is refused rather than silently producing an empty + // file that would read as "the router selected nothing". + // ---- PER-STAGE TIMING. `--no-capture` only: an event recorded inside a stream capture is silently + // dropped, so a captured graph cannot carry these events and the numbers would be zeros that read as + // "every stage is free". Refusing is the fix. + if (o.stage_timing) { + if (!o.no_capture) { + std::fprintf(stderr, "strata generate: --stage-timing records CUDA events inside the layer path, " + "and an event record inside a stream capture is silently dropped. Pass " + "--no-capture as well.\n"); + return 2; + } + if (!strata::core::stage_timing_enable()) { + std::fprintf(stderr, "strata generate: stage_timing_enable failed\n"); + return 1; + } + strata::core::stage_timing_name(0, "gr_read (attn)"); + strata::core::stage_timing_name(1, "attention block"); + strata::core::stage_timing_name(2, "gr_write (attn)"); + strata::core::stage_timing_name(3, "gr_read (ffn)"); + strata::core::stage_timing_name(4, "moe_route"); + strata::core::stage_timing_name(5, "moe_finish"); + strata::core::stage_timing_name(6, "gr_write (ffn)"); + // The GDN block's internals. It is 36 of the 48 layers, 0.96 ms each, and its entire weight traffic + // is ~26 MB - so ~0.11 ms at the measured read rate. ~13 tiny latency-bound launches live in it and + // a single "attention block" number cannot say which one costs anything. + strata::core::stage_timing_name(8, " gdn: quantize x"); + strata::core::stage_timing_name(9, " gdn: qkv gemv"); + strata::core::stage_timing_name(10, " gdn: conv+silu"); + strata::core::stage_timing_name(11, " gdn: l2 norms"); + strata::core::stage_timing_name(12, " gdn: alpha/beta/gate"); + strata::core::stage_timing_name(13, " gdn: gdn_step"); + strata::core::stage_timing_name(14, " gdn: z + out_norm"); + strata::core::stage_timing_name(15, " gdn: out gemv"); + } + std::FILE* routing = nullptr; + if (!o.dump_routing.empty()) { + if (o.no_pool) { + std::fprintf(stderr, "strata generate: --dump-routing needs the expert pool; the routed ids reach " + "the host through the doorbell the pool reads. Drop --no-pool.\n"); + return 2; + } + routing = std::fopen(o.dump_routing.c_str(), "wb"); + if (routing == nullptr) { + std::fprintf(stderr, "strata generate: cannot write %s\n", o.dump_routing.c_str()); + return 1; + } + drive.routing = routing; + } + strata::core::PoolFn pool_fn = o.no_pool ? nullptr : &drive_pool; + // The hit hook rides the same switch as the pool: with no pool there is no `parts` staging to + // write into, and a hit path with nowhere to write is a wrong token rather than an error. + strata::core::HitFn hit_fn = + (o.no_pool || o.expert_cache <= 0) ? nullptr : &strata::core::expert_hit_run; + void* pool_user = o.no_pool ? nullptr : (void*) &drive; + std::fprintf(stderr, "strata generate: %d expert-pool workers%s%s\n", pool.workers(), + pool.host_works() ? " + the host thread" : "", + o.no_pool ? " (UNUSED: --no-pool)" : ""); + + // **THE MISALIGNMENT WARNING THAT STOOD HERE IS GONE, BECAUSE THE MISALIGNMENT IS FIXED.** + // + // It said the tokens were not the model's, and it was true: `session_loop` handed layer `l`'s expert + // outputs to layer `l+1`, which multiplied them by layer `l+1`'s router weights (LEDGER L100). The loop + // now runs a captured PAIR per layer - `pre[l]` ending with the router and the doorbell, then the CPU + // pool, then `post[l]` which combines those experts with THAT layer's weights - so layer `l`'s experts meet + // layer `l`'s routing. The generated ids changed the moment it landed, which is what a correctness fix + // looks like from the outside. + // + // The cost is real and is recorded rather than hidden: the window for the CPU pool is now whatever GPU + // work follows the ring inside `pre[l]`, which is the shared expert and nothing else - 0.038 ms against + // 0.514 ms of CPU work per layer. A per-layer CPU expert pool cannot be hidden behind a strictly serial + // residual chain; the CPU term is answered by Phase 3's VRAM expert cache, not by this pipeline. + + // ---- the graphs + strata::core::SessionGraphs gr; + if (!o.no_capture && !native_pack) { // plan v0.3 P6: a native pack runs verify windows only + if (!strata::core::session_capture(wt, g, ss, d_parts, gr, err, /*split=*/o.gpu_stages)) { + std::fprintf(stderr, "strata generate: session_capture: %s\n", err.c_str()); + return 1; + } + } + + // **`--no-capture` AND THE EXPERTS ARE MUTUALLY EXCLUSIVE, AND SILENTLY SO.** + // + // The CPU expert pool is wired into `session_loop` - the host loop around the captured graphs - and + // `session_token` has no pool hook at all. So `--no-capture` did not merely change HOW the layers were + // launched: it ran the whole model with `parts` left at whatever the buffer held, which is ZERO, and the + // only symptom was `expert blobs 0` in a stats line nobody had to read. A run that silently omits the + // routed experts is not a slow measurement of this model, it is a measurement of a different model. + // + // Refusing is the fix. `--no-pool` is the explicit way to say "I want the GPU-only floor". + if (o.no_capture && !o.no_pool) { + std::fprintf(stderr, + "strata generate: --no-capture runs `session_token`, which has NO CPU expert pool hook, so " + "the routed experts would silently contribute nothing. Pass --no-pool as well if the " + "GPU-only floor is what you want.\n"); + return 2; + } + // The ladder is written by `session_loop`, and `session_token` does not touch the staging buffer at all - so + // accepting the flag there would produce a file of uninitialised memory, which reads as a wrong answer rather + // than as a mistake. `--no-capture` without `--no-pool` is already refused above, so this catches the pair. + if (o.no_capture && !o.dump_layers.empty()) { + std::fprintf(stderr, + "strata generate: --dump-layers is written by `session_loop`; `--no-capture` runs " + "`session_token` instead, which never fills the staging buffer. Drop one of the two.\n"); + return 2; + } + if (o.no_capture && !o.dump_halves.empty()) { + std::fprintf(stderr, + "strata generate: --dump-halves is CAPTURED into the layer graphs, so it needs the " + "captured path; `--no-capture` never records it. Drop one of the two.\n"); + return 2; + } + + std::fprintf(stderr, "strata generate: session is up; locating the head\n"); + const strata::core::WeightRef* wo = wt.find("output.weight"); + if (wo == nullptr) { std::fprintf(stderr, "strata generate: output.weight is missing\n"); return 1; } + const int64_t n_vocab = wo->ne1; + strata::core::NativeHead native_head; + if (!o.native_head_gguf.empty()) { + if (!native_head.load(o.native_head_gguf, g.n_embd, n_vocab, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + std::fprintf(stderr, "strata generate: experimental native Q5_K head, %llu bytes\n", + (unsigned long long) native_head.weight_bytes()); + } + std::vector logits((size_t) n_vocab); + float* d_logits = nullptr; + if (cudaMalloc(&d_logits, (size_t) n_vocab * 4) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the logits buffer failed\n"); + return 1; + } + auto run_head = [&](void* stream) -> bool { + if (!native_head.loaded()) + return strata::core::lm_head(wt, g, ss.block, d_logits, stream, err); + return strata::core::lm_head_mix(wt, g, ss.block, stream, err) && + native_head.run(ss.block.mixed, d_logits, stream, err); + }; + float* d_emb = nullptr; + if (cudaMalloc(&d_emb, (size_t) g.n_embd * 4) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the embedding buffer failed\n"); + return 1; + } + // **`sample_tokens` TAKES DEVICE POINTERS.** It is a kernel launch; `logits` and `out` are both read and + // written on the device. Passing `logits.data()` - the host vector - faults inside the kernel and the + // error surfaces at the NEXT synchronising call, which here was the next token's `embed_row`, reporting an + // illegal access on a weight plane. Nothing in the parameter names said device. + int* d_next = nullptr; + if (cudaMalloc(&d_next, sizeof(int)) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the sampler output buffer failed\n"); + return 1; + } + + // **`R` IS BOTH THE INPUT AND THE OUTPUT, SO THE NEW TOKEN'S EMBEDDING HAS TO REPLACE THE OLD RESIDUAL.** + // At `pos == 0` that is `session_zero`, which is the reference's own initial condition - the embedding + // broadcast to all `hc` streams. After that `session_zero` would also wipe the recurrence, so the + // broadcast is done directly. Getting this wrong is invisible for exactly one token. + void* token_stream = o.stream_token ? main_cs : nullptr; + auto put_input = [&](int64_t tok, int64_t pos) -> bool { + if (!strata::core::embed_row(wt, g, tok, d_emb, token_stream, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return false; + } + if (pos == 0) { + strata::core::session_zero(ss, g, d_emb, token_stream); + } else { + for (int64_t c = 0; c < g.hc; ++c) + if (cudaMemcpyAsync(ss.R + (size_t) c * g.n_embd, d_emb, (size_t) g.n_embd * 4, + cudaMemcpyDeviceToDevice, (cudaStream_t) token_stream) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the residual broadcast failed\n"); + return false; + } + } + return o.stream_token || cudaDeviceSynchronize() == cudaSuccess; + }; + + strata::kernels::SamplerParams sp; + sp.greedy = o.greedy; + sp.seed = o.seed; + sp.top_k = o.top_k; + sp.top_p = o.top_p; + sp.temperature = o.temperature; + + std::FILE* dump = nullptr; + const int64_t dump_positions = (int64_t) o.tokens.size() - 1 + o.max_new; + if (!o.dump_logits.empty()) { + if (dump_positions > INT32_MAX || n_vocab > INT32_MAX) { + std::fprintf(stderr, "strata generate: logits dump dimensions exceed int32\n"); + return 2; + } + dump = std::fopen(o.dump_logits.c_str(), "wb"); + if (dump == nullptr) { + std::fprintf(stderr, "strata generate: cannot write %s\n", o.dump_logits.c_str()); + return 1; + } + // **THE COUNT IS `n_prompt - 1 + max_new`, NOT `n_prompt + max_new`.** The loop writes one row per + // position from 0, and it stops once `produced` holds `max_new` tokens - and `produced` only starts + // receiving at position `n_prompt - 1`. So a 5-token prompt with `--max-new 6` writes 10 rows, and the + // header used to claim 11. A header that describes a different file from the one written is the same + // class of defect as a self-check that verifies the wrong invariant: anything reading the count instead + // of the size gets a wrong answer that looks authoritative. `tools/logits_identical.py` caught it by + // parsing the header and refusing the file. + const int32_t n_rows = (int32_t) strata::program::logits_selection::row_count(dump_positions, o.logits_stride); + const int32_t hdr[2] = {(int32_t) n_vocab, n_rows}; + if (std::fwrite(hdr, sizeof hdr, 1, dump) != 1) { + std::fprintf(stderr, "strata generate: cannot write logits header\n"); + std::fclose(dump); + return 1; + } + } + + // ---- THE C1 ORACLE: ONE RESIDUAL SNAPSHOT PER LAYER PER POSITION, so the engine can be bisected against + // `llama-debug`'s `l_last-` node instead of against a single end-to-end perplexity. The buffer is + // PINNED because `session_loop` enqueues a device-to-host copy into it after every layer and the transfer + // would otherwise be staged through a pageable bounce buffer on the critical path. + std::FILE* layer_dump = nullptr; + float* layer_stage = nullptr; + const size_t layer_floats = (size_t) (g.n_layers + 1) * (size_t) g.hc * (size_t) g.n_embd; + if (!o.dump_layers.empty()) { + layer_dump = std::fopen(o.dump_layers.c_str(), "wb"); + if (layer_dump == nullptr) { + std::fprintf(stderr, "strata generate: cannot write %s\n", o.dump_layers.c_str()); + return 1; + } + if (cudaHostAlloc((void**) &layer_stage, layer_floats * sizeof(float), cudaHostAllocDefault) != + cudaSuccess) { + std::fprintf(stderr, "strata generate: cannot pin the layer-dump staging buffer\n"); + return 1; + } + } + + // ---- prefill is the DECODE PATH ONE TOKEN AT A TIME, which `phase-2-correct-engine.md:12-13` says is + // fine here: "process the prompt through the decode-style graphs in small batches; a 19K-token prompt will + // take minutes". A real batched prefill is P2.S6's other half and is not this. + // + // **THE LOOP IS `feed -> 48 layers -> head -> sample -> feed`, AND THE FIRST GENERATED TOKEN COMES FROM THE + // LAST *PROMPT* POSITION.** The first version sampled only on the decode positions, so `produced` was + // still empty when the first generated position asked for `produced.back()` - an out-of-bounds read on an + // empty vector. Teacher forcing below is what makes the distinction unnecessary to special-case: for every + // position before the last prompt one, the next input is the PROMPT's next token, and after that it is the + // sampled one. + std::vector produced; + double total_ms = 0; + double prefill_ms = 0; // positions 0 .. n_prompt-2: prompt tokens that only condition + const Clock::time_point t_start = Clock::now(); + double ttft_ms = 0; + const int64_t n_prompt = (int64_t) o.tokens.size(); + int64_t tok = o.tokens[0]; + + // ---- THE PURE-GPU MEASUREMENT. `session_replay` launches all 48 `pre` graphs back to back on one stream + // with NO host work between them - no doorbell poll, no pool, no parts copy - so what it times is the GPU + // executing the layer sequence and nothing else. It had been declared, defined and never called since the + // day it was written. + // + // **THIS IS THE MEASUREMENT THAT SAYS WHETHER THE ENGINE IS HOST-BOUND OR GPU-BOUND**, and the stage table + // cannot answer it: those events measure the interval between two marks on a stream, which includes every + // gap where the GPU sat idle waiting for the host to enqueue the next kernel. In `--no-capture` those gaps + // are the host's launch latency and they are proportional to the KERNEL COUNT rather than to any work, so + // the no-capture stage shares are shares of kernel count - which is why the attention block, with the most + // kernels, looks like 55% of the token there. + // ---- R0.9: THE PER-STAGE TABLE ON THE CAPTURED GRAPH. + if (o.gpu_stages) { + strata::core::doorbell_reset(db); + double mix = 0, ffn = 0, post = 0; + if (!strata::core::session_replay_stages(g, 0, 0, ss, gr, main_cs, mix, ffn, post, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + const int reps = 20; + double t_mix = 0, t_ffn = 0, t_post = 0; + for (int r = 0; r < reps; ++r) { + if (!strata::core::session_replay_stages(g, 0, 0, ss, gr, main_cs, mix, ffn, post, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + t_mix += mix; + t_ffn += ffn; + t_post += post; + } + const double tot = t_mix + t_ffn + t_post; + std::printf("\nper-stage GPU time on the CAPTURED graph, one token over %lld layers\n", + (long long) g.n_layers); + std::printf(" %-22s %9.3f ms/token %8.3f ms/layer %6.1f%%\n", "mixer (gr_read+attn+gr_write)", + t_mix / reps, t_mix / reps / (double) g.n_layers, 100.0 * t_mix / tot); + std::printf(" %-22s %9.3f ms/token %8.3f ms/layer %6.1f%%\n", "ffn front + router", + t_ffn / reps, t_ffn / reps / (double) g.n_layers, 100.0 * t_ffn / tot); + std::printf(" %-22s %9.3f ms/token %8.3f ms/layer %6.1f%%\n", "post (moe_finish+gr_write)", + t_post / reps, t_post / reps / (double) g.n_layers, 100.0 * t_post / tot); + std::printf(" %-22s %9.3f ms/token\n", "sum of the three", tot / reps); + + // ---- AND THE MIXER BY LAYER KIND, because 36 of the 48 are GDN and 12 are QSA and a total cannot + // separate them. Round 309's uncaptured table put GDN at 10.88 ms for 36 layers against QSA's 4.56 for + // 12, which would make the recurrence the largest single R3 target - and that table had `moe_finish` + // wrong by 4x, so the ratio is re-derived here from the captured graph rather than inherited. + { + // **ACCUMULATED OVER `reps`, NOT MEASURED ONCE AND THEN DIVIDED.** The first version called the + // per-layer replay a single time and printed `gdn / reps`, which reported GDN at 0.480 ms/token + // against a mixer total of 14.039 - a factor of exactly `reps`, and the tell was that + // 0.480 + 0.220 = 0.700 = 14.039 / 20. + std::vector acc((size_t) g.n_layers, 0.0), per; + double f2 = 0, p2 = 0; + for (int r = 0; r < reps; ++r) { + if (!strata::core::session_replay_stages_per_layer(g, 0, 0, ss, gr, main_cs, per, f2, p2, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + for (int64_t l = 0; l < g.n_layers; ++l) acc[(size_t) l] += per[(size_t) l]; + } + double gdn = 0, qsa = 0, worst = 0; + int64_t ng = 0, nq = 0, worst_l = 0; + for (int64_t l = 0; l < g.n_layers; ++l) { + const double v = acc[(size_t) l] / reps; + if (strata::core::is_qsa_layer(g, l)) { qsa += v; ++nq; } + else { gdn += v; ++ng; } + if (v > worst) { worst = v; worst_l = l; } + } + std::printf("\n the mixer by layer kind, averaged over %d runs\n", reps); + std::printf(" %-22s %9.3f ms/token %8.3f ms/layer (%lld layers)\n", "GDN layers", + gdn, ng ? gdn / (double) ng : 0.0, (long long) ng); + std::printf(" %-22s %9.3f ms/token %8.3f ms/layer (%lld layers)\n", "QSA layers", + qsa, nq ? qsa / (double) nq : 0.0, (long long) nq); + std::printf(" %-22s layer %lld at %.3f ms\n", "worst mixer layer", (long long) worst_l, worst); + std::printf(" %-22s %9.3f ms/token (must equal the mixer above)\n", "GDN + QSA", gdn + qsa); + } + + // ================================ R0.11: THE FIVE STAGES SEPARATELY ================================ + // + // Prefixes 1..5 are replayed per layer with the residual restored between them, and consecutive + // differences are the per-stage times. **THE CHECK IS THAT THE FIVE SUM TO THE THREE-GRAPH TOTAL** - + // the same independent-restatement test that caught round 320's divide-by-reps bug, and it is the only + // reason to believe a table built out of differences. + { + std::vector acc5(5, 0.0), per, s5; + for (int r = 0; r < reps; ++r) { + if (!strata::core::session_replay_stage_prefixes(g, 0, 0, ss, gr, main_cs, s5, per, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + for (int k = 0; k < 5; ++k) acc5[(size_t) k] += s5[(size_t) k]; + } + static const char* sn[5] = {"0 gr_read (attn)", "1 attention", "2 gr_write (attn)", + "3 gr_read (ffn)", "4 moe_route (router)"}; + const char* kind[5] = {"GR", "ATTN", "GR", "GR", "ROUTER"}; + double tot5 = 0, gr_ms = 0; + std::printf("\n the five stages separately, by differencing prefixes\n"); + std::printf(" %-24s %-8s %10s %10s %8s\n", "stage", "kind", "ms/token", "ms/layer", "share"); + for (int k = 0; k < 5; ++k) { + const double v = acc5[(size_t) k] / reps; + tot5 += v; + if (k != 1 && k != 4) gr_ms += v; + std::printf(" %-24s %-8s %10.3f %10.4f %7.1f%%\n", sn[k], kind[k], v, + v / (double) g.n_layers, 0.0); + } + for (int k = 0; k < 5; ++k) { + const double v = acc5[(size_t) k] / reps; + (void) v; + } + std::printf(" %-24s %-8s %10.3f\n", "sum of the five", "", tot5); + std::printf(" %-24s %-8s %10.3f <- R3.3's target is <= 3 ms for all four passes\n", + "GR passes (0,2,3)", "GR", gr_ms); + std::printf("\n the three-graph total above was %.3f ms/token; the five must account for it.\n", + tot / reps); + + // ================================ R3.5c: THE SAME TABLE, A DIFFERENT WAY ================================ + // + // The differencing table above mixes five graphs per layer, so stage 4's interval carries the launch + // of the FULL five-stage graph while stage 3's carries a four-stage one. This sweep launches ONE + // graph type per layer and nothing else, so that bias cannot exist. **If the two disagree, the + // difference IS the bias and this one is right** - and stage 4 is the router, so it is exactly the + // number that must not be wrong. + { + double sweep[6] = {0, 0, 0, 0, 0, 0}; + for (int k = 1; k <= 5; ++k) { + double acc = 0, one = 0; + for (int r = 0; r < reps; ++r) { + if (!strata::core::session_replay_stage_sweep(g, 0, 0, ss, gr, main_cs, k, one, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + acc += one; + } + sweep[k] = acc / reps; + } + std::printf("\n the same five stages, by SWEEPING each prefix back to back (no graph switching)\n"); + std::printf(" %-24s %10s %10s %12s\n", "stage", "prefix sweep", "difference", "bias"); + static const char* sn2[5] = {"0 gr_read (attn)", "1 attention", "2 gr_write (attn)", + "3 gr_read (ffn)", "4 moe_route (router)"}; + for (int k = 0; k < 5; ++k) { + const double sw = sweep[k + 1] - sweep[k]; + const double df = acc5[(size_t) k] / reps; + std::printf(" %-24s %10.3f %10.3f %11.1f%%\n", sn2[k], sw, df, + sw != 0.0 ? 100.0 * (df / sw - 1.0) : 0.0); + } + std::printf(" %-24s %10.3f (full pre, 48 layers)\n", "prefix 5 total", sweep[5]); + } + } + std::printf("\n compare `--gpu-only-full`, which replays the same work as TWO graphs per layer. The\n"); + std::printf(" three sum slightly above it because each launch carries the driver's gap.\n"); + strata::core::session_graphs_free(gr); + strata::core::doorbell_free(db); + cudaFree(d_next); + return 0; + } + + if (o.graph_only) { + strata::core::doorbell_reset(db); + // one warm pass so the first launch does not pay for page mapping + if (!strata::core::session_replay(g, 0, 0, ss, gr, main_cs, err)) { + std::fprintf(stderr, "strata generate: session_replay warm: %s\n", err.c_str()); + return 1; + } + if (cudaDeviceSynchronize() != cudaSuccess) { + std::fprintf(stderr, "strata generate: session_replay warm faulted\n"); + return 1; + } + const int reps = 20; + const Clock::time_point t0 = Clock::now(); + for (int r = 0; r < reps; ++r) { + if (!strata::core::session_replay(g, 0, 0, ss, gr, main_cs, err)) { + std::fprintf(stderr, "strata generate: session_replay: %s\n", err.c_str()); + return 1; + } + } + if (cudaDeviceSynchronize() != cudaSuccess) { + std::fprintf(stderr, "strata generate: session_replay faulted: %s\n", cudaGetErrorString(cudaGetLastError())); + return 1; + } + const double ms = std::chrono::duration(Clock::now() - t0).count() / (double) reps; + std::printf("pre graphs only %8.2f ms per token over %lld layers -> %.2f tok/s of GPU work\n", ms, + (long long) g.n_layers, ms > 0 ? 1000.0 / ms : 0.0); + std::printf(" %8.3f ms per layer\n", ms / (double) g.n_layers); + return 0; + } + + // ---- R0.3: THE TRUE PER-TOKEN GPU FLOOR. + // + // `--graph-only` above launches ONLY `gr.execs[l]`, the `pre` graphs. It omits the 48 `post` graphs - the + // shared expert, the combine and the second `gr_write` - and the LM head. Everything this project published + // as "39.8 ms pure GPU" came from that loop while being described as the whole GPU, which also made the + // "host's share = 49.4 - 39.8 = 9.6 ms" figure wrong by however much the missing work costs. See + // Memory/ERRORS.md A4/A5. + // + // This flag is what "pure GPU" has to mean, and it replaces that number everywhere. No pool runs, so + // `parts` keeps whatever the buffer holds and the timing is GPU work alone. + if (o.gpu_only_full) { + strata::core::doorbell_reset(db); + const int reps = 20; + double ms_layers = 0, ms_head = 0; + for (int r = -1; r < reps; ++r) { // r == -1 is the warm pass, not counted + const Clock::time_point t0 = Clock::now(); + if (!strata::core::session_replay_full(g, 0, 0, ss, gr, main_cs, err)) { + std::fprintf(stderr, "strata generate: session_replay_full: %s\n", err.c_str()); + return 1; + } + // The sync is INSIDE the interval on purpose: it is the wait for the GPU, so t1 - t0 is GPU time. + if (cudaStreamSynchronize((cudaStream_t) main_cs) != cudaSuccess) { + std::fprintf(stderr, "strata generate: gpu-only-full layers faulted: %s\n", + cudaGetErrorString(cudaGetLastError())); + return 1; + } + const Clock::time_point t1 = Clock::now(); + if (!run_head(main_cs)) { + std::fprintf(stderr, "strata generate: gpu-only-full lm_head: %s\n", err.c_str()); + return 1; + } + if (cudaStreamSynchronize((cudaStream_t) main_cs) != cudaSuccess) { + std::fprintf(stderr, "strata generate: gpu-only-full head faulted: %s\n", + cudaGetErrorString(cudaGetLastError())); + return 1; + } + const Clock::time_point t2 = Clock::now(); + if (r < 0) continue; + ms_layers += std::chrono::duration(t1 - t0).count(); + ms_head += std::chrono::duration(t2 - t1).count(); + } + ms_layers /= (double) reps; + ms_head /= (double) reps; + const double ms = ms_layers + ms_head; + std::printf("GPU floor pre+post+head %7.2f ms per token -> %.2f tok/s of GPU work\n", ms, + ms > 0 ? 1000.0 / ms : 0.0); + std::printf(" %8.3f ms layers (%lld x pre+post)\n", ms_layers, (long long) g.n_layers); + std::printf(" %8.3f ms per layer\n", ms_layers / (double) g.n_layers); + std::printf(" %8.3f ms LM head\n", ms_head); + return 0; + } + + // ---- **THE TOKEN PATH ALLOCATES NOTHING (P2.T10, review finding H3).** + // + // `session_loop` used to allocate its pinned staging buffer, its probe event and its host pin ON EVERY + // TOKEN, and `cudaFreeHost` at the end of each call implicitly synchronises the device - so every token + // finished with a device-wide sync nobody asked for. The scratch is created once here and reused; it also + // owns the host pin for the whole session rather than taking and releasing it per token. + strata::core::SessionLoopScratch loop_scratch; + struct ScratchFree { + strata::core::SessionLoopScratch* p; + ~ScratchFree() { if (p != nullptr) p->free(); } + } scratch_free{&loop_scratch}; + // Initialised unconditionally, including under --no-pool: the loop validates the scratch it is handed, so + // passing a default-constructed one is an error rather than a fallback. (It was, and the guard caught it - + // which is the point of the guard.) One allocation at setup either way. + if (!loop_scratch.init((size_t) K * g.n_embd * 4, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + // Plan v0.3 P3: the whole token as ONE graph whenever nothing needs a host step between the ring and post[l] + // (the VRAM expert tier and the per-layer dumps do). `--no-token-graph` keeps two graphs per layer. + strata::core::TokenGraph tgraph; + struct TokenGraphFree { + strata::core::TokenGraph* p; + ~TokenGraphFree() { strata::core::token_graph_free(*p); } + } tgraph_free{&tgraph}; + // Plan v0.3 P4: with a PROFILE-filled cache the residency is static, so the hit decision moves onto the + // device and the token graph keeps it. (A cache filled on demand still needs the per-layer host path.) + std::vector host_res; + int32_t* d_res = nullptr; + int32_t* d_hit_count = nullptr; + strata::core::TokenHits thits; + const bool graph_hits = hit_fn != nullptr && !profile.empty() && !o.no_pool; + if (graph_hits && !o.no_capture && !o.no_token_graph && layer_dump == nullptr && half_dump == nullptr) { + host_res.assign((size_t) (g.n_layers * g.n_expert), strata::core::kNotResident); + int64_t resident = 0; + for (int64_t l = 0; l < g.n_layers; ++l) + for (int64_t e = 0; e < g.n_expert; ++e) { + const int32_t slot = xcache.slot_of(l, e); + host_res[(size_t) (l * g.n_expert + e)] = slot; + if (slot != strata::core::kNotResident) ++resident; + } + if (cudaMalloc((void**) &d_res, host_res.size() * sizeof(int32_t)) != cudaSuccess || + cudaMalloc((void**) &d_hit_count, sizeof(int32_t)) != cudaSuccess || + cudaMemcpy(d_res, host_res.data(), host_res.size() * sizeof(int32_t), cudaMemcpyHostToDevice) != cudaSuccess) { + std::fprintf(stderr, "strata generate: the device residency table could not be staged\n"); + return 1; + } + thits.d_res = d_res; + thits.n_expert = g.n_expert; + thits.cache_base = drive.d.cache_base; + thits.blob = drive.d.cache_blob; + thits.d_slot = drive.d.d_slot; + thits.d_dst = drive.d.d_dst; + thits.d_count = d_hit_count; + thits.x_q8 = drive.d.x_q8_0_hit; + thits.x_scale = drive.d.x_q8_0_hit_scale; + thits.scratch = drive.d.hit_scratch; + thits.hit_out = drive.d.hit_out; + drive.d.host_res = host_res.data(); + std::fprintf(stderr, "strata generate: token graph hit path: %lld resident experts, decided on the device\n", + (long long) resident); + } + if (!o.no_capture && !o.no_token_graph && layer_dump == nullptr && half_dump == nullptr && + (hit_fn == nullptr || thits.on()) && !native_pack) { + if (!strata::core::session_capture_token(wt, g, ss, d_parts, loop_scratch.y_miss, loop_scratch.parts_bytes, + tgraph, err, thits.on() ? &thits : nullptr)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + std::fprintf(stderr, "strata generate: token graph captured (48 layers, one launch per token)\n"); + } + + // ================================ WHERE THE HOST TERM GOES, PER TOKEN ================================ + // + // **`--gpu-only-full` MEASURES THE 48 LAYER GRAPHS AND THE LM HEAD AND NOTHING ELSE.** It never enters + // this loop, so it does not run `ple_stage_token`, `embed_row`, the whole-vocabulary logits readback, the + // NaN scan or the sampler - and `--no-pool --stats` against that floor was being read as "the per-layer + // round trip costs 12.4 ms" when an unknown part of it is per TOKEN, not per layer. That is the same error + // the review catalogued as A4/A5, one level down: a difference between two measurements attributed to a + // mechanism that neither of them isolates. + // + // Six accumulators, because the six have different fixes. Reported in `--stats` as ms/token. **The + // boundary after the layer loop is the one that matters**: without it the head's interval swallows all 48 + // layers and the report reads as "head = 50 ms", which is not a thing that can happen to 1.4 ms of GPU + // work. That is not hypothetical - it is what the first version of this printed. + double ms_ple = 0, ms_embed = 0, ms_layers = 0, ms_head = 0, ms_readback = 0, ms_sample = 0; + int64_t phase_tokens = 0; + + // ================================ plan v0.3 P8: THE PERSISTENT ENGINE (--serve) ================================ + // + // The weights, the expert arena and the VRAM tier load once; then requests arrive on stdin, one per line, + // + // GEN + // + // and each generated token is written to stdout as `T ` as soon as its verify window is done, followed by + // + // DONE + // + // (`ERR ` instead when a request cannot run; `QUIT` ends the process). Every request starts from an + // empty sequence (`session_zero`): the prompt goes through the batched prompt path and its last token through + // the first verify window - the path all three model files share. Decoding is greedy. + if (o.serve) { + if (o.spec < 2 || o.mtp.empty() || o.prefill_chunk <= 0 || thits.d_res == nullptr || host_res.empty()) { + std::fprintf(stderr, "strata serve: needs --spec T, --mtp DIR, --prefill CHUNK, --expert-profile P and " + "--expert-cache\n"); + return 2; + } + strata::prefill::Prefill sp; + void* borrow = nullptr; + uint64_t borrow_bytes = 0; + int32_t lend_first = -1; + if (!o.no_prefill_borrow && d_res != nullptr) { + const uint64_t need = strata::prefill::Prefill::bytes_needed(g, ss, o.prefill_chunk); + const int64_t blob = (int64_t) strata::kernels::cpu::expert_layout().max_blob; + int64_t k = (int64_t) ((need + (uint64_t) blob - 1) / (uint64_t) blob); + if (xcache.slot_offsets() != nullptr) { + k = 0; + while (k < xcache.slots() && + (uint64_t) (xcache.bytes() - (int64_t) xcache.slot_offsets()[xcache.slots() - k]) < need) ++k; + } + if (k + 128 <= xcache.slots()) { + lend_first = (int32_t) (xcache.slots() - k); + borrow = xcache.device_slot(lend_first); + borrow_bytes = xcache.slot_offsets() ? (uint64_t) (xcache.bytes() - (int64_t) xcache.slot_offsets()[lend_first]) + : (uint64_t) k * (uint64_t) blob; + } + } + if (!sp.init(wt, g, ss, srcp, &xcache, host_res.data(), o.prefill_chunk, main_cs, err, borrow, borrow_bytes)) { + std::fprintf(stderr, "strata serve: %s\n", err.c_str()); + return 1; + } + strata::core::Verifier ver; + strata::core::VerifyHits vh; + vh.d_res = thits.d_res; + vh.cache_base = thits.cache_base; + vh.blob = thits.blob; + if (!ver.init(wt, g, ss, vh, native_head.loaded() ? &native_head : nullptr, o.spec, err) || + !mtp.bind(wt, &native_head, ver.final_R_all(), err)) { + std::fprintf(stderr, "strata serve: %s\n", err.c_str()); + return 1; + } + ver.set_split(o.spec_split); + ver.set_pcie_mode(o.pcie_mode == "dma" ? 0 : o.pcie_mode == "direct" ? 1 : o.pcie_mode == "kernel" ? 2 + : native_pack ? 0 : 2); // auto: DMA for the native packs, the copy kernel for Q2_0 + std::vector cur; + sp.on_chunk = [&](const float* R_rows, int64_t T, int64_t p0, std::string& e) -> bool { + std::vector nxt((size_t) T); + for (int64_t t = 0; t < T; ++t) nxt[(size_t) t] = (int32_t) cur[(size_t) (p0 + t + 1)]; + return mtp.prefill(R_rows, nxt.data(), T, p0, e); + }; + drive.d.plan = ver.plan_sink(); + drive.d.pcie_num = std::max(0, std::min(256, (int) (o.pcie_frac * 256.0 + 0.5))); + if (o.adapt_every > 0 && o.adapt_swaps > 0) drive.d.usage.assign((size_t) (g.n_layers * g.n_expert), 0.0f); + cudaStream_t adapt_stream = nullptr; + if (cudaStreamCreateWithFlags(&adapt_stream, cudaStreamNonBlocking) != cudaSuccess) { + std::fprintf(stderr, "strata serve: cannot create the refill stream\n"); + return 1; + } + // plan v0.3 P6: swaps in flight - (residency index, slot) admitted when adapt_ev has completed + std::vector> pending; + cudaEvent_t adapt_ev = nullptr; + cudaEventCreateWithFlags(&adapt_ev, cudaEventDisableTiming); + auto apply_pending = [&](bool wait) { + if (pending.empty()) return; + if (wait) cudaEventSynchronize(adapt_ev); + else if (cudaEventQuery(adapt_ev) != cudaSuccess) return; + for (const auto& [i, slot] : pending) host_res[(size_t) i] = slot; + pending.clear(); + if (d_res != nullptr) + cudaMemcpy(d_res, host_res.data(), host_res.size() * sizeof(int32_t), cudaMemcpyHostToDevice); + }; + // the VRAM tier follows the conversation (the same rule as the speculative loop below) + auto adapt = [&]() -> bool { + if (!pending.empty()) return true; // the previous swaps are still in flight + struct Swap { float gain; int32_t layer, in, out; }; + std::vector swaps; + std::vector> cand, vict; + for (int64_t l = 0; l < g.n_layers; ++l) { + cand.clear(); + vict.clear(); + const float* u = drive.d.usage.data() + l * g.n_expert; + const int32_t* r = host_res.data() + l * g.n_expert; + for (int32_t e = 0; e < (int32_t) g.n_expert; ++e) { + if (r[e] < 0) { if (u[e] >= 2.0f) cand.emplace_back(u[e], e); } + else vict.emplace_back(u[e], e); + } + if (cand.empty() || vict.empty()) continue; + std::sort(cand.begin(), cand.end(), [](auto& a, auto& b) { return a.first > b.first; }); + const size_t nc = std::min(cand.size(), vict.size()); + std::partial_sort(vict.begin(), vict.begin() + (ptrdiff_t) nc, vict.end(), + [](auto& a, auto& b) { return a.first < b.first; }); + for (size_t i = 0; i < nc; ++i) { + if (cand[i].first < vict[i].first + 1.5f) break; + swaps.push_back({cand[i].first - vict[i].first, (int32_t) l, cand[i].second, vict[i].second}); + } + } + std::sort(swaps.begin(), swaps.end(), [](const Swap& a, const Swap& b) { return a.gain > b.gain; }); + if ((int) swaps.size() > o.adapt_swaps) swaps.resize((size_t) o.adapt_swaps); + for (const Swap& s : swaps) { + const size_t in = (size_t) s.layer * g.n_expert + s.in, out = (size_t) s.layer * g.n_expert + s.out; + const int32_t slot = host_res[out]; + const uint8_t* b = srcp->blob(s.layer, s.in); + if (slot < 0 || b == nullptr || + cudaMemcpyAsync(xcache.device_slot(slot), b, (size_t) strata::kernels::cpu::expert_layout().blob_bytes(s.layer), + cudaMemcpyHostToDevice, adapt_stream) != cudaSuccess) + return false; + host_res[out] = strata::core::kNotResident; // evicted now: the CPU computes it meanwhile + pending.emplace_back((int32_t) in, slot); // resident once the copy has landed + } + if (!swaps.empty()) cudaEventRecord(adapt_ev, adapt_stream); + for (float& v : drive.d.usage) v *= 0.7f; + return true; + }; + std::printf("READY %lld\n", (long long) o.max_context); + std::fflush(stdout); + std::string line; + int64_t rounds = 0; + const int S = o.spec; + // The vision path (--vision): GENI carries images. The file is one + // or more strata-vision records (int32 'SVE1', n, nx, ny, n_embd, then n x n_embd floats) in prompt order; + // each image's rows go to its run of <|image_pad|> tokens, whose M-RoPE positions are mtmd's: t = p, + // h = p + y, w = p + x, and the text after the image continues at p + max(nx, ny). + constexpr int64_t kImagePad = 248056; // qwen4exp.ple.image_token_id: the PLE hash reads it for image cells + bool mrope_identity = true; + std::vector img_rows; + std::vector row_ptr; + while (std::getline(std::cin, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line == "QUIT") break; + const bool geni = line.rfind("GENI ", 0) == 0; + if (!geni && line.rfind("GEN ", 0) != 0) { + std::printf("ERR expected: GEN or GENI \n"); + continue; + } + char* endp = nullptr; + const long long max_new = std::strtoll(line.c_str() + (geni ? 5 : 4), &endp, 10); + std::string emb_path; + if (geni && endp != nullptr) { + while (*endp == ' ') ++endp; + char* gap = std::strchr(endp, ' '); + if (gap != nullptr) { emb_path.assign(endp, (size_t) (gap - endp)); endp = gap; } + } + std::vector ids; + std::string pe; + if (max_new < 1 || endp == nullptr || (geni && emb_path.empty()) || !parse_i64_list(endp, ids, pe)) { + std::printf("ERR bad request: %s\n", pe.empty() ? "max_new" : pe.c_str()); + continue; + } + const int64_t n = (int64_t) ids.size(); + if (geni && !o.vision) { std::printf("ERR this engine was started without --vision\n"); continue; } + if (geni || !mrope_identity) { + // positions for every cell this request can reach; the identity again for a text request + std::string ve; + row_ptr.assign((size_t) n, nullptr); + const int64_t cells = (int64_t) mrope_host.size() / 3; + auto put = [&](int64_t c, int64_t t, int64_t h, int64_t w) { + mrope_host[(size_t) c * 3] = (int32_t) t; + mrope_host[(size_t) c * 3 + 1] = (int32_t) h; + mrope_host[(size_t) c * 3 + 2] = (int32_t) w; + }; + if (!geni) { + for (int64_t c = 0; c < cells; ++c) put(c, c, c, c); + } else { + struct Img { int64_t n, nx, ny; size_t off; }; + std::vector imgs; + img_rows.clear(); + std::FILE* f = std::fopen(emb_path.c_str(), "rb"); + if (!f) ve = "cannot open " + emb_path; + while (f && ve.empty()) { + int32_t hdr[5]; + const size_t got = std::fread(hdr, sizeof(int32_t), 5, f); + if (got == 0) break; + if (got != 5 || hdr[0] != 0x31455653 || hdr[1] < 1 || hdr[2] < 1 || hdr[3] < 1 || + (int64_t) hdr[2] * hdr[3] != hdr[1] || hdr[4] != (int32_t) g.n_embd) { + ve = "bad embeddings file (expected strata-vision records of width " + + std::to_string((long long) g.n_embd) + ")"; + break; + } + const size_t off = img_rows.size(), cnt = (size_t) hdr[1] * (size_t) hdr[4]; + img_rows.resize(off + cnt); + if (std::fread(img_rows.data() + off, sizeof(float), cnt, f) != cnt) { ve = "short embeddings file"; break; } + imgs.push_back({hdr[1], hdr[2], hdr[3], off}); + } + if (f) std::fclose(f); + int64_t p = 0, i = 0; + size_t k = 0; + while (ve.empty() && i < n) { + if (ids[(size_t) i] != kImagePad) { put(i, p, p, p); ++p; ++i; continue; } + if (k >= imgs.size()) { ve = "the prompt has more images than the embeddings file"; break; } + const Img& im = imgs[k++]; + for (int64_t j = 0; j < im.n && ve.empty(); ++j) + if (i + j >= n || ids[(size_t) (i + j)] != kImagePad) + ve = "image " + std::to_string(k) + " has " + std::to_string((long long) im.n) + + " rows but fewer <|image_pad|> tokens"; + for (int64_t j = 0; j < im.n && ve.empty(); ++j) { + const int64_t y = j / im.nx, x = j % im.nx; + put(i + j, p, p + y, p + x); + row_ptr[(size_t) (i + j)] = img_rows.data() + im.off + (size_t) j * (size_t) g.n_embd; + } + i += im.n; + p += std::max(im.nx, im.ny); + } + if (ve.empty() && k != imgs.size()) ve = "the embeddings file has more images than the prompt"; + if (ve.empty() && n > 0 && ids[(size_t) (n - 1)] == kImagePad) ve = "the prompt cannot end in an image"; + for (int64_t c = n; ve.empty() && c < cells; ++c) put(c, p + (c - n), p + (c - n), p + (c - n)); + } + cudaDeviceSynchronize(); + if (ve.empty() && cudaMemcpy(d_mrope, mrope_host.data(), mrope_host.size() * sizeof(int32_t), + cudaMemcpyHostToDevice) != cudaSuccess) + ve = "the image position upload failed"; + if (!ve.empty()) { + // leave the table as the identity so the next text request is untouched + for (int64_t c = 0; c < cells; ++c) put(c, c, c, c); + cudaMemcpy(d_mrope, mrope_host.data(), mrope_host.size() * sizeof(int32_t), cudaMemcpyHostToDevice); + mrope_identity = true; + std::printf("ERR %s\n", ve.c_str()); + std::fflush(stdout); + continue; + } + mrope_identity = !geni; + } + sp.embd_rows = geni ? row_ptr.data() : nullptr; + if (n + max_new + 8 > o.max_context) { + std::printf("ERR prompt (%lld tokens) + max_new (%lld) exceeds the context (%lld)\n", (long long) n, + (long long) max_new, (long long) o.max_context); + continue; + } + bool bad = false; + for (int64_t t : ids) bad = bad || t < 0 || t >= n_vocab; + if (bad) { std::printf("ERR a token id is outside the vocabulary\n"); continue; } + cur = ids; + const Clock::time_point r0 = Clock::now(); + strata::core::session_zero(ss, g, nullptr, main_cs); + cudaStreamSynchronize(main_stream); + mtp.set_prompt_len(n); + std::vector> lent_now; + apply_pending(true); + if (lend_first >= 0) { + for (size_t i = 0; i < host_res.size(); ++i) + if (host_res[i] >= lend_first) { + lent_now.emplace_back((int32_t) i, host_res[i]); + host_res[i] = strata::core::kNotResident; + } + cudaMemcpy(d_res, host_res.data(), host_res.size() * sizeof(int32_t), cudaMemcpyHostToDevice); + } + if (n > 1 && !sp.run(ids.data(), n - 1, 0, err)) { + std::fprintf(stderr, "strata serve: %s\n", err.c_str()); + std::printf("ERR %s\n", err.c_str()); + return 1; + } + for (const auto& [i, slot] : lent_now) { + const uint8_t* b = srcp->blob(i / g.n_expert, i % g.n_expert); + if (b == nullptr || !xcache.fill_slot_blocking(slot, b, err, + (int64_t) strata::kernels::cpu::expert_layout().blob_bytes(i / g.n_expert))) { + std::printf("ERR refilling a lent slot failed: %s\n", err.c_str()); + return 1; + } + host_res[(size_t) i] = slot; + } + if (!lent_now.empty()) + cudaMemcpy(d_res, host_res.data(), host_res.size() * sizeof(int32_t), cudaMemcpyHostToDevice); + const double prompt_ms = std::chrono::duration(Clock::now() - r0).count(); + // the verify windows: the first holds the last prompt token alone + int64_t p = n - 1; + int32_t x = (int32_t) ids[(size_t) (n - 1)]; + std::vector drafts((size_t) S, 0), window((size_t) S), outv((size_t) S); + std::vector dprob((size_t) S, 0.0f); + bool first_window = true; + int64_t produced_n = 0; + const char* finish = "length"; + const Clock::time_point d0 = Clock::now(); + while (produced_n < max_new) { + int T = S; + if (o.spec_min_p > 0.0) { + T = 1; + while (T < S && dprob[(size_t) T - 1] >= (float) o.spec_min_p) ++T; + } + if (first_window) T = 1; + if (p + T > o.max_context) break; + window[0] = x; + for (int i = 1; i < T; ++i) window[(size_t) i] = drafts[(size_t) i - 1]; + drive.d.layers = 0; + drive.d.experts = 0; + drive.d.failed = false; + apply_pending(false); + if (!ver.run(T, window.data(), p, &drive_pool_multi, &drive, outv.data(), err) || drive.d.failed) { + std::printf("ERR %s\n", drive.d.failed && drive.d.fail ? drive.d.fail : err.c_str()); + return 1; + } + int a = 0; + while (a < T - 1 && window[(size_t) a + 1] == outv[(size_t) a]) ++a; + std::thread adapt_thr; // the adaptive tier beside the commit and the draft (as in generate) + bool adapt_ok = true; + if (!drive.d.usage.empty() && ((rounds + 1) % o.adapt_every) == 0) + adapt_thr = std::thread([&] { adapt_ok = adapt(); }); + if (!ver.commit(a + 1, err)) { + if (adapt_thr.joinable()) adapt_thr.join(); + std::printf("ERR %s\n", err.c_str()); + return 1; + } + first_window = false; + bool eos = false; + for (int i = 0; i <= a && produced_n < max_new && !eos; ++i) { + std::printf("T %d\n", (int) outv[(size_t) i]); + ++produced_n; + eos = std::find(o.eos_ids.begin(), o.eos_ids.end(), (int64_t) outv[(size_t) i]) != o.eos_ids.end(); + } + std::fflush(stdout); + ++rounds; + const bool drafted = eos || produced_n >= max_new || + mtp.draft(T, outv.data(), p, a, drafts.data(), err, dprob.data(), (float) o.spec_min_p); + if (adapt_thr.joinable()) adapt_thr.join(); + if (!adapt_ok) { + std::printf("ERR an adaptive refill failed\n"); + return 1; + } + if (!drafted) { + std::printf("ERR %s\n", err.c_str()); + return 1; + } + if (eos) { finish = "stop"; break; } + x = outv[(size_t) a]; + p += a + 1; + } + const double decode_ms = std::chrono::duration(Clock::now() - d0).count(); + std::printf("DONE %lld %lld %.1f %.1f %s\n", (long long) produced_n, (long long) n, prompt_ms, decode_ms, + finish); + std::fflush(stdout); + std::fprintf(stderr, "strata serve: %lld prompt tokens in %.0f ms (%.1f tok/s), %lld generated in %.0f ms " + "(%.1f tok/s)\n", (long long) n, prompt_ms, prompt_ms > 0 ? 1000.0 * n / prompt_ms : 0.0, + (long long) produced_n, decode_ms, decode_ms > 0 ? 1000.0 * produced_n / decode_ms : 0.0); + } + return 0; + } + + // ---- plan v0.3 P5: the prompt's conditioning positions [0, n_prompt - 1) in batched chunks. The token loop + // then starts at the last prompt position, whose prediction is the first generated token. + int64_t pos_start = 0; + int64_t spec_pos = 0; // plan v0.3 P6: where the speculative loop starts (0 = not used) + strata::prefill::Prefill prefill; + double prefill_batched_ms = 0; + std::FILE* final_r = o.dump_final_r.empty() ? nullptr : std::fopen(o.dump_final_r.c_str(), "wb"); + std::vector final_r_host(final_r ? (size_t) (g.hc * g.n_embd) : 0); + std::vector> lent; // (residency index, slot) lent to the prompt path + if (o.prefill_chunk > 0 && n_prompt > 1) { + void* borrow = nullptr; + uint64_t borrow_bytes = 0; + if (!o.no_prefill_borrow && !host_res.empty() && d_res != nullptr) { + const uint64_t need = strata::prefill::Prefill::bytes_needed(g, ss, o.prefill_chunk); + const int64_t blob = (int64_t) strata::kernels::cpu::expert_layout().max_blob; + int64_t k = (int64_t) ((need + (uint64_t) blob - 1) / (uint64_t) blob); + if (xcache.slot_offsets() != nullptr) { // sized slots: take slots from the end until they hold `need` + k = 0; + while (k < xcache.slots() && + (uint64_t) (xcache.bytes() - (int64_t) xcache.slot_offsets()[xcache.slots() - k]) < need) ++k; + } + if (k + 128 <= xcache.slots()) { // the lent slots are refilled after the prompt + const int32_t first = (int32_t) (xcache.slots() - k); + for (size_t i = 0; i < host_res.size(); ++i) + if (host_res[i] >= first) { + lent.emplace_back((int32_t) i, host_res[i]); + host_res[i] = strata::core::kNotResident; + } + cudaMemcpy(d_res, host_res.data(), host_res.size() * sizeof(int32_t), cudaMemcpyHostToDevice); + borrow = xcache.device_slot(first); + borrow_bytes = xcache.slot_offsets() ? (uint64_t) (xcache.bytes() - (int64_t) xcache.slot_offsets()[first]) + : (uint64_t) k * (uint64_t) blob; + std::fprintf(stderr, "strata generate: prompt path borrows %lld cache slots (%.2f GiB)\n", (long long) k, + (double) borrow_bytes / 1073741824.0); + } + } + if (borrow == nullptr) + std::fprintf(stderr, "strata generate: prompt path allocates its own buffers (no cache slots to borrow)\n"); + if (!prefill.init(wt, g, ss, srcp, o.expert_cache > 0 ? &xcache : nullptr, + host_res.empty() ? nullptr : host_res.data(), o.prefill_chunk, main_cs, err, borrow, + borrow_bytes)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + if (!o.mtp.empty()) { + if (!mtp.bind(wt, &native_head, nullptr, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + prefill.on_chunk = [&](const float* R_rows, int64_t T, int64_t p0, std::string& e) -> bool { + // cell i pairs R_i with the token at i + 1 (every such token is in the prompt) + std::vector nxt((size_t) T); + for (int64_t t = 0; t < T; ++t) nxt[(size_t) t] = (int32_t) o.tokens[(size_t) (p0 + t + 1)]; + return mtp.prefill(R_rows, nxt.data(), T, p0, e); + }; + } + const Clock::time_point tp0 = Clock::now(); + const int64_t n_batched = (o.prefill_until > 0 && o.prefill_until < n_prompt - 1) ? o.prefill_until : n_prompt - 1; + if (!prefill.run(o.tokens.data(), n_batched, 0, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + // refill the lent slots from the arena and give them back to the decode tier + if (!lent.empty()) { + const Clock::time_point tr = Clock::now(); + for (const auto& [i, slot] : lent) { + const uint8_t* b = srcp->blob(i / g.n_expert, i % g.n_expert); + if (b == nullptr || !xcache.fill_slot_blocking(slot, b, err, + (int64_t) strata::kernels::cpu::expert_layout().blob_bytes(i / g.n_expert))) { + std::fprintf(stderr, "strata generate: refilling a lent slot failed: %s\n", err.c_str()); + return 1; + } + host_res[(size_t) i] = slot; + } + cudaMemcpy(d_res, host_res.data(), host_res.size() * sizeof(int32_t), cudaMemcpyHostToDevice); + std::fprintf(stderr, "strata generate: %zu lent slots refilled in %.1f ms\n", lent.size(), + std::chrono::duration(Clock::now() - tr).count()); + } + prefill_batched_ms = std::chrono::duration(Clock::now() - tp0).count(); + prefill_ms += prefill_batched_ms; + pos_start = n_batched; + tok = o.tokens[(size_t) pos_start]; + // the PLE window of the token path: the two tokens before `pos_start` + ss.ple_prev[0] = pos_start >= 2 ? (int32_t) o.tokens[(size_t) (pos_start - 2)] : -1; + ss.ple_prev[1] = pos_start >= 1 ? (int32_t) o.tokens[(size_t) (pos_start - 1)] : -1; + const strata::prefill::PrefillStats& ps = prefill.stats(); + std::fprintf(stderr, "strata generate: prefill %lld tokens in %lld chunks, %.1f ms (%.1f tok/s); experts " + "streamed %lld (%lld by DMA, host %.1f ms), resident %lld; PLE %.1f ms\n", + (long long) ps.tokens, (long long) ps.chunks, ps.ms_total, + ps.ms_total > 0 ? 1000.0 * (double) ps.tokens / ps.ms_total : 0.0, (long long) ps.experts_streamed, + (long long) ps.experts_dma, ps.ms_experts_host, (long long) ps.experts_resident, ps.ms_ple); + } + + for (int64_t pos = pos_start;; ++pos) { + // plan v0.3 P6: a native pack's last prompt token is the first verify window (T = 1) + if (native_pack) { spec_pos = pos; break; } + if (pos >= o.max_context) { + std::fprintf(stderr, "strata generate: ran out of context at position %lld\n", (long long) pos); + return 2; + } + // **THE TOKEN TIMER STARTS HERE, BEFORE ANY OF THE TOKEN'S WORK (A7).** It used to start after + // `put_input`/`embed_row`, which excluded the embedding and the PLE window advance from the reported + // rate, and it stopped before the NaN scan, the logits dump and the sampler. The published tok/s + // figure is a WALL-CLOCK rate: everything one token costs, PLE advance to sampled id. A rate that + // excludes real per-token work is not a rate anyone can plan against. + const Clock::time_point t0 = Clock::now(); + // **THE PLE'S TOKEN WINDOW ADVANCES HERE, ONCE PER TOKEN, AND `ple_stage_token` RUNS OUTSIDE THE + // CAPTURE.** Both are the driver's job: `ngram_rows` is a host hash over the last three tokens and the + // table gather is a host read, so either one inside a captured graph would run once at capture time and + // replay forever. `ple_prev` is OLDEST FIRST and `-1` means "no predecessor", which `ngram_rows` + // treats as the EOS cut - a sequence boundary. + ss.ple_token = (int32_t) tok; + Clock::time_point tp = Clock::now(); + // Plan v0.3 P2: the 16 SSD reads start here and complete while the embedding is staged; `ms_ple` is + // the issue plus the time still spent WAITING afterwards, i.e. the part the embedding did not hide. + if (ss.ple.ready() && !strata::core::ple_issue_token(ss.ple, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + { + const Clock::time_point n = Clock::now(); + ms_ple += std::chrono::duration(n - tp).count(); + tp = n; + } + if (!put_input(tok, pos)) return 1; + { + const Clock::time_point n = Clock::now(); + ms_embed += std::chrono::duration(n - tp).count(); + tp = n; + } + if (ss.ple.ready() && !strata::core::ple_finish_token(ss.ple, token_stream, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + { + const Clock::time_point n = Clock::now(); + ms_ple += std::chrono::duration(n - tp).count(); + tp = n; + } + + if (pos % 256 == 0 || pos + 1 >= n_prompt - 1) + std::fprintf(stderr, "strata generate: position %lld, token %lld%s\n", (long long) pos, (long long) tok, + pos < n_prompt ? " (prompt)" : ""); + // **`d.layers` IS THE BLOB'S LAYER AXIS, NOT A COUNTER.** The adapter uses it to index + // `experts.bin` as `layer * n_expert + expert`, so it MUST restart at 0 for every token. Leaving it + // running across tokens asks for layer 48 of a 48-layer file on the second token - which + // `FileExpertSource` REFUSES rather than wrapping into layer 0's experts, and that refusal is the only + // reason this was a clean error instead of a silently wrong second token. + drive.d.layers = 0; + drive.d.experts = 0; + drive.d.failed = false; + err.clear(); + if (o.no_capture) { + if (!strata::core::session_token(wt, g, pos, /*pos_base=*/0, ss, d_parts, main_cs, + o.sync_every_layer, err)) { + std::fprintf(stderr, "strata generate: session_token: %s\n", err.c_str()); + return 1; + } + } else { + strata::core::doorbell_reset(db); + if (tgraph.captured) { + if (!strata::core::session_run_token(g, pos, /*pos_base=*/0, ss, tgraph, pool_fn, pool_user, + loop_scratch.y_miss, main_cs, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + } else if (!strata::core::session_loop(g, pos, /*pos_base=*/0, ss, gr, pool_fn, hit_fn, pool_user, /*overlap=*/true, main_cs, + err, layer_stage, &loop_scratch)) { + std::fprintf(stderr, "strata generate: session_loop: %s\n", err.c_str()); + return 1; + } + } + if (final_r != nullptr) { + cudaMemcpy(final_r_host.data(), ss.R, final_r_host.size() * sizeof(float), cudaMemcpyDeviceToHost); + const int64_t posrec[2] = {pos, tok}; + std::fwrite(posrec, sizeof posrec, 1, final_r); + std::fwrite(final_r_host.data(), sizeof(float), final_r_host.size(), final_r); + } + if (drive.d.failed) { + std::fprintf(stderr, "strata generate: the expert pool failed at layer %lld expert %lld: %s\n", + (long long) drive.d.fail_layer, (long long) drive.d.fail_expert, + drive.d.fail ? drive.d.fail : "(no message)"); + return 1; + } + { + // **THE LAYER LOOP ITSELF, WHICH IS WHAT `--gpu-only-full` HAS TO BE COMPARED AGAINST.** + const Clock::time_point n = Clock::now(); + ms_layers += std::chrono::duration(n - tp).count(); + tp = n; + } + // ---- the ladder for THIS position, in the order `session_loop` filled it: layer 0 first. + if (layer_dump != nullptr) std::fwrite(layer_stage, sizeof(float), layer_floats, layer_dump); + if (half_dump != nullptr) { + std::fwrite(half_stage, sizeof(float), (size_t) g.n_layers * (size_t) half_stride, half_dump); + } + if (!run_head(token_stream)) { + std::fprintf(stderr, "strata generate: lm_head: %s\n", err.c_str()); + return 1; + } + // **A CHECKPOINT AFTER THE HEAD, BECAUSE AN ASYNC FAULT IS STICKY AND LIES ABOUT WHERE IT HAPPENED.** + // Measured, and it cost an hour: without this, `embed_row`'s D2H on the NEXT token reported "an illegal + // memory access" at a plane offset that has nothing to do with the fault, and the layer that actually + // faulted had completed its own error checks successfully - because its kernels had not run yet. A + // sticky error surfaces at the next SYNCHRONISING call, which is whatever happens to come next. + if (!o.stream_token && cudaDeviceSynchronize() != cudaSuccess) { + std::fprintf(stderr, "strata generate: the device faulted in lm_head at position %lld: %s\n", + (long long) pos, cudaGetErrorString(cudaGetLastError())); + return 1; + } + { + const Clock::time_point n = Clock::now(); + ms_head += std::chrono::duration(n - tp).count(); + tp = n; + } + const bool emit_logits = dump != nullptr && + strata::program::logits_selection::selected(pos, dump_positions, o.logits_stride); + const bool read_logits = !o.stream_token || o.check_logits || emit_logits; + if (read_logits && (cudaMemcpyAsync(logits.data(), d_logits, (size_t) n_vocab * 4, + cudaMemcpyDeviceToHost, (cudaStream_t) token_stream) != cudaSuccess || + cudaStreamSynchronize((cudaStream_t) token_stream) != cudaSuccess)) { + std::fprintf(stderr, "strata generate: reading the logits back failed\n"); + return 1; + } + int bad = 0; + if (read_logits) for (float v : logits) if (!std::isfinite(v)) ++bad; + if (bad != 0) { + std::fprintf(stderr, "strata generate: %d of %lld logits are not finite at position %lld\n", bad, + (long long) n_vocab, (long long) pos); + return 1; + } + if (emit_logits && std::fwrite(logits.data(), sizeof(float), (size_t) n_vocab, dump) != (size_t) n_vocab) { + std::fprintf(stderr, "strata generate: cannot write logits at position %lld\n", (long long) pos); + std::fclose(dump); + return 1; + } + { + // **993 KB OF SYNCHRONOUS D2H AND A 248,320-FLOAT HOST SCAN, EVERY TOKEN.** (The review's notes + // say 151,936 floats; the artifact's `output.weight` is 248,320 rows, so the real figure is 1.6x + // that - a number nobody had checked because nothing measured this term.) R2.6 asks for the dump + // and the scan to be behind flags; round 36 did exactly that and measured it SLOWER, because on + // this driver a large blocking readback is also what flushes the pipeline for the sampler that + // follows. Timed so the claim can be re-checked rather than remembered. + const Clock::time_point n = Clock::now(); + ms_readback += std::chrono::duration(n - tp).count(); + tp = n; + } + + int next = 0; + // Teacher-forced prompt rows consume no generation draws. + sp.counter = (uint64_t) produced.size(); + strata::kernels::sample_tokens(d_logits, 1, (int) n_vocab, nullptr, 0, sp, d_next, token_stream); + if (cudaMemcpyAsync(&next, d_next, sizeof(int), cudaMemcpyDeviceToHost, + (cudaStream_t) token_stream) != cudaSuccess || + cudaStreamSynchronize((cudaStream_t) token_stream) != cudaSuccess) { + std::fprintf(stderr, "strata generate: reading the sampled token back failed: %s\n", + cudaGetErrorString(cudaGetLastError())); + return 1; + } + // The sampled-token synchronization also completes every captured QSA + // status readback. Retain one status per layer so a later layer cannot + // hide an earlier failure; no extra synchronization or token allocation. + if (o.native_flash_attn_short) for (int64_t i = 0; i < g.n_qsa_layers(); ++i) { + const int32_t status = ss.qsa_states[i].host_step[strata::kernels::kStepCount]; + if (status != 0) { + std::fprintf(stderr, "strata generate: native attention status %d at QSA layer %lld, position %lld\n", + status, (long long) i, (long long) pos); + return 1; + } + } + if (next < 0 || next >= n_vocab) { + std::fprintf(stderr, "strata generate: the sampler returned %d, outside 0..%lld\n", next, + (long long) (n_vocab - 1)); + return 1; + } + { + // **TWO DEVICE-WIDE SYNCS FOR FOUR BYTES.** `sample_tokens(nullptr)` ends in + // `cudaDeviceSynchronize()` (`sampler.cu:245`) and the blocking 4-byte read below is the second. + const Clock::time_point n = Clock::now(); + ms_sample += std::chrono::duration(n - tp).count(); + ++phase_tokens; + } + // CHARGED HERE, AFTER THE SAMPLER, so the wall-clock rate covers the whole token including the embedding, + // the NaN scan, the logits readback and the sample (A7). Only DECODE positions count; prefill is + // measured separately. + if (pos >= n_prompt - 1) total_ms += std::chrono::duration(Clock::now() - t0).count(); + else prefill_ms += std::chrono::duration(Clock::now() - t0).count(); + if (pos == n_prompt - 1) ttft_ms = std::chrono::duration(Clock::now() - t_start).count(); + // **THE PREDICTION AT THE LAST PROMPT POSITION *IS* THE FIRST GENERATED TOKEN.** Sampling on every + // position and recording only from `n_prompt - 1` onward is what keeps the two cases from needing + // separate handling - and the version that "obviously" only samples after the prompt loses exactly one + // token's worth of conditioning. + if (pos >= n_prompt - 1) produced.push_back(next); + if ((int64_t) produced.size() >= o.max_new) break; + if (o.stop_eos && pos >= n_prompt - 1 && + std::find(o.eos_ids.begin(), o.eos_ids.end(), (int64_t) next) != o.eos_ids.end()) break; + // TEACHER FORCING while the prompt lasts: the next input is the prompt's own next token, not the + // model's guess. Feeding the guess would make the run depend on the model's own errors from position + // 1, which is a different (and worse) measurement of the same prompt. + // and the window advances: the token just decoded becomes the newest predecessor. + ss.ple_prev[0] = ss.ple_prev[1]; + ss.ple_prev[1] = (int32_t) tok; + tok = (pos + 1 < n_prompt) ? o.tokens[(size_t) (pos + 1)] : next; + // Plan v0.3 P6: from the first generated token on, the speculative loop below takes over. + if (o.spec > 0 && pos >= n_prompt - 1) { spec_pos = pos + 1; break; } + } + + // ================================ plan v0.3 P6: SPECULATIVE DECODING ================================ + // + // Each round verifies [the last emitted token, drafts...] in one window; the window's argmax after token t + // is exactly what greedy decode would emit there, so the first draft that differs ends the round and the + // round emits (accepted drafts + 1) tokens. `commit` keeps the state of the tokens that were emitted. + const bool ended = o.stop_eos && !produced.empty() && + std::find(o.eos_ids.begin(), o.eos_ids.end(), (int64_t) produced.back()) != o.eos_ids.end(); + if (spec_pos > 0 && (int64_t) produced.size() < o.max_new && !ended) { + std::vector oracle; + if (!o.spec_oracle.empty()) { + std::ifstream in(o.spec_oracle); + std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::string e; + if (!in || !parse_i64_list(text.c_str(), oracle, e)) { + std::fprintf(stderr, "strata generate: cannot read --spec-oracle %s\n", o.spec_oracle.c_str()); + return 2; + } + } + if (thits.d_res == nullptr) { + std::fprintf(stderr, "strata generate: --spec needs the device residency table (--expert-profile, " + "--expert-cache and the token graph)\n"); + return 2; + } + strata::core::Verifier ver; + strata::core::VerifyHits vh; + vh.d_res = thits.d_res; + vh.cache_base = thits.cache_base; + vh.blob = thits.blob; + if (!ver.init(wt, g, ss, vh, native_head.loaded() ? &native_head : nullptr, o.spec, err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + const bool use_mtp = !o.mtp.empty(); + if (use_mtp && !mtp.bind(wt, &native_head, ver.final_R_all(), err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + ver.set_split(o.spec_split); + ver.set_pcie_mode(o.pcie_mode == "dma" ? 0 : o.pcie_mode == "direct" ? 1 : o.pcie_mode == "kernel" ? 2 + : native_pack ? 0 : 2); // auto: DMA for the native packs, the copy kernel for Q2_0 + drive.d.plan = ver.plan_sink(); + drive.d.pcie_num = (int) (o.pcie_frac * 256.0 + 0.5); + if (drive.d.pcie_num < 0) drive.d.pcie_num = 0; + if (drive.d.pcie_num > 256) drive.d.pcie_num = 256; + const int64_t pcie0 = drive.d.pcie_experts; + if (o.adapt_every > 0 && o.adapt_swaps > 0) drive.d.usage.assign((size_t) (g.n_layers * g.n_expert), 0.0f); + int64_t swaps_total = 0; + double ms_adapt = 0; + cudaStream_t adapt_stream = nullptr; + if (!drive.d.usage.empty() && cudaStreamCreateWithFlags(&adapt_stream, cudaStreamNonBlocking) != cudaSuccess) { + std::fprintf(stderr, "strata generate: cannot create the refill stream\n"); + return 1; + } + // plan v0.3 P6: swaps in flight - (residency index, slot) admitted when adapt_ev has completed + std::vector> pending; + cudaEvent_t adapt_ev = nullptr; + cudaEventCreateWithFlags(&adapt_ev, cudaEventDisableTiming); + auto apply_pending = [&](bool wait) { + if (pending.empty()) return; + if (wait) cudaEventSynchronize(adapt_ev); + else if (cudaEventQuery(adapt_ev) != cudaSuccess) return; + for (const auto& [i, slot] : pending) host_res[(size_t) i] = slot; + pending.clear(); + if (d_res != nullptr) + cudaMemcpy(d_res, host_res.data(), host_res.size() * sizeof(int32_t), cudaMemcpyHostToDevice); + }; + // Plan v0.3 P6: the VRAM tier follows the conversation. Candidates are missing experts routed at least + // twice (decayed); each is paired with its layer's least-routed resident expert and swapped when it was + // routed clearly more often. Copies run between rounds, when the GPU is idle. + auto adapt = [&]() -> bool { + const Clock::time_point ta = Clock::now(); + if (!pending.empty()) return true; // the previous swaps are still in flight + struct Swap { float gain; int32_t layer, in, out; }; + std::vector swaps; + std::vector> cand, vict; + for (int64_t l = 0; l < g.n_layers; ++l) { + cand.clear(); + vict.clear(); + const float* u = drive.d.usage.data() + l * g.n_expert; + const int32_t* r = host_res.data() + l * g.n_expert; + for (int32_t e = 0; e < (int32_t) g.n_expert; ++e) { + if (r[e] < 0) { if (u[e] >= 2.0f) cand.emplace_back(u[e], e); } + else vict.emplace_back(u[e], e); + } + if (cand.empty() || vict.empty()) continue; + std::sort(cand.begin(), cand.end(), [](auto& a, auto& b) { return a.first > b.first; }); + const size_t nc = std::min(cand.size(), vict.size()); + std::partial_sort(vict.begin(), vict.begin() + (ptrdiff_t) nc, vict.end(), + [](auto& a, auto& b) { return a.first < b.first; }); + for (size_t i = 0; i < nc; ++i) { + if (cand[i].first < vict[i].first + 1.5f) break; + swaps.push_back({cand[i].first - vict[i].first, (int32_t) l, cand[i].second, vict[i].second}); + } + } + std::sort(swaps.begin(), swaps.end(), [](const Swap& a, const Swap& b) { return a.gain > b.gain; }); + if ((int) swaps.size() > o.adapt_swaps) swaps.resize((size_t) o.adapt_swaps); + for (const Swap& s : swaps) { + const size_t in = (size_t) s.layer * g.n_expert + s.in, out = (size_t) s.layer * g.n_expert + s.out; + const int32_t slot = host_res[out]; + const uint8_t* b = srcp->blob(s.layer, s.in); + // asynchronous: the copies run while the MTP drafts; the next window waits for them + if (slot < 0 || b == nullptr || + cudaMemcpyAsync(xcache.device_slot(slot), b, (size_t) strata::kernels::cpu::expert_layout().blob_bytes(s.layer), + cudaMemcpyHostToDevice, adapt_stream) != cudaSuccess) { + std::fprintf(stderr, "strata generate: an adaptive refill failed\n"); + return false; + } + host_res[out] = strata::core::kNotResident; // evicted now: the CPU computes it meanwhile + pending.emplace_back((int32_t) in, slot); // resident once the copy has landed + } + if (!swaps.empty()) cudaEventRecord(adapt_ev, adapt_stream); + for (float& v : drive.d.usage) v *= 0.7f; + swaps_total += (int64_t) swaps.size(); + ms_adapt += std::chrono::duration(Clock::now() - ta).count(); + return true; + }; + int64_t p = spec_pos; + int32_t x = (int32_t) tok; + std::vector drafts((size_t) o.spec, 0); + std::vector dprob((size_t) o.spec, 1.0f); + std::vector window_hist((size_t) o.spec + 1, 0); + // plan v0.3 P6: with a native pack the first window is the last prompt token alone (it produces the first + // generated token and the MTP's first cell); otherwise the token loop already did that. + bool first_window = native_pack; + if (use_mtp && !first_window && + !mtp.draft_first(o.spec, ss.R, x, p - 1, drafts.data(), err, dprob.data(), (float) o.spec_min_p)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + std::vector window((size_t) o.spec), outv((size_t) o.spec); + std::vector accepted_hist((size_t) o.spec, 0); + int64_t rounds = 0, drafts_total = 0, drafts_ok = 0, corrupt_counter = 0; + const double pool_ms0 = drive.cpu_ms; + const int64_t misses0 = drive.d.multi_misses, entries0 = drive.d.multi_entries; + while ((int64_t) produced.size() < o.max_new) { + const Clock::time_point t0 = Clock::now(); + int T = o.spec; + if (use_mtp && o.spec_min_p > 0.0) { + T = 1; + while (T < o.spec && dprob[(size_t) T - 1] >= (float) o.spec_min_p) ++T; + } + if (first_window) T = 1; + ++window_hist[(size_t) T]; + if (p + T > o.max_context) { + std::fprintf(stderr, "strata generate: ran out of context at position %lld\n", (long long) p); + return 2; + } + window[0] = x; + for (int i = 1; i < T; ++i) { + const size_t at = produced.size() - 1 + (size_t) i; + int32_t d = use_mtp ? drafts[(size_t) i - 1] : at < oracle.size() ? (int32_t) oracle[at] : 0; + if (o.spec_corrupt > 0 && (++corrupt_counter % o.spec_corrupt) == 0) d = (d + 1) % (int32_t) n_vocab; + window[(size_t) i] = d; + } + drive.d.layers = 0; + drive.d.experts = 0; + drive.d.failed = false; + apply_pending(false); + if (!ver.run(T, window.data(), p, &drive_pool_multi, &drive, outv.data(), err)) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + if (drive.d.failed) { + std::fprintf(stderr, "strata generate: the expert pool failed at layer %lld expert %lld: %s\n", + (long long) drive.d.fail_layer, (long long) drive.d.fail_expert, + drive.d.fail ? drive.d.fail : "(no message)"); + return 1; + } + int a = 0; + while (a < T - 1 && window[(size_t) a + 1] == outv[(size_t) a]) ++a; + if (first_window) { + first_window = false; + ttft_ms = std::chrono::duration(Clock::now() - t_start).count(); + } + // plan v0.3 P6: the adaptive tier's host work (ranking, copy submission) runs on its own thread while the + // GPU commits and drafts; it touches only the residency tables, which nothing reads until the next window + std::thread adapt_thr; + bool adapt_ok = true; + if (!drive.d.usage.empty() && ((rounds + 1) % o.adapt_every) == 0) + adapt_thr = std::thread([&] { adapt_ok = adapt(); }); + if (!ver.commit(a + 1, err)) { + if (adapt_thr.joinable()) adapt_thr.join(); + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + ++rounds; + drafts_total += T - 1; + drafts_ok += a; + ++accepted_hist[(size_t) a]; + bool eos = false; + for (int i = 0; i <= a && (int64_t) produced.size() < o.max_new && !eos; ++i) { + produced.push_back(outv[(size_t) i]); + eos = o.stop_eos && std::find(o.eos_ids.begin(), o.eos_ids.end(), (int64_t) outv[(size_t) i]) != o.eos_ids.end(); + } + if (eos) { + if (adapt_thr.joinable()) adapt_thr.join(); + total_ms += std::chrono::duration(Clock::now() - t0).count(); + break; + } + const bool drafted = !use_mtp || (int64_t) produced.size() >= o.max_new || + mtp.draft(T, outv.data(), p, a, drafts.data(), err, dprob.data(), (float) o.spec_min_p); + if (adapt_thr.joinable()) adapt_thr.join(); + if (!adapt_ok) return 1; + if (!drafted) { + std::fprintf(stderr, "strata generate: %s\n", err.c_str()); + return 1; + } + x = outv[(size_t) a]; + p += a + 1; + total_ms += std::chrono::duration(Clock::now() - t0).count(); + if (rounds % 64 == 0) + std::fprintf(stderr, "strata generate: position %lld, %lld tokens, %lld rounds\n", (long long) p, + (long long) produced.size(), (long long) rounds); + } + std::printf("%-24s %lld rounds of %d, drafts accepted %lld of %lld (%.3f), %.2f tokens per round\n", + "speculation", (long long) rounds, o.spec, (long long) drafts_ok, (long long) drafts_total, + drafts_total > 0 ? (double) drafts_ok / (double) drafts_total : 0.0, + rounds > 0 ? (double) (drafts_ok + rounds) / (double) rounds : 0.0); + if (o.spec_min_p > 0.0) { + std::printf("%-24s", "window sizes"); + for (size_t i = 1; i < window_hist.size(); ++i) std::printf(" T%zu:%lld", i, (long long) window_hist[i]); + std::printf(" (min draft probability %.2f)\n", o.spec_min_p); + } + std::printf("%-24s", "accepted per round"); + for (size_t i = 0; i < accepted_hist.size(); ++i) std::printf(" %zu:%lld", i, (long long) accepted_hist[i]); + std::printf("\n"); + if (rounds > 0) + std::printf("%-24s wait for rings %.3f pool %.3f host %.3f commit %.3f ms/round; CPU experts %.2f " + "distinct / %.2f routed per layer\n", + "verify window", ver.ms_wait / rounds, ver.ms_pool / rounds, ver.ms_host / rounds, + ver.ms_commit / rounds, + (double) (drive.d.multi_misses - misses0) / (double) (rounds * g.n_layers), + (double) (drive.d.multi_entries - entries0) / (double) (rounds * g.n_layers)); + if (rounds > 0) + std::printf("%-24s gate/up %.3f quantize %.3f down %.3f ms/round; %.1f GB/s over the rows phases; " + "CPU pool call %.3f ms/round\n", "pool multi", pool.ms_multi_gu / rounds, + pool.ms_multi_q / rounds, pool.ms_multi_down / rounds, + (double) pool.multi_bytes / 1e6 / std::max(1e-9, pool.ms_multi_gu + pool.ms_multi_down), + (drive.cpu_ms - pool_ms0) / rounds); + if (rounds > 0) + std::printf("%-24s plan %.3f activation quantize %.3f jobs %.3f run %.3f ms/round\n", "dispatch", + drive.d.ms_plan / rounds, drive.d.ms_actq / rounds, drive.d.ms_jobs / rounds, + drive.d.ms_run / rounds); + if (rounds > 0 && !drive.d.usage.empty()) + std::printf("%-24s %lld experts swapped into the VRAM tier (every %d rounds, %.3f ms/round)\n", "adaptive tier", + (long long) swaps_total, o.adapt_every, ms_adapt / rounds); + if (rounds > 0 && drive.d.pcie_num > 0) + std::printf("%-24s %.2f distinct experts per layer read over PCIe (share %d/256 of the misses)\n", + "pcie experts", (double) (drive.d.pcie_experts - pcie0) / (double) (rounds * g.n_layers), + drive.d.pcie_num); + (void) pool_ms0; + if (use_mtp && rounds > 0) + std::printf("%-24s %.3f ms/round drafting (%lld rounds), MTP prompt %.1f ms, %.0f MiB of VRAM\n", "mtp", + mtp.ms_draft / (double) mtp.rounds, (long long) mtp.rounds, mtp.ms_prefill, + (double) mtp.vram_bytes() / 1048576.0); + } + + if (dump != nullptr && std::fclose(dump) != 0) { + std::fprintf(stderr, "strata generate: cannot finish logits dump\n"); + return 1; + } + if (layer_dump != nullptr) { + std::fclose(layer_dump); + cudaFreeHost(layer_stage); + std::printf("%-24s %s (%lld layers + the input x %d streams x %lld per position)\n", "layers dumped", + o.dump_layers.c_str(), (long long) g.n_layers, (int) g.hc, (long long) g.n_embd); + } + if (half_dump != nullptr) { + std::fclose(half_dump); + cudaFreeHost(half_stage); + std::printf("%-24s %s (%lld layers x %llu per position)\n", "halves dumped", o.dump_halves.c_str(), + (long long) g.n_layers, (unsigned long long) half_stride); + } + if (routing != nullptr) { + std::fclose(routing); + drive.routing = nullptr; + std::printf("%-24s %s (%lld records of layer, k, ids, weights)\n", "routing dumped", + o.dump_routing.c_str(), (long long) drive.calls); + } + if (o.stage_timing) strata::core::stage_timing_report(g.n_layers); + + const int64_t decoded = (int64_t) produced.size(); + std::printf("prompt :"); + for (int64_t t : o.tokens) std::printf(" %lld", (long long) t); + std::printf("\noutput :"); + for (int64_t t : produced) std::printf(" %lld", (long long) t); + std::printf("\n"); + const double decode_ms = decoded > 0 ? total_ms / (double) decoded : 0.0; + std::printf("%-24s %lld tokens in %.1f ms -> %.2f tok/s\n", "decode", (long long) decoded, total_ms, + decode_ms > 0.0 ? 1000.0 / decode_ms : 0.0); + if (n_prompt > 1) + std::printf("%-24s %lld tokens in %.1f ms -> %.2f tok/s (time to first token %.1f ms)\n", "prefill", + (long long) (n_prompt - 1), prefill_ms, + prefill_ms > 0 ? 1000.0 * (double) (n_prompt - 1) / prefill_ms : 0.0, ttft_ms); + if (!o.dump_mixed.empty()) { + std::vector mx((size_t) g.n_embd); + if (cudaMemcpy(mx.data(), ss.block.mixed, mx.size() * sizeof(float), cudaMemcpyDeviceToHost) != + cudaSuccess) { + std::fprintf(stderr, "strata generate: reading mixed back failed\n"); + return 1; + } + std::FILE* mf = std::fopen(o.dump_mixed.c_str(), "wb"); + if (mf == nullptr) { + std::fprintf(stderr, "strata generate: cannot write %s\n", o.dump_mixed.c_str()); + return 1; + } + std::fwrite(mx.data(), sizeof(float), mx.size(), mf); + std::fclose(mf); + double s2 = 0, mag = 0; + for (float v : mx) { s2 += (double) v * (double) v; mag += std::fabs((double) v); } + std::printf("%-24s %s (n_embd %lld, rms %.5g, mean|.| %.5g)\n", "mixed dumped", o.dump_mixed.c_str(), + (long long) g.n_embd, std::sqrt(s2 / (double) mx.size()), mag / (double) mx.size()); + } + + // ---- the residual, for bisecting the head against the layers (see `dump_residual`'s note) + if (!o.dump_residual.empty()) { + std::vector R((size_t) g.hc * g.n_embd); + if (cudaMemcpy(R.data(), ss.R, R.size() * sizeof(float), cudaMemcpyDeviceToHost) != cudaSuccess) { + std::fprintf(stderr, "strata generate: reading R back failed\n"); + return 1; + } + std::FILE* rf = std::fopen(o.dump_residual.c_str(), "wb"); + if (rf == nullptr) { + std::fprintf(stderr, "strata generate: cannot write %s\n", o.dump_residual.c_str()); + return 1; + } + const int32_t hdr[2] = {(int32_t) g.hc, (int32_t) g.n_embd}; + std::fwrite(hdr, sizeof hdr, 1, rf); + std::fwrite(R.data(), sizeof(float), R.size(), rf); + std::fclose(rf); + double mag = 0, mx = 0; + int bad = 0; + for (float v : R) { + if (!std::isfinite(v)) ++bad; + else { mag += std::fabs((double) v); mx = std::max(mx, (double) std::fabs((double) v)); } + } + std::printf("%-24s %s (%d x %lld, nonfinite %d, mean|.| %.4g, max|.| %.4g)\n", "residual dumped", + o.dump_residual.c_str(), (int) g.hc, (long long) g.n_embd, bad, mag / (double) R.size(), mx); + } + + if (o.stats) { + std::printf("%-24s %.3f ms/token (WALL CLOCK: embed, layers, head, sample)\n", " per token", decode_ms); + // **THE PER-TOKEN HOST TERM, WHICH `--gpu-only-full` CANNOT SEE.** That measurement never enters the + // token loop, so it excludes all six of these. On the 78-token fixture + 200 generated tokens the six + // sum to ~9 ms of non-layer work against ~1.5 ms of actual head GPU work - 16% of the token, and it is + // not the pool. + if (phase_tokens > 0) { + const double pt = (double) phase_tokens; + std::printf("%-24s PLE %.3f embed %.3f LAYERS %.3f head %.3f readback %.3f sample %.3f " + "(sum %.3f of %.3f ms)\n", + " token host phases", ms_ple / pt, ms_embed / pt, ms_layers / pt, ms_head / pt, + ms_readback / pt, ms_sample / pt, + (ms_ple + ms_embed + ms_layers + ms_head + ms_readback + ms_sample) / pt, decode_ms); + } + if (const std::string io = ple_table.io_report(); !io.empty()) std::printf(" %s\n", io.c_str()); + // **THE DENOMINATOR IS THE POSITIONS THE POOL ACTUALLY RAN ON, NOT THE DECODED TOKENS (A6).** + // `drive_pool` is called once per layer per position and PREFILL runs the loop too, so accumulating + // `cpu_ms` over prefill and then dividing by `decoded` inflates this figure. `drive.calls / n_layers` + // is the number of positions - the same correction the ring counters below already received, which is + // why they print "of 192" rather than "240 of 192". + const double pool_positions = g.n_layers > 0 ? (double) drive.calls / (double) g.n_layers : 0.0; + std::printf("%-24s %.3f ms/token over %lld layers (%.0f positions, %lld dispatches)\n", + " the CPU expert pool", pool_positions > 0.0 ? drive.cpu_ms / pool_positions : 0.0, + (long long) g.n_layers, pool_positions, (long long) drive.calls); + // **AND WHERE INSIDE `run()` IT WENT.** Three phases per layer and they were one number, which cannot + // tell a pool that is slow at the WORK from one that is slow at the SYNCHRONISATION - opposite fixes. + // Wait-for-park is expected to be ~0 (the workers re-parked at the end of the previous layer); the + // question is whether the time is in the drain or in the re-park barrier. + if (pool_positions > 0.0) { + double wp = 0, dr = 0, rp = 0; + pool.phase_ms(wp, dr, rp); + const double per = pool_positions; + std::printf("%-24s wait-park %.3f drain %.3f re-park %.3f ms/token\n", + " pool phases", wp / per, dr / per, rp / per); + } + std::printf("%-24s %lld blobs read\n", " expert blobs", (long long) srcp->reads()); + // ---- **R4's DISPATCH MEASUREMENT: h, ON THE ENGINE'S OWN ROUTING.** No offline trace, no corpus + // question, no k-fold - these are the ids the router actually produced on this run. Reported as + // hits/lookups so it can be read directly as the h the cache would deliver, and alongside `refused` + // so a full cache is visible rather than silently capping the rate. + if (o.expert_cache > 0) { + const int64_t look = drive.d.cache_hits + drive.d.cache_admitted + drive.d.cache_refused; + const int64_t hl = drive.d.hit_ready + drive.d.hit_late; + std::printf("%-24s %lld of %lld layers the hit work was DONE when the pool returned\n", + " R4 overlap", (long long) drive.d.hit_ready, (long long) hl); + std::printf("%-24s %lld of %lld = %.4f (%lld admitted, %lld refused, cache %.4f%% full)\n", + " R4 expert-cache hits", (long long) drive.d.cache_hits, (long long) look, + look > 0 ? (double) drive.d.cache_hits / (double) look : 0.0, + (long long) drive.d.cache_admitted, (long long) drive.d.cache_refused, + 100.0 * (double) (drive.d.cache_admitted + drive.d.cache_hits > 0 + ? (double) xcache.resident() / (double) xcache.slots() + : 0.0)); + } + if (tgraph.captured && tgraph.calls > 0) { + const double per = (double) tgraph.calls; + std::printf("%-24s wait for rings %.3f pool %.3f ms/token (%lld flushes over %lld positions)\n", + " token graph", tgraph.ms_wait / per, tgraph.ms_pool / per, (long long) tgraph.flushes, + (long long) tgraph.calls); + } + if (gr.captured && gr.calls_total > 0) { + // The counters are CUMULATIVE over every `session_loop` call, and PREFILL runs the loop too - so + // the denominator is the number of positions, not the number of generated tokens. Dividing by + // `n_layers * decoded` printed "240 of 192", which is a reporting bug that looks like a ring + // firing more often than it should. + const int64_t positions = gr.calls_total; + std::printf("%-24s %lld of %lld over %lld positions\n", " rings seen MID-GRAPH", + (long long) gr.rings_mid_graph, (long long) (g.n_layers * positions), + (long long) positions); + std::printf("%-24s %.3f ms of a %.3f ms layer\n", " ring latency", + gr.ms_to_ring / (double) (g.n_layers * positions), decode_ms / (double) g.n_layers); + // **THE ROUND TRIP, SPLIT AT THE RING.** `ring latency` is the first half and stops when the ring + // is seen; this is the second half - the driver calls after it, during which the GPU is IDLE + // because `post[l]` has not been launched yet. `--no-pool` is the arm that isolates it: 38.73 + // ms/token against a 26.32 ms pure-GPU floor is 12.4 ms of round trip with no expert work at all. + // + // Same denominator as the pool line above (the positions the loop actually ran on), so the two can + // be added without one of them being inflated by prefill. + const double perlap = (double) (g.n_layers * positions); + std::printf("%-24s %.3f ms/token over %.0f positions (%.3f ms/layer, after the ring)\n", + " host after ring", pool_positions > 0.0 ? gr.ms_host / pool_positions : 0.0, + pool_positions, gr.ms_host / perlap); + } + } + + if (dump != nullptr) std::printf("%-24s %s\n", "logits dumped", o.dump_logits.c_str()); + + strata::core::session_graphs_free(gr); + strata::core::doorbell_free(db); + cudaFree(d_next); + cudaFree(d_logits); + cudaFree(d_emb); + cudaFree(d_parts); + cudaFree(sbuf); + cudaFree(arena); + return 0; +} diff --git a/src/spec/controller.cpp b/src/spec/controller.cpp new file mode 100644 index 00000000..be312701 --- /dev/null +++ b/src/spec/controller.cpp @@ -0,0 +1,73 @@ +// src/spec/controller.cpp - see include/strata/spec/controller.hpp. +#include "strata/spec/controller.hpp" + +#include + +namespace strata::spec { + +double CostModel::step_ms(int k, bool mtp) const { + const int n = std::clamp(k + 1, 1, K_MAX + 1); + const double dense = dense_ms * dense_ratio[n - 1]; + // CPU misses: distinct experts grow as U(n); each extra token routed to a missed expert adds a fraction of a + // read. Tokens per distinct expert = n / U(n)-ratio (U measured in units of one token's 10 experts). + const double u = distinct_ratio[n - 1]; + const double uses_per_expert = (double) n / u; + const double cpu = (1.0 - hit_rate) * cpu_all_miss_ms * u * (1.0 + extra_use_cost * (uses_per_expert - 1.0)); + const double draft = k * (mtp ? mtp_draft_ms : lookup_draft_ms); + return dense + cpu + sync_ms + draft; +} + +Controller::Controller(CostModel cost, double min_gain, double ema) : cost_(cost), min_gain_(min_gain), ema_(ema) { + // Priors: MTP per-position acceptance from flyweight's measurement on this model (0.86 per draft); lookup by + // match length from the offline replay (2026-09-23-suffix-lookup). Both are then learned per session. + mtp_p_.fill(0.86); + lookup_q_ = {0.35, 0.6, 0.8, 0.92}; +} + +double Controller::expected(const double* p, int k, bool same) { + double e = 1.0, run = 1.0; // the verify pass always commits one token + for (int i = 0; i < k; ++i) { + run *= same ? p[0] : p[i]; + e += run; + } + return e; +} + +Choice Controller::choose(int lookup_available, int lookup_match, bool mtp_ready) const { + Choice best; + best.tokens_per_ms = 1.0 / cost_.step_ms(0, false); + const double baseline = best.tokens_per_ms; + auto consider = [&](Source s, int k, double e) { + const double rate = e / cost_.step_ms(k, s == Source::Mtp); + if (rate > best.tokens_per_ms) best = Choice{s, k, e, rate}; + }; + const int lk = std::min(lookup_available, K_MAX); + const double q = lookup_q_[bucket(lookup_match)]; + for (int k = 1; k <= lk; ++k) consider(Source::Lookup, k, expected(&q, k, true)); + if (mtp_ready) + for (int k = 1; k <= K_MAX; ++k) consider(Source::Mtp, k, expected(mtp_p_.data(), k, false)); + if (best.source != Source::None && best.tokens_per_ms < baseline * (1.0 + min_gain_)) { + Choice none; + none.tokens_per_ms = baseline; + return none; + } + return best; +} + +void Controller::observe(const Choice& c, int accepted, int lookup_match) { + if (c.source == Source::None || c.k <= 0) return; + accepted = std::clamp(accepted, 0, c.k); + // Positions 0..accepted-1 were accepted given their prefix; position `accepted` (if drafted) was rejected. + const int seen = std::min(c.k, accepted + 1); + for (int i = 0; i < seen; ++i) { + const double hit = i < accepted ? 1.0 : 0.0; + if (c.source == Source::Mtp) mtp_p_[i] += ema_ * (hit - mtp_p_[i]); + else lookup_q_[bucket(lookup_match)] += ema_ * (hit - lookup_q_[bucket(lookup_match)]); + } + // Everything drafted was accepted: the positions beyond the window were never tested, so without this they + // keep their prior forever and the window can never grow. Pull them toward the deepest observed rate. + if (c.source == Source::Mtp && accepted == c.k && c.k < K_MAX) + for (int i = c.k; i < K_MAX; ++i) mtp_p_[i] += ema_ * (mtp_p_[c.k - 1] - mtp_p_[i]); +} + +} // namespace strata::spec diff --git a/src/spec/controller_test.cpp b/src/spec/controller_test.cpp new file mode 100644 index 00000000..ee056ab5 --- /dev/null +++ b/src/spec/controller_test.cpp @@ -0,0 +1,74 @@ +// src/spec/controller_test.cpp - plan v0.3 P6: the speculation controller's decisions (no model, no GPU). +#include "strata/spec/controller.hpp" + +#include + +using namespace strata::spec; + +namespace { +int g_fail = 0; +void check(bool ok, const char* what) { + if (!ok) { std::fprintf(stderr, "FAIL: %s\n", what); ++g_fail; } +} +const char* name(Source s) { return s == Source::Mtp ? "mtp" : s == Source::Lookup ? "lookup" : "none"; } +} // namespace + +int main() { + { // priors (MTP 0.86 per draft): MTP with a short-to-medium window beats plain decoding + Controller c; + const Choice ch = c.choose(0, 0, true); + check(ch.source == Source::Mtp && ch.k >= 2 && ch.k <= 5, "MTP chosen with k in 2..5 at the prior"); + std::printf("prior: %s k=%d expected %.2f tokens, %.1f tok/s (plain %.1f)\n", name(ch.source), ch.k, + ch.expected_tokens, 1000 * ch.tokens_per_ms, 1000.0 / c.cost().step_ms(0, false)); + } + { // a drafter that keeps missing its first token is switched off + Controller c; + Choice ch = c.choose(0, 0, true); + for (int i = 0; i < 400; ++i) { c.observe(ch, 0, 0); ch = c.choose(0, 0, true); if (ch.source == Source::None) break; } + check(ch.source == Source::None, "persistent rejection turns speculation off"); + std::printf("after rejections: %s, p0 %.2f\n", name(ch.source), c.mtp_accept(0)); + } + { // a long lookup match beats MTP; how LONG a window pays depends on what a wide verify costs + Controller c; + const Choice ch = c.choose(8, 20, true); + check(ch.source == Source::Lookup, "long lookup match preferred over MTP"); + std::printf("long lookup match, measured costs: %s k=%d expected %.2f, %.1f tok/s\n", name(ch.source), ch.k, + ch.expected_tokens, 1000 * ch.tokens_per_ms); + CostModel cheap; // a verify pass whose cost barely grows with width + cheap.dense_ratio = {1.0, 1.02, 1.04, 1.06, 1.08, 1.1, 1.12, 1.14, 1.16}; + cheap.hit_rate = 0.95; + Controller c2(cheap); + const Choice ch2 = c2.choose(8, 20, true); + check(ch2.source == Source::Lookup && ch2.k == 8, "cheap wide verify takes the whole lookup window"); + std::printf("long lookup match, cheap wide verify: %s k=%d expected %.2f, %.1f tok/s\n", name(ch2.source), + ch2.k, ch2.expected_tokens, 1000 * ch2.tokens_per_ms); + } + { // a bare trigram match, no MTP: not worth a verify window + Controller c; + const Choice ch = c.choose(8, 3, false); + check(ch.source == Source::None, "short lookup match alone is not used"); + } + { // perfect acceptance is learned and widens the window + Controller c; + Choice ch = c.choose(0, 0, true); + const int k0 = ch.k; + for (int i = 0; i < 400; ++i) { c.observe(ch, ch.k, 0); ch = c.choose(0, 0, true); } + check(ch.k > k0 && c.mtp_accept(0) > 0.99, "full acceptance learned and the window widens"); + std::printf("after full acceptance: %s k=%d (was %d), p0 %.3f\n", name(ch.source), ch.k, k0, c.mtp_accept(0)); + } + std::printf("\nMTP acceptance -> choice (default cost model):\n"); + for (double p : {0.5, 0.6, 0.7, 0.8, 0.86, 0.9, 0.95}) { + Controller c; + Choice ch = c.choose(0, 0, true); + for (int i = 0; i < 2000; ++i) { // feed a stream with iid acceptance p per position + int acc = 0; + unsigned r = 2654435761u * (unsigned) (i + 1); + while (acc < ch.k && ((r = r * 1103515245u + 12345u) >> 8) % 1000 < p * 1000) ++acc; + c.observe(ch, acc, 0); + ch = c.choose(0, 0, true); + } + std::printf(" p=%.2f: %s k=%d, %.1f tok/s\n", p, name(ch.source), ch.k, 1000 * ch.tokens_per_ms); + } + std::printf("controller tests: %s\n", g_fail ? "FAILED" : "OK"); + return g_fail ? 1 : 0; +} diff --git a/src/spec/suffix_drafter.cpp b/src/spec/suffix_drafter.cpp new file mode 100644 index 00000000..6f04f76d --- /dev/null +++ b/src/spec/suffix_drafter.cpp @@ -0,0 +1,88 @@ +// src/spec/suffix_drafter.cpp - see include/strata/spec/suffix_drafter.hpp. +#include "strata/spec/suffix_drafter.hpp" + +#include + +namespace strata::spec { + +namespace { +uint64_t mix(uint64_t x) { + x ^= x >> 33; + x *= 0xff51afd7ed558ccdull; + x ^= x >> 33; + x *= 0xc4ceb9fe1a85ec53ull; + return x ^ (x >> 33); +} +} // namespace + +SuffixDrafter::SuffixDrafter(int min_match, int max_match, size_t capacity_tokens) + : min_match_(std::max(3, min_match)), max_match_(std::max(min_match, max_match)) { + size_t cap = 1; + while (cap < capacity_tokens * 2) cap <<= 1; // load factor <= 0.5 at the nominal capacity + table_.assign(cap, Slot{}); + mask_ = cap - 1; + hist_.reserve(capacity_tokens); +} + +void SuffixDrafter::reset() { + hist_.clear(); + std::fill(table_.begin(), table_.end(), Slot{}); + last_match_ = 0; +} + +uint64_t SuffixDrafter::key_at(size_t end) const { + const uint64_t a = (uint32_t) hist_[end - 2], b = (uint32_t) hist_[end - 1], c = (uint32_t) hist_[end]; + return mix(a * 0x9E3779B97F4A7C15ull ^ mix(b + 0x632BE59BD9B4E019ull) ^ (c << 1)) | 1ull; // never 0 +} + +SuffixDrafter::Slot* SuffixDrafter::find_slot(uint64_t key, bool insert) { + for (size_t i = key & mask_, probes = 0; probes <= mask_; i = (i + 1) & mask_, ++probes) { + Slot& s = table_[i]; + if (s.key == key) return &s; + if (s.key == 0) { + if (!insert) return nullptr; + s.key = key; + return &s; + } + } + return nullptr; // table full: the history outgrew its capacity +} + +void SuffixDrafter::append(const int32_t* tokens, size_t n) { + for (size_t i = 0; i < n; ++i) { + hist_.push_back(tokens[i]); + const size_t end = hist_.size() - 1; + if (end < 2) continue; + Slot* s = find_slot(key_at(end), true); + if (s == nullptr) continue; + for (int w = WAYS - 1; w > 0; --w) s->pos[w] = s->pos[w - 1]; + s->pos[0] = (uint32_t) end; + if (s->n < WAYS) ++s->n; + } +} + +int SuffixDrafter::propose(int max_k, int32_t* out) { + last_match_ = 0; + const size_t n = hist_.size(); + if (n < 4 || max_k <= 0) return 0; + const size_t cur = n - 1; + const Slot* s = find_slot(key_at(cur), false); + if (s == nullptr) return 0; + size_t best_end = 0; + int best_len = 0; + for (int w = 0; w < s->n; ++w) { + const size_t p = s->pos[w]; + if (p >= cur) continue; // the current suffix itself + int len = 0; + while (len < max_match_ && len <= (int) p && hist_[p - len] == hist_[cur - len]) ++len; + if (len > best_len) { best_len = len; best_end = p; } // most recent first, so ties keep the newer + } + if (best_len < min_match_) return 0; + last_match_ = best_len; + int k = 0; + // The continuation may run into the current suffix (periodic text); reading history up to `cur` is valid. + for (size_t q = best_end + 1; q <= cur && k < max_k; ++q) out[k++] = hist_[q]; + return k; +} + +} // namespace strata::spec diff --git a/src/spec/suffix_drafter_test.cpp b/src/spec/suffix_drafter_test.cpp new file mode 100644 index 00000000..9fa8611e --- /dev/null +++ b/src/spec/suffix_drafter_test.cpp @@ -0,0 +1,141 @@ +// src/spec/suffix_drafter_test.cpp - plan v0.3 P6: suffix-lookup drafter tests and an offline simulation. +// +// suffix_drafter_test unit tests +// suffix_drafter_test --simulate [--k K] FILE.ids ... +// +// The simulation replays text as if a model had produced it: each step drafts up to K tokens, accepts the +// prefix that matches the true next tokens, and commits that prefix plus one model token (the verify pass's +// bonus). tokens/step is the lookup drafter's ceiling speedup on that text when a K+1-token verify costs the same +// as a 1-token step. Two regimes per file: +// continue history = first half of the prompt; generate its second half (natural continuation) +// copy history = the whole prompt; generate its middle third again (output quoting input: edits) +// No model runs; this measures the drafter on text, not on the model's own outputs. +#include "strata/spec/suffix_drafter.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using strata::spec::SuffixDrafter; + +namespace { +int g_fail = 0; +void check(bool ok, const char* what) { + if (!ok) { std::fprintf(stderr, "FAIL: %s\n", what); ++g_fail; } +} + +std::vector read_ids(const char* path) { + std::ifstream f(path); + std::string text((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + for (char& c : text) if (c == ',') c = ' '; + std::istringstream in(text); + std::vector ids; + for (int32_t v; in >> v;) ids.push_back(v); + return ids; +} + +struct Sim { long steps = 0, tokens = 0, proposals = 0, accepted = 0; }; + +Sim simulate(const std::vector& history, const std::vector& target, int K) { + SuffixDrafter d(3, 32, history.size() + target.size() + 16); + d.append(history.data(), history.size()); + Sim s; + std::vector draft(K); + size_t i = 0; + while (i < target.size()) { + const int n = d.propose(K, draft.data()); + int a = 0; + while (a < n && i + a < target.size() && draft[a] == target[i + a]) ++a; + if (n > 0) { ++s.proposals; s.accepted += a; } + const size_t commit = std::min(target.size() - i, (size_t) a + 1); + d.append(&target[i], commit); + i += commit; + s.tokens += (long) commit; + ++s.steps; + } + return s; +} + +void unit_tests() { + { // a repeated passage is proposed verbatim + SuffixDrafter d; + const std::vector doc = {10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}; + d.append(doc.data(), doc.size()); + const int32_t again[] = {10, 11, 12}; + d.append(again, 3); + int32_t out[8]; + const int n = d.propose(5, out); + check(n == 5 && out[0] == 13 && out[4] == 17, "repeat proposes the continuation"); + check(d.last_match() == 3, "match length is the shared suffix"); + } + { // no earlier occurrence: nothing proposed + SuffixDrafter d; + const std::vector doc = {1, 2, 3, 4, 5, 6, 7}; + d.append(doc.data(), doc.size()); + int32_t out[4]; + check(d.propose(4, out) == 0, "no match proposes nothing"); + } + { // two occurrences of the trigram: the longer match wins even though it is older + SuffixDrafter d; + const std::vector doc = {7, 8, 1, 2, 3, 100, 101, 9, 1, 2, 3, 200, 201, 50, 7, 8, 1, 2, 3}; + d.append(doc.data(), doc.size()); + int32_t out[2]; + const int n = d.propose(2, out); + check(n == 2 && out[0] == 100 && out[1] == 101, "longest match preferred over most recent"); + check(d.last_match() == 5, "longest match length 5"); + } + { // a match shorter than min_match is ignored (only a bigram in common) + SuffixDrafter d(4); + const std::vector doc = {1, 2, 3, 9, 5, 2, 3}; + d.append(doc.data(), doc.size()); + int32_t out[2]; + check(d.propose(2, out) == 0, "min_match respected"); + } + { // periodic text: the continuation may run into the current suffix + SuffixDrafter d; + const std::vector doc = {1, 2, 3, 1, 2, 3, 1, 2, 3}; + d.append(doc.data(), doc.size()); + int32_t out[6]; + const int n = d.propose(6, out); + check(n >= 3 && out[0] == 1 && out[1] == 2 && out[2] == 3, "periodic continuation"); + } + { // bounded memory: appending beyond the nominal capacity does not crash and still works + SuffixDrafter d(3, 32, 1024); + std::vector doc; + for (int i = 0; i < 5000; ++i) doc.push_back(i % 997); + d.append(doc.data(), doc.size()); + int32_t out[4]; + check(d.propose(4, out) > 0, "propose after overflow of nominal capacity"); + } + std::printf("suffix_drafter unit tests: %s\n", g_fail ? "FAILED" : "OK"); +} +} // namespace + +int main(int argc, char** argv) { + if (argc > 1 && std::strcmp(argv[1], "--simulate") == 0) { + int K = 8, first = 2; + if (argc > 3 && std::strcmp(argv[2], "--k") == 0) { K = std::atoi(argv[3]); first = 4; } + std::printf("%-28s %8s | %-44s | %-44s\n", "prompt", "tokens", "continue: tok/step proposals acc/proposal", + "copy: tok/step proposals acc/proposal"); + for (int f = first; f < argc; ++f) { + const std::vector ids = read_ids(argv[f]); + if (ids.size() < 64) continue; + const size_t half = ids.size() / 2; + const Sim c = simulate({ids.begin(), ids.begin() + half}, {ids.begin() + half, ids.end()}, K); + const size_t a = ids.size() / 3, b = 2 * ids.size() / 3; + const Sim p = simulate(ids, {ids.begin() + a, ids.begin() + b}, K); + const char* name = std::strrchr(argv[f], '/') ? std::strrchr(argv[f], '/') + 1 : argv[f]; + std::printf("%-28s %8zu | %8.2f %10.1f%% %10.2f | %8.2f %10.1f%% %10.2f\n", name, ids.size(), + (double) c.tokens / c.steps, 100.0 * c.proposals / c.steps, + c.proposals ? (double) c.accepted / c.proposals : 0.0, (double) p.tokens / p.steps, + 100.0 * p.proposals / p.steps, p.proposals ? (double) p.accepted / p.proposals : 0.0); + } + return 0; + } + unit_tests(); + return g_fail ? 1 : 0; +} diff --git a/third_party/ggml/LICENSE b/third_party/ggml/LICENSE new file mode 100644 index 00000000..e7dca554 --- /dev/null +++ b/third_party/ggml/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-2026 The ggml authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/ggml/VERSION.txt b/third_party/ggml/VERSION.txt new file mode 100644 index 00000000..2fca1351 --- /dev/null +++ b/third_party/ggml/VERSION.txt @@ -0,0 +1 @@ +3cf03257f219afbe7334045ff7c6a06ac68c627d Sun Sep 20 16:08:11 2026 +0800 diff --git a/third_party/ggml/ggml-common.h b/third_party/ggml/ggml-common.h new file mode 100644 index 00000000..1dbbe326 --- /dev/null +++ b/third_party/ggml/ggml-common.h @@ -0,0 +1,1911 @@ +#ifndef GGML_COMMON_DECL + +#if defined(GGML_COMMON_DECL_C) +#include + +typedef uint16_t ggml_half; +typedef uint32_t ggml_half2; + +#define GGML_COMMON_AGGR_U +#define GGML_COMMON_AGGR_S + +#define GGML_COMMON_DECL +#elif defined(GGML_COMMON_DECL_CPP) +#include + +typedef uint16_t ggml_half; +typedef uint32_t ggml_half2; + +// std-c++ allow anonymous unions but some compiler warn on it +#define GGML_COMMON_AGGR_U data +// std-c++ do not allow it. +#define GGML_COMMON_AGGR_S data + +#define GGML_COMMON_DECL +#elif defined(GGML_COMMON_DECL_METAL) +#include + +typedef half ggml_half; +typedef half2 ggml_half2; + +#define GGML_COMMON_AGGR_U +#define GGML_COMMON_AGGR_S + +#define GGML_COMMON_DECL +#elif defined(GGML_COMMON_DECL_CUDA) +#if defined(GGML_COMMON_DECL_MUSA) +#include +#else +#include +#endif +#include + +typedef half ggml_half; +typedef half2 ggml_half2; + +#define GGML_COMMON_AGGR_U +#define GGML_COMMON_AGGR_S data + +#define GGML_COMMON_DECL +#elif defined(GGML_COMMON_DECL_HIP) +#include +#include + +typedef half ggml_half; +typedef half2 ggml_half2; + +#define GGML_COMMON_AGGR_U +#define GGML_COMMON_AGGR_S data + +#define GGML_COMMON_DECL +#elif defined(GGML_COMMON_DECL_SYCL) +#include +#include + +typedef sycl::half ggml_half; +typedef sycl::half2 ggml_half2; + +#define GGML_COMMON_AGGR_U +#define GGML_COMMON_AGGR_S data + +#define GGML_COMMON_DECL +#endif + +#if defined(GGML_COMMON_DECL) + +#ifndef __cplusplus +#ifndef static_assert +#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 201100L) +#define static_assert(cond, msg) _Static_assert(cond, msg) +#else +#define static_assert(cond, msg) struct global_scope_noop_trick +#endif +#endif +#endif // __cplusplus + +// QK = number of values after dequantization +// QK_K = super-block size + +#define QK_K 256 +#define K_SCALE_SIZE 12 + +#if defined(GGML_COMMON_DECL_CUDA) || defined(GGML_COMMON_DECL_HIP) || defined(GGML_COMMON_DECL_SYCL) +// QR = QK / number of values before dequantization +// QI = number of 32 bit integers before dequantization + +#define QI1_0 (QK1_0 / 32) +#define QR1_0 1 + +#define QI2_0 (QK2_0 / 32) +#define QR2_0 1 + + +#define QI4_0 (QK4_0 / (4 * QR4_0)) +#define QR4_0 2 + +#define QI4_1 (QK4_1 / (4 * QR4_1)) +#define QR4_1 2 + +#define QI_MXFP4 (QK_MXFP4 / (4 * QR_MXFP4)) +#define QR_MXFP4 2 + +#define QI_NVFP4 (QK_NVFP4 / (4 * QR_NVFP4)) +#define QR_NVFP4 2 + +#define QI5_0 (QK5_0 / (4 * QR5_0)) +#define QR5_0 2 + +#define QI5_1 (QK5_1 / (4 * QR5_1)) +#define QR5_1 2 + +#define QI8_0 (QK8_0 / (4 * QR8_0)) +#define QR8_0 1 + +#define QI8_1 (QK8_1 / (4 * QR8_1)) +#define QR8_1 1 + +#define QI2_K (QK_K / (4*QR2_K)) +#define QR2_K 4 + +#define QI3_K (QK_K / (4*QR3_K)) +#define QR3_K 4 + +#define QI4_K (QK_K / (4*QR4_K)) +#define QR4_K 2 + +#define QI5_K (QK_K / (4*QR5_K)) +#define QR5_K 2 + +#define QI6_K (QK_K / (4*QR6_K)) +#define QR6_K 2 + +#define QI2_XXS (QK_K / (4*QR2_XXS)) +#define QR2_XXS 4 + +#define QI2_XS (QK_K / (4*QR2_XS)) +#define QR2_XS 4 + +#define QI2_S (QK_K / (4*QR2_S)) +#define QR2_S 4 + +#define QI3_XXS (QK_K / (4*QR3_XXS)) +#define QR3_XXS 4 + +#define QI3_XS (QK_K / (4*QR3_XS)) +#define QR3_XS 4 + +#define QI1_S (QK_K / (4*QR1_S)) +#define QR1_S 8 + +#define QI1_M (QK_K / (4*QR1_M)) +#define QR1_M 8 + +#define QI4_NL (QK4_NL / (4*QR4_NL)) +#define QR4_NL 2 + +#define QI4_XS (QK_K / (4*QR4_XS)) +#define QR4_XS 2 + +#define QI3_S (QK_K / (4*QR3_S)) +#define QR3_S 4 + +#endif // GGML_COMMON_DECL_CUDA || GGML_COMMON_DECL_HIP + +#ifdef _MSC_VER +#define GGML_EXTENSION +#else // _MSC_VER +#define GGML_EXTENSION __extension__ +#endif // _MSC_VER + +#define QK1_0 128 +typedef struct { + ggml_half d; // delta + uint8_t qs[QK1_0 / 8]; // bits / quants +} block_q1_0; +static_assert(sizeof(block_q1_0) == sizeof(ggml_half) + QK1_0 / 8, "wrong q1_0 block size/padding"); + +#define QK2_0 64 +typedef struct { + ggml_half d; // delta (scale) + uint8_t qs[QK2_0 / 4]; // 2 bits per element +} block_q2_0; +static_assert(sizeof(block_q2_0) == sizeof(ggml_half) + QK2_0 / 4, "wrong q2_0 block size/padding"); + +#define QK4_0 32 +typedef struct { + ggml_half d; // delta + uint8_t qs[QK4_0 / 2]; // nibbles / quants +} block_q4_0; +static_assert(sizeof(block_q4_0) == sizeof(ggml_half) + QK4_0 / 2, "wrong q4_0 block size/padding"); + +#define QK4_1 32 +typedef struct { + GGML_EXTENSION union { + struct { + ggml_half d; // delta + ggml_half m; // min + } GGML_COMMON_AGGR_S; + ggml_half2 dm; + } GGML_COMMON_AGGR_U; + uint8_t qs[QK4_1 / 2]; // nibbles / quants +} block_q4_1; +static_assert(sizeof(block_q4_1) == 2 * sizeof(ggml_half) + QK4_1 / 2, "wrong q4_1 block size/padding"); + +#define QK_MXFP4 32 +typedef struct { + uint8_t e; // E8M0 + uint8_t qs[QK_MXFP4/2]; +} block_mxfp4; +static_assert(sizeof(block_mxfp4) == sizeof(uint8_t) + QK_MXFP4/2, "wrong mxfp4 block size/padding"); + +#define QK_NVFP4 64 +#define QK_NVFP4_SUB 16 // sub-block size for per-group scales +typedef struct { + uint8_t d[QK_NVFP4/QK_NVFP4_SUB]; // UE4M3 scales (4 bytes, one per 16-element sub-block) + uint8_t qs[QK_NVFP4/2]; // packed 4-bit E2M1 values (32 bytes) +} block_nvfp4; +static_assert(sizeof(block_nvfp4) == sizeof(uint8_t)*(QK_NVFP4/QK_NVFP4_SUB) + QK_NVFP4/2, "wrong nvfp4 block size/padding"); + +#define QK5_0 32 +typedef struct { + ggml_half d; // delta + uint8_t qh[4]; // 5-th bit of quants + uint8_t qs[QK5_0 / 2]; // nibbles / quants +} block_q5_0; +static_assert(sizeof(block_q5_0) == sizeof(ggml_half) + sizeof(uint32_t) + QK5_0 / 2, "wrong q5_0 block size/padding"); + +#define QK5_1 32 +typedef struct { + GGML_EXTENSION union { + struct { + ggml_half d; // delta + ggml_half m; // min + } GGML_COMMON_AGGR_S; + ggml_half2 dm; + } GGML_COMMON_AGGR_U; + uint8_t qh[4]; // 5-th bit of quants + uint8_t qs[QK5_1 / 2]; // nibbles / quants +} block_q5_1; +static_assert(sizeof(block_q5_1) == 2 * sizeof(ggml_half) + sizeof(uint32_t) + QK5_1 / 2, "wrong q5_1 block size/padding"); + +#define QK8_0 32 +typedef struct { + ggml_half d; // delta + int8_t qs[QK8_0]; // quants +} block_q8_0; +static_assert(sizeof(block_q8_0) == sizeof(ggml_half) + QK8_0, "wrong q8_0 block size/padding"); + +#define QK8_1 32 +typedef struct { + GGML_EXTENSION union { + struct { + ggml_half d; // delta + ggml_half s; // d * sum(qs[i]) + } GGML_COMMON_AGGR_S; + ggml_half2 ds; + } GGML_COMMON_AGGR_U; + int8_t qs[QK8_1]; // quants +} block_q8_1; +static_assert(sizeof(block_q8_1) == 2*sizeof(ggml_half) + QK8_1, "wrong q8_1 block size/padding"); + +// +// Ternary quantization +// + +// 1.6875 bpw +typedef struct { + uint8_t qs[(QK_K - 4 * QK_K / 64) / 5]; // 5 elements per byte (3^5 = 243 < 256) + uint8_t qh[QK_K/64]; // 4 elements per byte + ggml_half d; +} block_tq1_0; +static_assert(sizeof(block_tq1_0) == sizeof(ggml_half) + QK_K / 64 + (QK_K - 4 * QK_K / 64) / 5, "wrong tq1_0 block size/padding"); + +// 2.0625 bpw +typedef struct { + uint8_t qs[QK_K/4]; // 2 bits per element + ggml_half d; +} block_tq2_0; +static_assert(sizeof(block_tq2_0) == sizeof(ggml_half) + QK_K / 4, "wrong tq2_0 block size/padding"); + +// +// Super-block quantization structures +// + +// 2-bit quantization +// weight is represented as x = a * q + b +// 16 blocks of 16 elements each +// Effectively 2.625 bits per weight +typedef struct { + uint8_t scales[QK_K/16]; // scales and mins, quantized with 4 bits + uint8_t qs[QK_K/4]; // quants + GGML_EXTENSION union { + struct { + ggml_half d; // super-block scale for quantized scales + ggml_half dmin; // super-block scale for quantized mins + } GGML_COMMON_AGGR_S; + ggml_half2 dm; + } GGML_COMMON_AGGR_U; +} block_q2_K; +static_assert(sizeof(block_q2_K) == 2*sizeof(ggml_half) + QK_K/16 + QK_K/4, "wrong q2_K block size/padding"); + +// 3-bit quantization +// weight is represented as x = a * q +// 16 blocks of 16 elements each +// Effectively 3.4375 bits per weight +typedef struct { + uint8_t hmask[QK_K/8]; // quants - high bit + uint8_t qs[QK_K/4]; // quants - low 2 bits + uint8_t scales[12]; // scales, quantized with 6 bits + ggml_half d; // super-block scale +} block_q3_K; +static_assert(sizeof(block_q3_K) == sizeof(ggml_half) + QK_K / 4 + QK_K / 8 + 12, "wrong q3_K block size/padding"); + +// 4-bit quantization +// 8 blocks of 32 elements each +// weight is represented as x = a * q + b +// Effectively 4.5 bits per weight +typedef struct { + GGML_EXTENSION union { + struct { + ggml_half d; // super-block scale for quantized scales + ggml_half dmin; // super-block scale for quantized mins + } GGML_COMMON_AGGR_S; + ggml_half2 dm; + } GGML_COMMON_AGGR_U; + uint8_t scales[K_SCALE_SIZE]; // scales and mins, quantized with 6 bits + uint8_t qs[QK_K/2]; // 4--bit quants +} block_q4_K; +static_assert(sizeof(block_q4_K) == 2*sizeof(ggml_half) + K_SCALE_SIZE + QK_K/2, "wrong q4_K block size/padding"); + +// 5-bit quantization +// 8 blocks of 32 elements each +// weight is represented as x = a * q + b +// Effectively 5.5 bits per weight +typedef struct { + GGML_EXTENSION union { + struct { + ggml_half d; // super-block scale for quantized scales + ggml_half dmin; // super-block scale for quantized mins + } GGML_COMMON_AGGR_S; + ggml_half2 dm; + } GGML_COMMON_AGGR_U; + uint8_t scales[K_SCALE_SIZE]; // scales and mins, quantized with 6 bits + uint8_t qh[QK_K/8]; // quants, high bit + uint8_t qs[QK_K/2]; // quants, low 4 bits +} block_q5_K; +static_assert(sizeof(block_q5_K) == 2*sizeof(ggml_half) + K_SCALE_SIZE + QK_K/2 + QK_K/8, "wrong q5_K block size/padding"); + +// 6-bit quantization +// weight is represented as x = a * q +// 16 blocks of 16 elements each +// Effectively 6.5625 bits per weight +typedef struct { + uint8_t ql[QK_K/2]; // quants, lower 4 bits + uint8_t qh[QK_K/4]; // quants, upper 2 bits + int8_t scales[QK_K/16]; // scales, quantized with 8 bits + ggml_half d; // super-block scale +} block_q6_K; +static_assert(sizeof(block_q6_K) == sizeof(ggml_half) + QK_K / 16 + 3*QK_K/4, "wrong q6_K block size/padding"); + +// This is only used for intermediate quantization and dot products +typedef struct { + float d; // delta + int8_t qs[QK_K]; // quants + int16_t bsums[QK_K/16]; // sum of quants in groups of 16 +} block_q8_K; +static_assert(sizeof(block_q8_K) == sizeof(float) + QK_K + QK_K/16*sizeof(int16_t), "wrong q8_K block size/padding"); + +// (Almost) "true" 2-bit quantization. +// Due to the need to use blocks as per ggml design, it ends up using +// 2.0625 bpw because of the 16-bit scale for each block of 256. +typedef struct { + ggml_half d; + uint16_t qs[QK_K/8]; +} block_iq2_xxs; +static_assert(sizeof(block_iq2_xxs) == sizeof(ggml_half) + QK_K/8*sizeof(uint16_t), "wrong iq2_xxs block size/padding"); + +// 2.3125 bpw quants +typedef struct { + ggml_half d; + uint16_t qs[QK_K/8]; + uint8_t scales[QK_K/32]; +} block_iq2_xs; +static_assert(sizeof(block_iq2_xs) == sizeof(ggml_half) + QK_K/8*sizeof(uint16_t) + QK_K/32, "wrong iq2_xs block size/padding"); + +// 2.5625 bpw quants +typedef struct { + ggml_half d; + uint8_t qs[QK_K/4]; + uint8_t qh[QK_K/32]; + uint8_t scales[QK_K/32]; +} block_iq2_s; +static_assert(sizeof(block_iq2_s) == sizeof(ggml_half) + QK_K/4 + QK_K/16, "wrong iq2_s block size/padding"); + +// (Almost) "true" 3-bit quantization. +// Due to the need to use blocks as per ggml design, it ends up using +// 3.0625 bpw because of the 16-bit scale for each block of 256. +typedef struct { + ggml_half d; + uint8_t qs[3*QK_K/8]; +} block_iq3_xxs; +static_assert(sizeof(block_iq3_xxs) == sizeof(ggml_half) + 3*(QK_K/8), "wrong iq3_xxs block size/padding"); + +// 3.4375 bpw +#define IQ3S_N_SCALE QK_K/64 +typedef struct { + ggml_half d; + uint8_t qs[QK_K/4]; + uint8_t qh[QK_K/32]; + uint8_t signs[QK_K/8]; + uint8_t scales[IQ3S_N_SCALE]; +} block_iq3_s; +static_assert(sizeof(block_iq3_s) == sizeof(ggml_half) + 13*(QK_K/32) + IQ3S_N_SCALE, "wrong iq3_s block size/padding"); + +// 1.5625 bpw +typedef struct { + ggml_half d; + uint8_t qs[QK_K/8]; + uint16_t qh[QK_K/32]; +} block_iq1_s; +static_assert(sizeof(block_iq1_s) == sizeof(ggml_half) + QK_K/8 + QK_K/16, "wrong iq1_s block size/padding"); + +// 1.75 bpw +typedef struct { + uint8_t qs[QK_K/8]; // grid index, low 8 bits + uint8_t qh[QK_K/16]; // grid index, high 3 bits + grid shift bit (for two groups of 8) + uint8_t scales[QK_K/32]; // 3-bit block scales (4-bit if QK_K == 64) +} block_iq1_m; +static_assert(sizeof(block_iq1_m) == QK_K/8 + QK_K/16 + QK_K/32, "wrong iq1_m block size/padding"); + +// Used by IQ1_M quants +typedef union { + ggml_half f16; + uint16_t u16; +} iq1m_scale_t; + +// Non-linear quants +#define QK4_NL 32 +typedef struct { + ggml_half d; + uint8_t qs[QK4_NL/2]; +} block_iq4_nl; +static_assert(sizeof(block_iq4_nl) == sizeof(ggml_half) + QK4_NL/2, "wrong iq4_nl block size/padding"); + +typedef struct { + ggml_half d; + uint16_t scales_h; + uint8_t scales_l[QK_K/64]; + uint8_t qs[QK_K/2]; +} block_iq4_xs; +static_assert(sizeof(block_iq4_xs) == sizeof(ggml_half) + sizeof(uint16_t) + QK_K/64 + QK_K/2, "wrong iq4_xs block size/padding"); + +#endif // GGML_COMMON_DECL +#endif // GGML_COMMON_DECL + +//////////////////////////////////////////////////////////////////////////////// + +#ifndef GGML_COMMON_IMPL + +#if defined(GGML_COMMON_IMPL_C) +#include + +#define GGML_TABLE_BEGIN(type, name, size) static const type name[size] = { +#define GGML_TABLE_END() }; + +#define GGML_COMMON_IMPL +#elif defined(GGML_COMMON_IMPL_CPP) +#include + +#define GGML_TABLE_BEGIN(type, name, size) static const type name[size] = { +#define GGML_TABLE_END() }; + +#define GGML_COMMON_IMPL +#elif defined(GGML_COMMON_IMPL_METAL) +#include + +#define GGML_TABLE_BEGIN(type, name, size) static const constant type name[size] = { +#define GGML_TABLE_END() }; + +#define GGML_COMMON_IMPL +#elif defined(GGML_COMMON_IMPL_CUDA) || defined(GGML_COMMON_IMPL_HIP) || defined(GGML_COMMON_IMPL_MUSA) +#include + +#define GGML_TABLE_BEGIN(type, name, size) static const __device__ type name[size] = { +#define GGML_TABLE_END() }; + +#define GGML_COMMON_IMPL +#elif defined(GGML_COMMON_IMPL_SYCL) + +#include + +#define GGML_TABLE_BEGIN(type, name, size) static const type name[size] = { +#define GGML_TABLE_END() }; + +#define GGML_COMMON_IMPL +#endif + +#if defined(GGML_COMMON_IMPL) + +GGML_TABLE_BEGIN(uint8_t, kmask_iq2xs, 8) + 1, 2, 4, 8, 16, 32, 64, 128 +GGML_TABLE_END() + +GGML_TABLE_BEGIN(uint8_t, ksigns_iq2xs, 128) + 0, 129, 130, 3, 132, 5, 6, 135, 136, 9, 10, 139, 12, 141, 142, 15, + 144, 17, 18, 147, 20, 149, 150, 23, 24, 153, 154, 27, 156, 29, 30, 159, + 160, 33, 34, 163, 36, 165, 166, 39, 40, 169, 170, 43, 172, 45, 46, 175, + 48, 177, 178, 51, 180, 53, 54, 183, 184, 57, 58, 187, 60, 189, 190, 63, + 192, 65, 66, 195, 68, 197, 198, 71, 72, 201, 202, 75, 204, 77, 78, 207, + 80, 209, 210, 83, 212, 85, 86, 215, 216, 89, 90, 219, 92, 221, 222, 95, + 96, 225, 226, 99, 228, 101, 102, 231, 232, 105, 106, 235, 108, 237, 238, 111, + 240, 113, 114, 243, 116, 245, 246, 119, 120, 249, 250, 123, 252, 125, 126, 255, +GGML_TABLE_END() + +GGML_TABLE_BEGIN(uint64_t, ksigns64, 128) + 0x0000000000000000, 0xff000000000000ff, 0xff0000000000ff00, 0x000000000000ffff, + 0xff00000000ff0000, 0x0000000000ff00ff, 0x0000000000ffff00, 0xff00000000ffffff, + 0xff000000ff000000, 0x00000000ff0000ff, 0x00000000ff00ff00, 0xff000000ff00ffff, + 0x00000000ffff0000, 0xff000000ffff00ff, 0xff000000ffffff00, 0x00000000ffffffff, + 0xff0000ff00000000, 0x000000ff000000ff, 0x000000ff0000ff00, 0xff0000ff0000ffff, + 0x000000ff00ff0000, 0xff0000ff00ff00ff, 0xff0000ff00ffff00, 0x000000ff00ffffff, + 0x000000ffff000000, 0xff0000ffff0000ff, 0xff0000ffff00ff00, 0x000000ffff00ffff, + 0xff0000ffffff0000, 0x000000ffffff00ff, 0x000000ffffffff00, 0xff0000ffffffffff, + 0xff00ff0000000000, 0x0000ff00000000ff, 0x0000ff000000ff00, 0xff00ff000000ffff, + 0x0000ff0000ff0000, 0xff00ff0000ff00ff, 0xff00ff0000ffff00, 0x0000ff0000ffffff, + 0x0000ff00ff000000, 0xff00ff00ff0000ff, 0xff00ff00ff00ff00, 0x0000ff00ff00ffff, + 0xff00ff00ffff0000, 0x0000ff00ffff00ff, 0x0000ff00ffffff00, 0xff00ff00ffffffff, + 0x0000ffff00000000, 0xff00ffff000000ff, 0xff00ffff0000ff00, 0x0000ffff0000ffff, + 0xff00ffff00ff0000, 0x0000ffff00ff00ff, 0x0000ffff00ffff00, 0xff00ffff00ffffff, + 0xff00ffffff000000, 0x0000ffffff0000ff, 0x0000ffffff00ff00, 0xff00ffffff00ffff, + 0x0000ffffffff0000, 0xff00ffffffff00ff, 0xff00ffffffffff00, 0x0000ffffffffffff, + 0xffff000000000000, 0x00ff0000000000ff, 0x00ff00000000ff00, 0xffff00000000ffff, + 0x00ff000000ff0000, 0xffff000000ff00ff, 0xffff000000ffff00, 0x00ff000000ffffff, + 0x00ff0000ff000000, 0xffff0000ff0000ff, 0xffff0000ff00ff00, 0x00ff0000ff00ffff, + 0xffff0000ffff0000, 0x00ff0000ffff00ff, 0x00ff0000ffffff00, 0xffff0000ffffffff, + 0x00ff00ff00000000, 0xffff00ff000000ff, 0xffff00ff0000ff00, 0x00ff00ff0000ffff, + 0xffff00ff00ff0000, 0x00ff00ff00ff00ff, 0x00ff00ff00ffff00, 0xffff00ff00ffffff, + 0xffff00ffff000000, 0x00ff00ffff0000ff, 0x00ff00ffff00ff00, 0xffff00ffff00ffff, + 0x00ff00ffffff0000, 0xffff00ffffff00ff, 0xffff00ffffffff00, 0x00ff00ffffffffff, + 0x00ffff0000000000, 0xffffff00000000ff, 0xffffff000000ff00, 0x00ffff000000ffff, + 0xffffff0000ff0000, 0x00ffff0000ff00ff, 0x00ffff0000ffff00, 0xffffff0000ffffff, + 0xffffff00ff000000, 0x00ffff00ff0000ff, 0x00ffff00ff00ff00, 0xffffff00ff00ffff, + 0x00ffff00ffff0000, 0xffffff00ffff00ff, 0xffffff00ffffff00, 0x00ffff00ffffffff, + 0xffffffff00000000, 0x00ffffff000000ff, 0x00ffffff0000ff00, 0xffffffff0000ffff, + 0x00ffffff00ff0000, 0xffffffff00ff00ff, 0xffffffff00ffff00, 0x00ffffff00ffffff, + 0x00ffffffff000000, 0xffffffffff0000ff, 0xffffffffff00ff00, 0x00ffffffff00ffff, + 0xffffffffffff0000, 0x00ffffffffff00ff, 0x00ffffffffffff00, 0xffffffffffffffff, +GGML_TABLE_END() + + +GGML_TABLE_BEGIN(uint64_t, iq2xxs_grid, 256) + 0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, + 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x08080808082b0808, + 0x08080808082b082b, 0x08080808082b2b08, 0x08080808082b2b2b, 0x0808080819080819, + 0x0808080819081908, 0x0808080819190808, 0x0808080819192b08, 0x08080808192b0819, + 0x08080808192b1908, 0x080808082b080808, 0x080808082b08082b, 0x080808082b082b2b, + 0x080808082b2b082b, 0x0808081908080819, 0x0808081908081908, 0x0808081908190808, + 0x0808081908191919, 0x0808081919080808, 0x080808192b081908, 0x080808192b192b08, + 0x0808082b08080808, 0x0808082b0808082b, 0x0808082b082b082b, 0x0808082b2b08082b, + 0x0808190808080819, 0x0808190808081908, 0x0808190808190808, 0x08081908082b0819, + 0x08081908082b1908, 0x0808190819080808, 0x080819081908082b, 0x0808190819082b08, + 0x08081908192b0808, 0x080819082b080819, 0x080819082b081908, 0x080819082b190808, + 0x080819082b2b1908, 0x0808191908080808, 0x080819190808082b, 0x0808191908082b08, + 0x08081919082b0808, 0x080819191908192b, 0x08081919192b2b19, 0x080819192b080808, + 0x080819192b190819, 0x0808192b08082b19, 0x0808192b08190808, 0x0808192b19080808, + 0x0808192b2b081908, 0x0808192b2b2b1908, 0x08082b0808080808, 0x08082b0808081919, + 0x08082b0808082b08, 0x08082b0808191908, 0x08082b08082b2b08, 0x08082b0819080819, + 0x08082b0819081908, 0x08082b0819190808, 0x08082b081919082b, 0x08082b082b082b08, + 0x08082b1908081908, 0x08082b1919080808, 0x08082b2b0808082b, 0x08082b2b08191908, + 0x0819080808080819, 0x0819080808081908, 0x0819080808190808, 0x08190808082b0819, + 0x0819080819080808, 0x08190808192b0808, 0x081908082b081908, 0x081908082b190808, + 0x081908082b191919, 0x0819081908080808, 0x0819081908082b08, 0x08190819082b0808, + 0x0819081919190808, 0x0819081919192b2b, 0x081908192b080808, 0x0819082b082b1908, + 0x0819082b19081919, 0x0819190808080808, 0x0819190808082b08, 0x08191908082b0808, + 0x08191908082b1919, 0x0819190819082b19, 0x081919082b080808, 0x0819191908192b08, + 0x08191919192b082b, 0x0819192b08080808, 0x0819192b0819192b, 0x08192b0808080819, + 0x08192b0808081908, 0x08192b0808190808, 0x08192b0819080808, 0x08192b082b080819, + 0x08192b1908080808, 0x08192b1908081919, 0x08192b192b2b0808, 0x08192b2b19190819, + 0x082b080808080808, 0x082b08080808082b, 0x082b080808082b2b, 0x082b080819081908, + 0x082b0808192b0819, 0x082b08082b080808, 0x082b08082b08082b, 0x082b0819082b2b19, + 0x082b081919082b08, 0x082b082b08080808, 0x082b082b0808082b, 0x082b190808080819, + 0x082b190808081908, 0x082b190808190808, 0x082b190819080808, 0x082b19081919192b, + 0x082b191908080808, 0x082b191919080819, 0x082b1919192b1908, 0x082b192b2b190808, + 0x082b2b0808082b08, 0x082b2b08082b0808, 0x082b2b082b191908, 0x082b2b2b19081908, + 0x1908080808080819, 0x1908080808081908, 0x1908080808190808, 0x1908080808192b08, + 0x19080808082b0819, 0x19080808082b1908, 0x1908080819080808, 0x1908080819082b08, + 0x190808081919192b, 0x19080808192b0808, 0x190808082b080819, 0x190808082b081908, + 0x190808082b190808, 0x1908081908080808, 0x19080819082b0808, 0x19080819192b0819, + 0x190808192b080808, 0x190808192b081919, 0x1908082b08080819, 0x1908082b08190808, + 0x1908082b19082b08, 0x1908082b1919192b, 0x1908082b192b2b08, 0x1908190808080808, + 0x1908190808082b08, 0x19081908082b0808, 0x190819082b080808, 0x190819082b192b19, + 0x190819190819082b, 0x19081919082b1908, 0x1908192b08080808, 0x19082b0808080819, + 0x19082b0808081908, 0x19082b0808190808, 0x19082b0819080808, 0x19082b0819081919, + 0x19082b1908080808, 0x19082b1919192b08, 0x19082b19192b0819, 0x19082b192b08082b, + 0x19082b2b19081919, 0x19082b2b2b190808, 0x1919080808080808, 0x1919080808082b08, + 0x1919080808190819, 0x1919080808192b19, 0x19190808082b0808, 0x191908082b080808, + 0x191908082b082b08, 0x1919081908081908, 0x191908191908082b, 0x191908192b2b1908, + 0x1919082b2b190819, 0x191919082b190808, 0x191919082b19082b, 0x1919191908082b2b, + 0x1919192b08080819, 0x1919192b19191908, 0x19192b0808080808, 0x19192b0808190819, + 0x19192b0808192b19, 0x19192b08192b1908, 0x19192b1919080808, 0x19192b2b08082b08, + 0x192b080808081908, 0x192b080808190808, 0x192b080819080808, 0x192b0808192b2b08, + 0x192b081908080808, 0x192b081919191919, 0x192b082b08192b08, 0x192b082b192b0808, + 0x192b190808080808, 0x192b190808081919, 0x192b191908190808, 0x192b19190819082b, + 0x192b19192b081908, 0x192b2b081908082b, 0x2b08080808080808, 0x2b0808080808082b, + 0x2b08080808082b2b, 0x2b08080819080819, 0x2b0808082b08082b, 0x2b08081908081908, + 0x2b08081908192b08, 0x2b08081919080808, 0x2b08082b08190819, 0x2b08190808080819, + 0x2b08190808081908, 0x2b08190808190808, 0x2b08190808191919, 0x2b08190819080808, + 0x2b081908192b0808, 0x2b08191908080808, 0x2b0819191908192b, 0x2b0819192b191908, + 0x2b08192b08082b19, 0x2b08192b19080808, 0x2b08192b192b0808, 0x2b082b080808082b, + 0x2b082b1908081908, 0x2b082b2b08190819, 0x2b19080808081908, 0x2b19080808190808, + 0x2b190808082b1908, 0x2b19080819080808, 0x2b1908082b2b0819, 0x2b1908190819192b, + 0x2b1908192b080808, 0x2b19082b19081919, 0x2b19190808080808, 0x2b191908082b082b, + 0x2b19190819081908, 0x2b19191919190819, 0x2b192b082b080819, 0x2b192b19082b0808, + 0x2b2b08080808082b, 0x2b2b080819190808, 0x2b2b08082b081919, 0x2b2b081908082b19, + 0x2b2b082b08080808, 0x2b2b190808192b08, 0x2b2b2b0819190808, 0x2b2b2b1908081908, +GGML_TABLE_END() + +GGML_TABLE_BEGIN(uint64_t, iq2xs_grid, 512) + 0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, + 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x080808080819192b, + 0x0808080808192b19, 0x08080808082b0808, 0x08080808082b082b, 0x08080808082b1919, + 0x08080808082b2b08, 0x0808080819080819, 0x0808080819081908, 0x080808081908192b, + 0x0808080819082b19, 0x0808080819190808, 0x080808081919082b, 0x0808080819191919, + 0x0808080819192b08, 0x08080808192b0819, 0x08080808192b1908, 0x080808082b080808, + 0x080808082b08082b, 0x080808082b081919, 0x080808082b082b08, 0x080808082b190819, + 0x080808082b191908, 0x080808082b192b19, 0x080808082b2b0808, 0x0808081908080819, + 0x0808081908081908, 0x080808190808192b, 0x0808081908082b19, 0x0808081908190808, + 0x080808190819082b, 0x0808081908191919, 0x0808081908192b08, 0x0808081908192b2b, + 0x08080819082b0819, 0x08080819082b1908, 0x0808081919080808, 0x080808191908082b, + 0x0808081919081919, 0x0808081919082b08, 0x0808081919190819, 0x0808081919191908, + 0x08080819192b0808, 0x08080819192b2b08, 0x080808192b080819, 0x080808192b081908, + 0x080808192b190808, 0x0808082b08080808, 0x0808082b0808082b, 0x0808082b08081919, + 0x0808082b08082b08, 0x0808082b08190819, 0x0808082b08191908, 0x0808082b082b0808, + 0x0808082b19080819, 0x0808082b19081908, 0x0808082b19190808, 0x0808082b19191919, + 0x0808082b2b080808, 0x0808082b2b082b2b, 0x0808190808080819, 0x0808190808081908, + 0x080819080808192b, 0x0808190808082b19, 0x0808190808190808, 0x080819080819082b, + 0x0808190808191919, 0x0808190808192b08, 0x08081908082b0819, 0x08081908082b1908, + 0x0808190819080808, 0x080819081908082b, 0x0808190819081919, 0x0808190819082b08, + 0x0808190819190819, 0x0808190819191908, 0x080819081919192b, 0x08081908192b0808, + 0x080819082b080819, 0x080819082b081908, 0x080819082b190808, 0x0808191908080808, + 0x080819190808082b, 0x0808191908081919, 0x0808191908082b08, 0x0808191908190819, + 0x0808191908191908, 0x08081919082b0808, 0x0808191919080819, 0x0808191919081908, + 0x0808191919190808, 0x08081919192b0819, 0x080819192b080808, 0x0808192b08080819, + 0x0808192b08081908, 0x0808192b08190808, 0x0808192b082b192b, 0x0808192b19080808, + 0x0808192b1908082b, 0x0808192b2b081908, 0x08082b0808080808, 0x08082b080808082b, + 0x08082b0808081919, 0x08082b0808082b08, 0x08082b0808082b2b, 0x08082b0808190819, + 0x08082b0808191908, 0x08082b08082b0808, 0x08082b08082b1919, 0x08082b0819080819, + 0x08082b0819081908, 0x08082b0819190808, 0x08082b0819192b08, 0x08082b082b080808, + 0x08082b082b2b0808, 0x08082b082b2b2b2b, 0x08082b1908080819, 0x08082b1908081908, + 0x08082b1908190808, 0x08082b1919080808, 0x08082b192b080819, 0x08082b192b082b19, + 0x08082b2b08080808, 0x08082b2b082b0808, 0x08082b2b082b2b08, 0x08082b2b2b19192b, + 0x08082b2b2b2b0808, 0x0819080808080819, 0x0819080808081908, 0x081908080808192b, + 0x0819080808082b19, 0x0819080808190808, 0x081908080819082b, 0x0819080808191919, + 0x0819080808192b08, 0x08190808082b0819, 0x08190808082b1908, 0x0819080819080808, + 0x081908081908082b, 0x0819080819081919, 0x0819080819082b08, 0x0819080819190819, + 0x0819080819191908, 0x08190808192b0808, 0x08190808192b2b2b, 0x081908082b080819, + 0x081908082b081908, 0x081908082b190808, 0x0819081908080808, 0x081908190808082b, + 0x0819081908081919, 0x0819081908082b08, 0x0819081908190819, 0x0819081908191908, + 0x08190819082b0808, 0x0819081919080819, 0x0819081919081908, 0x0819081919190808, + 0x081908192b080808, 0x081908192b191908, 0x081908192b19192b, 0x0819082b08080819, + 0x0819082b08081908, 0x0819082b0808192b, 0x0819082b08190808, 0x0819082b19080808, + 0x0819082b192b0808, 0x0819190808080808, 0x081919080808082b, 0x0819190808081919, + 0x0819190808082b08, 0x0819190808190819, 0x0819190808191908, 0x08191908082b0808, + 0x0819190819080819, 0x0819190819081908, 0x0819190819082b19, 0x0819190819190808, + 0x08191908192b1908, 0x081919082b080808, 0x0819191908080819, 0x0819191908081908, + 0x0819191908190808, 0x0819191919080808, 0x0819192b08080808, 0x0819192b08191908, + 0x0819192b19082b19, 0x08192b0808080819, 0x08192b0808081908, 0x08192b0808190808, + 0x08192b080819082b, 0x08192b0819080808, 0x08192b0819191908, 0x08192b082b08192b, + 0x08192b1908080808, 0x08192b1908081919, 0x08192b19192b192b, 0x08192b2b19190819, + 0x08192b2b2b2b2b19, 0x082b080808080808, 0x082b08080808082b, 0x082b080808081919, + 0x082b080808082b08, 0x082b080808082b2b, 0x082b080808190819, 0x082b080808191908, + 0x082b0808082b0808, 0x082b080819080819, 0x082b080819081908, 0x082b080819190808, + 0x082b08082b080808, 0x082b08082b2b0808, 0x082b081908080819, 0x082b081908081908, + 0x082b081908190808, 0x082b081919080808, 0x082b081919082b08, 0x082b0819192b1919, + 0x082b082b08080808, 0x082b082b082b082b, 0x082b082b2b080808, 0x082b082b2b2b2b08, + 0x082b190808080819, 0x082b190808081908, 0x082b190808190808, 0x082b1908082b2b19, + 0x082b190819080808, 0x082b191908080808, 0x082b191919080819, 0x082b19191919082b, + 0x082b19192b192b19, 0x082b192b08080819, 0x082b192b08192b2b, 0x082b192b2b2b192b, + 0x082b2b0808080808, 0x082b2b0808082b08, 0x082b2b0808082b2b, 0x082b2b08082b0808, + 0x082b2b0819191919, 0x082b2b082b082b08, 0x082b2b082b2b082b, 0x082b2b19192b2b08, + 0x082b2b192b190808, 0x082b2b2b08082b08, 0x082b2b2b082b0808, 0x082b2b2b2b08082b, + 0x082b2b2b2b082b08, 0x082b2b2b2b082b2b, 0x1908080808080819, 0x1908080808081908, + 0x190808080808192b, 0x1908080808082b19, 0x1908080808190808, 0x190808080819082b, + 0x1908080808191919, 0x1908080808192b08, 0x19080808082b0819, 0x19080808082b1908, + 0x1908080819080808, 0x190808081908082b, 0x1908080819081919, 0x1908080819082b08, + 0x1908080819082b2b, 0x1908080819190819, 0x1908080819191908, 0x19080808192b0808, + 0x19080808192b1919, 0x190808082b080819, 0x190808082b081908, 0x190808082b190808, + 0x1908081908080808, 0x190808190808082b, 0x1908081908081919, 0x1908081908082b08, + 0x1908081908190819, 0x1908081908191908, 0x19080819082b0808, 0x1908081919080819, + 0x1908081919081908, 0x1908081919190808, 0x190808192b080808, 0x190808192b081919, + 0x190808192b2b082b, 0x1908082b08080819, 0x1908082b08081908, 0x1908082b08190808, + 0x1908082b0819082b, 0x1908082b082b2b19, 0x1908082b19080808, 0x1908190808080808, + 0x190819080808082b, 0x1908190808081919, 0x1908190808082b08, 0x1908190808190819, + 0x1908190808191908, 0x1908190808192b19, 0x19081908082b0808, 0x1908190819080819, + 0x1908190819081908, 0x1908190819190808, 0x190819082b080808, 0x190819082b191908, + 0x1908191908080819, 0x1908191908081908, 0x1908191908190808, 0x19081919082b1908, + 0x1908191919080808, 0x190819192b192b2b, 0x1908192b08080808, 0x1908192b08082b2b, + 0x1908192b19081908, 0x1908192b19190808, 0x19082b0808080819, 0x19082b0808081908, + 0x19082b0808190808, 0x19082b0819080808, 0x19082b0819081919, 0x19082b0819191908, + 0x19082b08192b082b, 0x19082b1908080808, 0x19082b1908190819, 0x19082b1919081908, + 0x19082b1919190808, 0x19082b19192b2b19, 0x19082b2b08081908, 0x1919080808080808, + 0x191908080808082b, 0x1919080808081919, 0x1919080808082b08, 0x1919080808190819, + 0x1919080808191908, 0x19190808082b0808, 0x19190808082b2b08, 0x1919080819080819, + 0x1919080819081908, 0x1919080819190808, 0x191908082b080808, 0x1919081908080819, + 0x1919081908081908, 0x1919081908190808, 0x1919081908191919, 0x1919081919080808, + 0x191908191908082b, 0x1919082b08080808, 0x1919082b19081908, 0x1919082b2b2b2b2b, + 0x1919190808080819, 0x1919190808081908, 0x1919190808190808, 0x19191908082b0819, + 0x1919190819080808, 0x19191908192b0808, 0x191919082b080819, 0x191919082b2b0819, + 0x1919191908080808, 0x1919191908082b08, 0x191919192b080808, 0x191919192b082b08, + 0x1919192b082b0819, 0x1919192b192b2b08, 0x1919192b2b2b0819, 0x19192b0808080808, + 0x19192b0808191908, 0x19192b0819080819, 0x19192b0819190808, 0x19192b082b192b19, + 0x19192b1908192b2b, 0x19192b1919080808, 0x19192b191908082b, 0x19192b2b2b081919, + 0x192b080808080819, 0x192b080808081908, 0x192b080808190808, 0x192b080819080808, + 0x192b080819191908, 0x192b0808192b082b, 0x192b08082b08192b, 0x192b08082b2b2b19, + 0x192b081908080808, 0x192b082b082b1908, 0x192b082b19082b2b, 0x192b082b2b19082b, + 0x192b190808080808, 0x192b19080819192b, 0x192b191908190808, 0x192b191919080808, + 0x192b191919081919, 0x192b19192b2b1908, 0x192b2b0808080819, 0x192b2b08192b2b2b, + 0x192b2b19082b1919, 0x192b2b2b0808192b, 0x192b2b2b19191908, 0x192b2b2b192b082b, + 0x2b08080808080808, 0x2b0808080808082b, 0x2b08080808081919, 0x2b08080808082b08, + 0x2b08080808190819, 0x2b08080808191908, 0x2b080808082b0808, 0x2b080808082b2b2b, + 0x2b08080819080819, 0x2b08080819081908, 0x2b08080819190808, 0x2b0808082b080808, + 0x2b0808082b08082b, 0x2b0808082b2b2b08, 0x2b0808082b2b2b2b, 0x2b08081908080819, + 0x2b08081908081908, 0x2b0808190808192b, 0x2b08081908190808, 0x2b08081919080808, + 0x2b08081919190819, 0x2b08081919192b19, 0x2b08082b08080808, 0x2b08082b082b0808, + 0x2b08082b2b080808, 0x2b08082b2b08082b, 0x2b08082b2b2b0808, 0x2b08082b2b2b2b08, + 0x2b08190808080819, 0x2b08190808081908, 0x2b08190808190808, 0x2b0819080819082b, + 0x2b08190808191919, 0x2b08190819080808, 0x2b081908192b0808, 0x2b0819082b082b19, + 0x2b08191908080808, 0x2b08191919081908, 0x2b0819192b2b1919, 0x2b08192b08192b08, + 0x2b08192b192b2b2b, 0x2b082b0808080808, 0x2b082b0808082b08, 0x2b082b08082b1919, + 0x2b082b0819192b2b, 0x2b082b082b080808, 0x2b082b082b08082b, 0x2b082b082b2b2b08, + 0x2b082b190808192b, 0x2b082b2b082b082b, 0x2b082b2b2b080808, 0x2b082b2b2b082b08, + 0x2b082b2b2b19192b, 0x2b082b2b2b2b2b08, 0x2b19080808080819, 0x2b19080808081908, + 0x2b19080808190808, 0x2b19080819080808, 0x2b1908081919192b, 0x2b1908082b081908, + 0x2b19081908080808, 0x2b190819082b082b, 0x2b190819192b1908, 0x2b19082b1919192b, + 0x2b19082b2b082b19, 0x2b19190808080808, 0x2b19190808081919, 0x2b19190819081908, + 0x2b19190819190808, 0x2b19190819192b08, 0x2b191919082b2b19, 0x2b1919192b190808, + 0x2b1919192b19082b, 0x2b19192b19080819, 0x2b192b0819190819, 0x2b192b082b2b192b, + 0x2b192b1919082b19, 0x2b192b2b08191919, 0x2b192b2b192b0808, 0x2b2b080808080808, + 0x2b2b08080808082b, 0x2b2b080808082b08, 0x2b2b080808082b2b, 0x2b2b0808082b0808, + 0x2b2b0808082b2b2b, 0x2b2b08082b2b0808, 0x2b2b081919190819, 0x2b2b081919192b19, + 0x2b2b08192b2b192b, 0x2b2b082b08080808, 0x2b2b082b0808082b, 0x2b2b082b08082b08, + 0x2b2b082b082b2b2b, 0x2b2b082b2b080808, 0x2b2b082b2b2b0808, 0x2b2b190819080808, + 0x2b2b19082b191919, 0x2b2b192b192b1919, 0x2b2b192b2b192b08, 0x2b2b2b0808082b2b, + 0x2b2b2b08082b0808, 0x2b2b2b08082b082b, 0x2b2b2b08082b2b08, 0x2b2b2b082b2b0808, + 0x2b2b2b082b2b2b08, 0x2b2b2b1908081908, 0x2b2b2b192b081908, 0x2b2b2b192b08192b, + 0x2b2b2b2b082b2b08, 0x2b2b2b2b082b2b2b, 0x2b2b2b2b2b190819, 0x2b2b2b2b2b2b2b2b, +GGML_TABLE_END() + +GGML_TABLE_BEGIN(uint64_t, iq2s_grid, 1024) + 0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, + 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x080808080819192b, + 0x0808080808192b19, 0x08080808082b0808, 0x08080808082b082b, 0x08080808082b1919, + 0x08080808082b2b08, 0x0808080819080819, 0x0808080819081908, 0x080808081908192b, + 0x0808080819082b19, 0x0808080819190808, 0x080808081919082b, 0x0808080819191919, + 0x0808080819192b08, 0x08080808192b0819, 0x08080808192b1908, 0x08080808192b192b, + 0x08080808192b2b19, 0x080808082b080808, 0x080808082b08082b, 0x080808082b081919, + 0x080808082b082b08, 0x080808082b190819, 0x080808082b191908, 0x080808082b2b0808, + 0x080808082b2b1919, 0x080808082b2b2b2b, 0x0808081908080819, 0x0808081908081908, + 0x080808190808192b, 0x0808081908082b19, 0x0808081908190808, 0x080808190819082b, + 0x0808081908191919, 0x0808081908192b08, 0x08080819082b0819, 0x08080819082b1908, + 0x0808081919080808, 0x080808191908082b, 0x0808081919081919, 0x0808081919082b08, + 0x0808081919190819, 0x0808081919191908, 0x080808191919192b, 0x0808081919192b19, + 0x08080819192b0808, 0x08080819192b1919, 0x08080819192b2b08, 0x080808192b080819, + 0x080808192b081908, 0x080808192b190808, 0x080808192b19082b, 0x080808192b191919, + 0x080808192b2b0819, 0x080808192b2b1908, 0x0808082b08080808, 0x0808082b0808082b, + 0x0808082b08081919, 0x0808082b08082b08, 0x0808082b08190819, 0x0808082b08191908, + 0x0808082b082b0808, 0x0808082b082b2b2b, 0x0808082b19080819, 0x0808082b19081908, + 0x0808082b1908192b, 0x0808082b19082b19, 0x0808082b19190808, 0x0808082b19191919, + 0x0808082b2b080808, 0x0808082b2b081919, 0x0808082b2b082b2b, 0x0808082b2b191908, + 0x0808082b2b2b082b, 0x0808190808080819, 0x0808190808081908, 0x080819080808192b, + 0x0808190808082b19, 0x0808190808190808, 0x080819080819082b, 0x0808190808191919, + 0x0808190808192b08, 0x08081908082b0819, 0x08081908082b1908, 0x08081908082b192b, + 0x08081908082b2b19, 0x0808190819080808, 0x080819081908082b, 0x0808190819081919, + 0x0808190819082b08, 0x0808190819082b2b, 0x0808190819190819, 0x0808190819191908, + 0x080819081919192b, 0x0808190819192b19, 0x08081908192b0808, 0x08081908192b082b, + 0x08081908192b1919, 0x080819082b080819, 0x080819082b081908, 0x080819082b08192b, + 0x080819082b082b19, 0x080819082b190808, 0x080819082b191919, 0x080819082b192b08, + 0x080819082b2b0819, 0x080819082b2b1908, 0x0808191908080808, 0x080819190808082b, + 0x0808191908081919, 0x0808191908082b08, 0x0808191908082b2b, 0x0808191908190819, + 0x0808191908191908, 0x080819190819192b, 0x0808191908192b19, 0x08081919082b0808, + 0x08081919082b1919, 0x08081919082b2b08, 0x0808191919080819, 0x0808191919081908, + 0x080819191908192b, 0x0808191919082b19, 0x0808191919190808, 0x080819191919082b, + 0x0808191919191919, 0x0808191919192b08, 0x08081919192b0819, 0x08081919192b1908, + 0x080819192b080808, 0x080819192b08082b, 0x080819192b081919, 0x080819192b082b08, + 0x080819192b190819, 0x080819192b191908, 0x080819192b2b0808, 0x0808192b08080819, + 0x0808192b08081908, 0x0808192b0808192b, 0x0808192b08082b19, 0x0808192b08190808, + 0x0808192b08191919, 0x0808192b19080808, 0x0808192b19081919, 0x0808192b19082b08, + 0x0808192b19190819, 0x0808192b19191908, 0x0808192b192b0808, 0x0808192b2b080819, + 0x0808192b2b081908, 0x0808192b2b190808, 0x08082b0808080808, 0x08082b080808082b, + 0x08082b0808081919, 0x08082b0808082b08, 0x08082b0808190819, 0x08082b0808191908, + 0x08082b080819192b, 0x08082b0808192b19, 0x08082b08082b0808, 0x08082b08082b1919, + 0x08082b08082b2b2b, 0x08082b0819080819, 0x08082b0819081908, 0x08082b081908192b, + 0x08082b0819082b19, 0x08082b0819190808, 0x08082b081919082b, 0x08082b0819191919, + 0x08082b0819192b08, 0x08082b08192b0819, 0x08082b08192b1908, 0x08082b082b080808, + 0x08082b082b081919, 0x08082b082b191908, 0x08082b082b2b2b2b, 0x08082b1908080819, + 0x08082b1908081908, 0x08082b1908190808, 0x08082b190819082b, 0x08082b1908191919, + 0x08082b1908192b08, 0x08082b19082b0819, 0x08082b1919080808, 0x08082b1919081919, + 0x08082b1919082b08, 0x08082b1919190819, 0x08082b1919191908, 0x08082b19192b0808, + 0x08082b192b080819, 0x08082b192b190808, 0x08082b2b08080808, 0x08082b2b08190819, + 0x08082b2b08191908, 0x08082b2b082b082b, 0x08082b2b082b2b08, 0x08082b2b082b2b2b, + 0x08082b2b19190808, 0x08082b2b2b192b19, 0x0819080808080819, 0x0819080808081908, + 0x081908080808192b, 0x0819080808082b19, 0x0819080808190808, 0x081908080819082b, + 0x0819080808191919, 0x0819080808192b08, 0x08190808082b0819, 0x08190808082b1908, + 0x08190808082b192b, 0x0819080819080808, 0x081908081908082b, 0x0819080819081919, + 0x0819080819082b08, 0x0819080819190819, 0x0819080819191908, 0x081908081919192b, + 0x0819080819192b19, 0x08190808192b0808, 0x08190808192b082b, 0x08190808192b1919, + 0x08190808192b2b08, 0x081908082b080819, 0x081908082b081908, 0x081908082b08192b, + 0x081908082b190808, 0x081908082b191919, 0x081908082b192b08, 0x081908082b2b0819, + 0x081908082b2b1908, 0x0819081908080808, 0x081908190808082b, 0x0819081908081919, + 0x0819081908082b08, 0x0819081908082b2b, 0x0819081908190819, 0x0819081908191908, + 0x081908190819192b, 0x0819081908192b19, 0x08190819082b0808, 0x08190819082b082b, + 0x08190819082b1919, 0x08190819082b2b08, 0x0819081919080819, 0x0819081919081908, + 0x081908191908192b, 0x0819081919082b19, 0x0819081919190808, 0x081908191919082b, + 0x0819081919191919, 0x0819081919192b08, 0x08190819192b0819, 0x08190819192b1908, + 0x081908192b080808, 0x081908192b08082b, 0x081908192b081919, 0x081908192b082b08, + 0x081908192b190819, 0x081908192b191908, 0x0819082b08080819, 0x0819082b08081908, + 0x0819082b08082b19, 0x0819082b08190808, 0x0819082b08191919, 0x0819082b082b0819, + 0x0819082b082b1908, 0x0819082b19080808, 0x0819082b19081919, 0x0819082b19190819, + 0x0819082b19191908, 0x0819082b2b080819, 0x0819082b2b081908, 0x0819082b2b190808, + 0x0819190808080808, 0x081919080808082b, 0x0819190808081919, 0x0819190808082b08, + 0x0819190808190819, 0x0819190808191908, 0x081919080819192b, 0x0819190808192b19, + 0x08191908082b0808, 0x08191908082b1919, 0x08191908082b2b08, 0x0819190819080819, + 0x0819190819081908, 0x081919081908192b, 0x0819190819082b19, 0x0819190819190808, + 0x081919081919082b, 0x0819190819191919, 0x0819190819192b08, 0x08191908192b0819, + 0x08191908192b1908, 0x081919082b080808, 0x081919082b08082b, 0x081919082b081919, + 0x081919082b082b08, 0x081919082b190819, 0x081919082b191908, 0x081919082b2b0808, + 0x0819191908080819, 0x0819191908081908, 0x081919190808192b, 0x0819191908082b19, + 0x0819191908190808, 0x081919190819082b, 0x0819191908191919, 0x0819191908192b08, + 0x08191919082b0819, 0x08191919082b1908, 0x0819191919080808, 0x081919191908082b, + 0x0819191919081919, 0x0819191919082b08, 0x0819191919190819, 0x0819191919191908, + 0x08191919192b0808, 0x081919192b080819, 0x081919192b081908, 0x081919192b190808, + 0x0819192b08080808, 0x0819192b08081919, 0x0819192b08082b08, 0x0819192b08190819, + 0x0819192b08191908, 0x0819192b082b0808, 0x0819192b19080819, 0x0819192b19081908, + 0x0819192b19190808, 0x0819192b2b080808, 0x0819192b2b2b2b2b, 0x08192b0808080819, + 0x08192b0808081908, 0x08192b080808192b, 0x08192b0808082b19, 0x08192b0808190808, + 0x08192b0808191919, 0x08192b0808192b08, 0x08192b08082b0819, 0x08192b0819080808, + 0x08192b081908082b, 0x08192b0819081919, 0x08192b0819082b08, 0x08192b0819190819, + 0x08192b0819191908, 0x08192b08192b0808, 0x08192b082b080819, 0x08192b082b081908, + 0x08192b1908080808, 0x08192b190808082b, 0x08192b1908081919, 0x08192b1908082b08, + 0x08192b1908190819, 0x08192b1908191908, 0x08192b19082b0808, 0x08192b1919080819, + 0x08192b1919081908, 0x08192b1919190808, 0x08192b19192b2b19, 0x08192b192b2b082b, + 0x08192b2b08081908, 0x08192b2b08190808, 0x08192b2b19080808, 0x08192b2b1919192b, + 0x082b080808080808, 0x082b08080808082b, 0x082b080808081919, 0x082b080808082b08, + 0x082b080808190819, 0x082b080808191908, 0x082b08080819192b, 0x082b080808192b19, + 0x082b0808082b0808, 0x082b0808082b1919, 0x082b0808082b2b2b, 0x082b080819080819, + 0x082b080819081908, 0x082b080819190808, 0x082b08081919082b, 0x082b080819191919, + 0x082b0808192b1908, 0x082b08082b080808, 0x082b08082b082b2b, 0x082b08082b191908, + 0x082b08082b2b2b2b, 0x082b081908080819, 0x082b081908081908, 0x082b081908190808, + 0x082b08190819082b, 0x082b081908191919, 0x082b0819082b0819, 0x082b081919080808, + 0x082b08191908082b, 0x082b081919081919, 0x082b081919190819, 0x082b081919191908, + 0x082b0819192b0808, 0x082b08192b080819, 0x082b08192b081908, 0x082b08192b190808, + 0x082b082b08080808, 0x082b082b08082b2b, 0x082b082b082b082b, 0x082b082b082b2b08, + 0x082b082b082b2b2b, 0x082b082b19081908, 0x082b082b19190808, 0x082b082b2b082b08, + 0x082b082b2b082b2b, 0x082b082b2b2b2b08, 0x082b190808080819, 0x082b190808081908, + 0x082b19080808192b, 0x082b190808082b19, 0x082b190808190808, 0x082b190808191919, + 0x082b190808192b08, 0x082b1908082b0819, 0x082b1908082b1908, 0x082b190819080808, + 0x082b19081908082b, 0x082b190819081919, 0x082b190819082b08, 0x082b190819190819, + 0x082b190819191908, 0x082b1908192b0808, 0x082b19082b080819, 0x082b19082b081908, + 0x082b19082b190808, 0x082b191908080808, 0x082b191908081919, 0x082b191908082b08, + 0x082b191908190819, 0x082b191908191908, 0x082b1919082b0808, 0x082b191919080819, + 0x082b191919081908, 0x082b191919190808, 0x082b1919192b192b, 0x082b19192b080808, + 0x082b192b08080819, 0x082b192b08081908, 0x082b192b08190808, 0x082b192b19080808, + 0x082b192b19192b19, 0x082b2b0808080808, 0x082b2b0808081919, 0x082b2b0808190819, + 0x082b2b0808191908, 0x082b2b0819080819, 0x082b2b0819081908, 0x082b2b0819190808, + 0x082b2b082b082b2b, 0x082b2b082b2b2b2b, 0x082b2b1908080819, 0x082b2b1908081908, + 0x082b2b1908190808, 0x082b2b192b191919, 0x082b2b2b08082b2b, 0x082b2b2b082b082b, + 0x082b2b2b192b1908, 0x082b2b2b2b082b08, 0x082b2b2b2b082b2b, 0x1908080808080819, + 0x1908080808081908, 0x190808080808192b, 0x1908080808082b19, 0x1908080808190808, + 0x190808080819082b, 0x1908080808191919, 0x1908080808192b08, 0x1908080808192b2b, + 0x19080808082b0819, 0x19080808082b1908, 0x19080808082b192b, 0x1908080819080808, + 0x190808081908082b, 0x1908080819081919, 0x1908080819082b08, 0x1908080819082b2b, + 0x1908080819190819, 0x1908080819191908, 0x190808081919192b, 0x1908080819192b19, + 0x19080808192b0808, 0x19080808192b082b, 0x19080808192b1919, 0x190808082b080819, + 0x190808082b081908, 0x190808082b190808, 0x190808082b191919, 0x190808082b192b08, + 0x190808082b2b0819, 0x190808082b2b1908, 0x1908081908080808, 0x190808190808082b, + 0x1908081908081919, 0x1908081908082b08, 0x1908081908190819, 0x1908081908191908, + 0x190808190819192b, 0x1908081908192b19, 0x19080819082b0808, 0x19080819082b082b, + 0x19080819082b1919, 0x1908081919080819, 0x1908081919081908, 0x190808191908192b, + 0x1908081919082b19, 0x1908081919190808, 0x190808191919082b, 0x1908081919191919, + 0x1908081919192b08, 0x19080819192b0819, 0x19080819192b1908, 0x190808192b080808, + 0x190808192b08082b, 0x190808192b081919, 0x190808192b082b08, 0x190808192b190819, + 0x190808192b191908, 0x190808192b2b0808, 0x1908082b08080819, 0x1908082b08081908, + 0x1908082b08190808, 0x1908082b0819082b, 0x1908082b08191919, 0x1908082b08192b08, + 0x1908082b082b1908, 0x1908082b19080808, 0x1908082b19081919, 0x1908082b19082b08, + 0x1908082b19190819, 0x1908082b19191908, 0x1908082b192b0808, 0x1908082b2b080819, + 0x1908082b2b081908, 0x1908190808080808, 0x190819080808082b, 0x1908190808081919, + 0x1908190808082b08, 0x1908190808082b2b, 0x1908190808190819, 0x1908190808191908, + 0x190819080819192b, 0x1908190808192b19, 0x19081908082b0808, 0x19081908082b082b, + 0x19081908082b1919, 0x19081908082b2b08, 0x1908190819080819, 0x1908190819081908, + 0x190819081908192b, 0x1908190819082b19, 0x1908190819190808, 0x190819081919082b, + 0x1908190819191919, 0x1908190819192b08, 0x19081908192b0819, 0x19081908192b1908, + 0x190819082b080808, 0x190819082b08082b, 0x190819082b081919, 0x190819082b082b08, + 0x190819082b190819, 0x190819082b191908, 0x190819082b2b0808, 0x1908191908080819, + 0x1908191908081908, 0x190819190808192b, 0x1908191908082b19, 0x1908191908190808, + 0x190819190819082b, 0x1908191908191919, 0x1908191908192b08, 0x19081919082b0819, + 0x19081919082b1908, 0x1908191919080808, 0x190819191908082b, 0x1908191919081919, + 0x1908191919082b08, 0x1908191919190819, 0x1908191919191908, 0x19081919192b0808, + 0x19081919192b2b2b, 0x190819192b080819, 0x190819192b081908, 0x190819192b190808, + 0x1908192b08080808, 0x1908192b0808082b, 0x1908192b08081919, 0x1908192b08082b08, + 0x1908192b08190819, 0x1908192b08191908, 0x1908192b082b0808, 0x1908192b19080819, + 0x1908192b19081908, 0x1908192b19190808, 0x1908192b2b080808, 0x1908192b2b2b1919, + 0x19082b0808080819, 0x19082b0808081908, 0x19082b0808082b19, 0x19082b0808190808, + 0x19082b080819082b, 0x19082b0808191919, 0x19082b0808192b08, 0x19082b08082b0819, + 0x19082b08082b1908, 0x19082b0819080808, 0x19082b081908082b, 0x19082b0819081919, + 0x19082b0819082b08, 0x19082b0819190819, 0x19082b0819191908, 0x19082b08192b0808, + 0x19082b082b081908, 0x19082b082b190808, 0x19082b1908080808, 0x19082b190808082b, + 0x19082b1908081919, 0x19082b1908082b08, 0x19082b1908190819, 0x19082b1908191908, + 0x19082b19082b0808, 0x19082b1919080819, 0x19082b1919081908, 0x19082b1919190808, + 0x19082b192b080808, 0x19082b192b19192b, 0x19082b2b08080819, 0x19082b2b08081908, + 0x19082b2b08190808, 0x19082b2b19080808, 0x1919080808080808, 0x191908080808082b, + 0x1919080808081919, 0x1919080808082b08, 0x1919080808190819, 0x1919080808191908, + 0x191908080819192b, 0x1919080808192b19, 0x19190808082b0808, 0x19190808082b082b, + 0x19190808082b1919, 0x19190808082b2b08, 0x1919080819080819, 0x1919080819081908, + 0x191908081908192b, 0x1919080819082b19, 0x1919080819190808, 0x191908081919082b, + 0x1919080819191919, 0x1919080819192b08, 0x19190808192b0819, 0x19190808192b1908, + 0x191908082b080808, 0x191908082b08082b, 0x191908082b081919, 0x191908082b082b08, + 0x191908082b190819, 0x191908082b191908, 0x1919081908080819, 0x1919081908081908, + 0x191908190808192b, 0x1919081908082b19, 0x1919081908190808, 0x191908190819082b, + 0x1919081908191919, 0x1919081908192b08, 0x19190819082b0819, 0x19190819082b1908, + 0x1919081919080808, 0x191908191908082b, 0x1919081919081919, 0x1919081919082b08, + 0x1919081919190819, 0x1919081919191908, 0x19190819192b0808, 0x191908192b080819, + 0x191908192b081908, 0x191908192b190808, 0x1919082b08080808, 0x1919082b08081919, + 0x1919082b08082b08, 0x1919082b08190819, 0x1919082b08191908, 0x1919082b082b0808, + 0x1919082b19080819, 0x1919082b19081908, 0x1919082b19190808, 0x1919082b192b2b19, + 0x1919082b2b080808, 0x1919190808080819, 0x1919190808081908, 0x191919080808192b, + 0x1919190808082b19, 0x1919190808190808, 0x191919080819082b, 0x1919190808191919, + 0x1919190808192b08, 0x19191908082b0819, 0x19191908082b1908, 0x1919190819080808, + 0x191919081908082b, 0x1919190819081919, 0x1919190819082b08, 0x1919190819190819, + 0x1919190819191908, 0x19191908192b0808, 0x191919082b080819, 0x191919082b081908, + 0x191919082b190808, 0x1919191908080808, 0x191919190808082b, 0x1919191908081919, + 0x1919191908082b08, 0x1919191908190819, 0x1919191908191908, 0x19191919082b0808, + 0x1919191919080819, 0x1919191919081908, 0x1919191919190808, 0x191919192b080808, + 0x1919192b08080819, 0x1919192b08081908, 0x1919192b08190808, 0x1919192b082b192b, + 0x1919192b19080808, 0x19192b0808080808, 0x19192b080808082b, 0x19192b0808081919, + 0x19192b0808082b08, 0x19192b0808190819, 0x19192b0808191908, 0x19192b08082b0808, + 0x19192b0819080819, 0x19192b0819081908, 0x19192b0819190808, 0x19192b0819192b2b, + 0x19192b082b080808, 0x19192b1908080819, 0x19192b1908081908, 0x19192b1908190808, + 0x19192b1919080808, 0x19192b2b08080808, 0x19192b2b08192b19, 0x19192b2b2b081919, + 0x19192b2b2b2b2b08, 0x192b080808080819, 0x192b080808081908, 0x192b08080808192b, + 0x192b080808190808, 0x192b08080819082b, 0x192b080808191919, 0x192b080808192b08, + 0x192b0808082b0819, 0x192b0808082b1908, 0x192b080819080808, 0x192b080819081919, + 0x192b080819082b08, 0x192b080819190819, 0x192b080819191908, 0x192b0808192b0808, + 0x192b08082b081908, 0x192b08082b190808, 0x192b081908080808, 0x192b08190808082b, + 0x192b081908081919, 0x192b081908082b08, 0x192b081908190819, 0x192b081908191908, + 0x192b0819082b0808, 0x192b081919080819, 0x192b081919081908, 0x192b081919190808, + 0x192b08192b080808, 0x192b08192b192b19, 0x192b082b08081908, 0x192b082b08190808, + 0x192b082b19080808, 0x192b082b1919192b, 0x192b082b2b2b0819, 0x192b190808080808, + 0x192b190808081919, 0x192b190808082b08, 0x192b190808190819, 0x192b190808191908, + 0x192b1908082b0808, 0x192b190819080819, 0x192b190819081908, 0x192b190819190808, + 0x192b19082b080808, 0x192b191908080819, 0x192b191908081908, 0x192b191908190808, + 0x192b191919080808, 0x192b191919082b2b, 0x192b1919192b2b08, 0x192b19192b19082b, + 0x192b192b08080808, 0x192b192b2b191908, 0x192b2b0808080819, 0x192b2b0808081908, + 0x192b2b0808190808, 0x192b2b08192b1919, 0x192b2b082b192b08, 0x192b2b1908080808, + 0x192b2b19082b2b2b, 0x192b2b2b1908082b, 0x192b2b2b2b2b0819, 0x2b08080808080808, + 0x2b0808080808082b, 0x2b08080808081919, 0x2b08080808082b08, 0x2b08080808190819, + 0x2b08080808191908, 0x2b08080808192b19, 0x2b080808082b0808, 0x2b080808082b1919, + 0x2b08080819080819, 0x2b08080819081908, 0x2b08080819190808, 0x2b0808081919082b, + 0x2b08080819191919, 0x2b08080819192b08, 0x2b080808192b0819, 0x2b0808082b080808, + 0x2b0808082b081919, 0x2b0808082b190819, 0x2b0808082b191908, 0x2b08081908080819, + 0x2b08081908081908, 0x2b08081908082b19, 0x2b08081908190808, 0x2b0808190819082b, + 0x2b08081908191919, 0x2b08081908192b08, 0x2b080819082b0819, 0x2b080819082b1908, + 0x2b08081919080808, 0x2b0808191908082b, 0x2b08081919081919, 0x2b08081919082b08, + 0x2b08081919190819, 0x2b08081919191908, 0x2b0808192b080819, 0x2b0808192b081908, + 0x2b0808192b190808, 0x2b0808192b2b2b19, 0x2b08082b08080808, 0x2b08082b08081919, + 0x2b08082b08082b2b, 0x2b08082b08190819, 0x2b08082b08191908, 0x2b08082b19080819, + 0x2b08082b19081908, 0x2b08082b19190808, 0x2b08190808080819, 0x2b08190808081908, + 0x2b0819080808192b, 0x2b08190808082b19, 0x2b08190808190808, 0x2b0819080819082b, + 0x2b08190808191919, 0x2b08190808192b08, 0x2b081908082b0819, 0x2b08190819080808, + 0x2b0819081908082b, 0x2b08190819081919, 0x2b08190819082b08, 0x2b08190819190819, + 0x2b08190819191908, 0x2b081908192b0808, 0x2b0819082b080819, 0x2b0819082b081908, + 0x2b0819082b190808, 0x2b08191908080808, 0x2b0819190808082b, 0x2b08191908081919, + 0x2b08191908082b08, 0x2b08191908190819, 0x2b08191908191908, 0x2b081919082b0808, + 0x2b08191919080819, 0x2b08191919081908, 0x2b08191919190808, 0x2b0819192b080808, + 0x2b0819192b082b2b, 0x2b08192b08080819, 0x2b08192b08081908, 0x2b08192b08190808, + 0x2b08192b082b2b19, 0x2b08192b19080808, 0x2b082b0808080808, 0x2b082b0808081919, + 0x2b082b0808190819, 0x2b082b0808191908, 0x2b082b0819080819, 0x2b082b0819081908, + 0x2b082b0819190808, 0x2b082b082b2b082b, 0x2b082b1908080819, 0x2b082b1908081908, + 0x2b082b1919080808, 0x2b082b19192b1919, 0x2b082b2b082b082b, 0x2b082b2b19192b08, + 0x2b082b2b19192b2b, 0x2b082b2b2b08082b, 0x2b082b2b2b2b082b, 0x2b19080808080819, + 0x2b19080808081908, 0x2b19080808082b19, 0x2b19080808190808, 0x2b1908080819082b, + 0x2b19080808191919, 0x2b19080808192b08, 0x2b190808082b1908, 0x2b19080819080808, + 0x2b1908081908082b, 0x2b19080819081919, 0x2b19080819082b08, 0x2b19080819190819, + 0x2b19080819191908, 0x2b190808192b0808, 0x2b1908082b080819, 0x2b1908082b081908, + 0x2b1908082b190808, 0x2b19081908080808, 0x2b19081908081919, 0x2b19081908190819, + 0x2b19081908191908, 0x2b19081919080819, 0x2b19081919081908, 0x2b19081919190808, + 0x2b19081919192b2b, 0x2b19082b08080819, 0x2b19082b08081908, 0x2b19082b08190808, + 0x2b19082b19080808, 0x2b19082b2b2b192b, 0x2b19190808080808, 0x2b1919080808082b, + 0x2b19190808081919, 0x2b19190808082b08, 0x2b19190808190819, 0x2b19190808191908, + 0x2b191908082b0808, 0x2b19190819080819, 0x2b19190819081908, 0x2b19190819190808, + 0x2b1919082b080808, 0x2b1919082b19192b, 0x2b19191908080819, 0x2b19191908081908, + 0x2b19191908190808, 0x2b19191919080808, 0x2b1919192b192b08, 0x2b1919192b2b0819, + 0x2b19192b08080808, 0x2b19192b1908192b, 0x2b19192b192b1908, 0x2b192b0808080819, + 0x2b192b0808081908, 0x2b192b0808190808, 0x2b192b08082b192b, 0x2b192b0819080808, + 0x2b192b082b2b2b19, 0x2b192b1908080808, 0x2b192b1919082b19, 0x2b192b191919082b, + 0x2b192b2b2b190808, 0x2b2b080808080808, 0x2b2b080808081919, 0x2b2b080808082b2b, + 0x2b2b080808191908, 0x2b2b0808082b082b, 0x2b2b0808082b2b2b, 0x2b2b080819080819, + 0x2b2b080819081908, 0x2b2b080819190808, 0x2b2b08082b2b082b, 0x2b2b08082b2b2b2b, + 0x2b2b081919080808, 0x2b2b0819192b1919, 0x2b2b082b0808082b, 0x2b2b082b08082b2b, + 0x2b2b082b082b082b, 0x2b2b082b082b2b08, 0x2b2b082b082b2b2b, 0x2b2b082b2b08082b, + 0x2b2b082b2b082b08, 0x2b2b082b2b082b2b, 0x2b2b082b2b2b2b08, 0x2b2b190808080819, + 0x2b2b190808081908, 0x2b2b190808190808, 0x2b2b190819080808, 0x2b2b19082b082b19, + 0x2b2b19082b2b1908, 0x2b2b191908080808, 0x2b2b191908192b19, 0x2b2b192b19190819, + 0x2b2b2b0808082b2b, 0x2b2b2b08082b2b08, 0x2b2b2b082b2b082b, 0x2b2b2b1919191908, + 0x2b2b2b192b08192b, 0x2b2b2b2b08082b08, 0x2b2b2b2b08082b2b, 0x2b2b2b2b082b0808, + 0x2b2b2b2b082b082b, 0x2b2b2b2b082b2b08, 0x2b2b2b2b2b082b08, 0x2b2b2b2b2b2b2b2b, +GGML_TABLE_END() + +GGML_TABLE_BEGIN(uint32_t, iq3xxs_grid, 256) + 0x04040404, 0x04040414, 0x04040424, 0x04040c0c, 0x04040c1c, 0x04040c3e, 0x04041404, 0x04041414, + 0x04041c0c, 0x04042414, 0x04043e1c, 0x04043e2c, 0x040c040c, 0x040c041c, 0x040c0c04, 0x040c0c14, + 0x040c140c, 0x040c142c, 0x040c1c04, 0x040c1c14, 0x040c240c, 0x040c2c24, 0x040c3e04, 0x04140404, + 0x04140414, 0x04140424, 0x04140c0c, 0x04141404, 0x04141414, 0x04141c0c, 0x04141c1c, 0x04141c3e, + 0x04142c0c, 0x04142c3e, 0x04143e2c, 0x041c040c, 0x041c043e, 0x041c0c04, 0x041c0c14, 0x041c142c, + 0x041c3e04, 0x04240c1c, 0x04241c3e, 0x04242424, 0x04242c3e, 0x04243e1c, 0x04243e2c, 0x042c040c, + 0x042c043e, 0x042c1c14, 0x042c2c14, 0x04341c2c, 0x04343424, 0x043e0c04, 0x043e0c24, 0x043e0c34, + 0x043e241c, 0x043e340c, 0x0c04040c, 0x0c04041c, 0x0c040c04, 0x0c040c14, 0x0c04140c, 0x0c04141c, + 0x0c041c04, 0x0c041c14, 0x0c041c24, 0x0c04243e, 0x0c042c04, 0x0c0c0404, 0x0c0c0414, 0x0c0c0c0c, + 0x0c0c1404, 0x0c0c1414, 0x0c14040c, 0x0c14041c, 0x0c140c04, 0x0c140c14, 0x0c14140c, 0x0c141c04, + 0x0c143e14, 0x0c1c0404, 0x0c1c0414, 0x0c1c1404, 0x0c1c1c0c, 0x0c1c2434, 0x0c1c3434, 0x0c24040c, + 0x0c24042c, 0x0c242c04, 0x0c2c1404, 0x0c2c1424, 0x0c2c2434, 0x0c2c3e0c, 0x0c34042c, 0x0c3e1414, + 0x0c3e2404, 0x14040404, 0x14040414, 0x14040c0c, 0x14040c1c, 0x14041404, 0x14041414, 0x14041434, + 0x14041c0c, 0x14042414, 0x140c040c, 0x140c041c, 0x140c042c, 0x140c0c04, 0x140c0c14, 0x140c140c, + 0x140c1c04, 0x140c341c, 0x140c343e, 0x140c3e04, 0x14140404, 0x14140414, 0x14140c0c, 0x14140c3e, + 0x14141404, 0x14141414, 0x14141c3e, 0x14142404, 0x14142c2c, 0x141c040c, 0x141c0c04, 0x141c0c24, + 0x141c3e04, 0x141c3e24, 0x14241c2c, 0x14242c1c, 0x142c041c, 0x142c143e, 0x142c240c, 0x142c3e24, + 0x143e040c, 0x143e041c, 0x143e0c34, 0x143e242c, 0x1c04040c, 0x1c040c04, 0x1c040c14, 0x1c04140c, + 0x1c04141c, 0x1c042c04, 0x1c04342c, 0x1c043e14, 0x1c0c0404, 0x1c0c0414, 0x1c0c1404, 0x1c0c1c0c, + 0x1c0c2424, 0x1c0c2434, 0x1c14040c, 0x1c14041c, 0x1c140c04, 0x1c14142c, 0x1c142c14, 0x1c143e14, + 0x1c1c0c0c, 0x1c1c1c1c, 0x1c241c04, 0x1c24243e, 0x1c243e14, 0x1c2c0404, 0x1c2c0434, 0x1c2c1414, + 0x1c2c2c2c, 0x1c340c24, 0x1c341c34, 0x1c34341c, 0x1c3e1c1c, 0x1c3e3404, 0x24040424, 0x24040c3e, + 0x24041c2c, 0x24041c3e, 0x24042c1c, 0x24042c3e, 0x240c3e24, 0x24141404, 0x24141c3e, 0x24142404, + 0x24143404, 0x24143434, 0x241c043e, 0x241c242c, 0x24240424, 0x24242c0c, 0x24243424, 0x242c142c, + 0x242c241c, 0x242c3e04, 0x243e042c, 0x243e0c04, 0x243e0c14, 0x243e1c04, 0x2c040c14, 0x2c04240c, + 0x2c043e04, 0x2c0c0404, 0x2c0c0434, 0x2c0c1434, 0x2c0c2c2c, 0x2c140c24, 0x2c141c14, 0x2c143e14, + 0x2c1c0414, 0x2c1c2c1c, 0x2c240c04, 0x2c24141c, 0x2c24143e, 0x2c243e14, 0x2c2c0414, 0x2c2c1c0c, + 0x2c342c04, 0x2c3e1424, 0x2c3e2414, 0x34041424, 0x34042424, 0x34042434, 0x34043424, 0x340c140c, + 0x340c340c, 0x34140c3e, 0x34143424, 0x341c1c04, 0x341c1c34, 0x34242424, 0x342c042c, 0x342c2c14, + 0x34341c1c, 0x343e041c, 0x343e140c, 0x3e04041c, 0x3e04042c, 0x3e04043e, 0x3e040c04, 0x3e041c14, + 0x3e042c14, 0x3e0c1434, 0x3e0c2404, 0x3e140c14, 0x3e14242c, 0x3e142c14, 0x3e1c0404, 0x3e1c0c2c, + 0x3e1c1c1c, 0x3e1c3404, 0x3e24140c, 0x3e24240c, 0x3e2c0404, 0x3e2c0414, 0x3e2c1424, 0x3e341c04, +GGML_TABLE_END() + +GGML_TABLE_BEGIN(uint32_t, iq3s_grid, 512) + 0x01010101, 0x01010103, 0x01010105, 0x0101010b, 0x0101010f, 0x01010301, 0x01010303, 0x01010305, + 0x01010309, 0x0101030d, 0x01010501, 0x01010503, 0x0101050b, 0x01010707, 0x01010901, 0x01010905, + 0x0101090b, 0x0101090f, 0x01010b03, 0x01010b07, 0x01010d01, 0x01010d05, 0x01010f03, 0x01010f09, + 0x01010f0f, 0x01030101, 0x01030103, 0x01030105, 0x01030109, 0x01030301, 0x01030303, 0x0103030b, + 0x01030501, 0x01030507, 0x0103050f, 0x01030703, 0x0103070b, 0x01030909, 0x01030d03, 0x01030d0b, + 0x01030f05, 0x01050101, 0x01050103, 0x0105010b, 0x0105010f, 0x01050301, 0x01050307, 0x0105030d, + 0x01050503, 0x0105050b, 0x01050701, 0x01050709, 0x01050905, 0x0105090b, 0x0105090f, 0x01050b03, + 0x01050b07, 0x01050f01, 0x01050f07, 0x01070107, 0x01070303, 0x0107030b, 0x01070501, 0x01070505, + 0x01070703, 0x01070707, 0x0107070d, 0x01070909, 0x01070b01, 0x01070b05, 0x01070d0f, 0x01070f03, + 0x01070f0b, 0x01090101, 0x01090307, 0x0109030f, 0x01090503, 0x01090509, 0x01090705, 0x01090901, + 0x01090907, 0x01090b03, 0x01090f01, 0x010b0105, 0x010b0109, 0x010b0501, 0x010b0505, 0x010b050d, + 0x010b0707, 0x010b0903, 0x010b090b, 0x010b090f, 0x010b0d0d, 0x010b0f07, 0x010d010d, 0x010d0303, + 0x010d0307, 0x010d0703, 0x010d0b05, 0x010d0f03, 0x010f0101, 0x010f0105, 0x010f0109, 0x010f0501, + 0x010f0505, 0x010f050d, 0x010f0707, 0x010f0b01, 0x010f0b09, 0x03010101, 0x03010103, 0x03010105, + 0x03010109, 0x03010301, 0x03010303, 0x03010307, 0x0301030b, 0x0301030f, 0x03010501, 0x03010505, + 0x03010703, 0x03010709, 0x0301070d, 0x03010b09, 0x03010b0d, 0x03010d03, 0x03010f05, 0x03030101, + 0x03030103, 0x03030107, 0x0303010d, 0x03030301, 0x03030309, 0x03030503, 0x03030701, 0x03030707, + 0x03030903, 0x03030b01, 0x03030b05, 0x03030f01, 0x03030f0d, 0x03050101, 0x03050305, 0x0305030b, + 0x0305030f, 0x03050501, 0x03050509, 0x03050705, 0x03050901, 0x03050907, 0x03050b0b, 0x03050d01, + 0x03050f05, 0x03070103, 0x03070109, 0x0307010f, 0x03070301, 0x03070307, 0x03070503, 0x0307050f, + 0x03070701, 0x03070709, 0x03070903, 0x03070d05, 0x03070f01, 0x03090107, 0x0309010b, 0x03090305, + 0x03090309, 0x03090703, 0x03090707, 0x03090905, 0x0309090d, 0x03090b01, 0x03090b09, 0x030b0103, + 0x030b0301, 0x030b0307, 0x030b0503, 0x030b0701, 0x030b0705, 0x030b0b03, 0x030d0501, 0x030d0509, + 0x030d050f, 0x030d0909, 0x030d090d, 0x030f0103, 0x030f0107, 0x030f0301, 0x030f0305, 0x030f0503, + 0x030f070b, 0x030f0903, 0x030f0d05, 0x030f0f01, 0x05010101, 0x05010103, 0x05010107, 0x0501010b, + 0x0501010f, 0x05010301, 0x05010305, 0x05010309, 0x0501030d, 0x05010503, 0x05010507, 0x0501050f, + 0x05010701, 0x05010705, 0x05010903, 0x05010907, 0x0501090b, 0x05010b01, 0x05010b05, 0x05010d0f, + 0x05010f01, 0x05010f07, 0x05010f0b, 0x05030101, 0x05030105, 0x05030301, 0x05030307, 0x0503030f, + 0x05030505, 0x0503050b, 0x05030703, 0x05030709, 0x05030905, 0x05030b03, 0x05050103, 0x05050109, + 0x0505010f, 0x05050503, 0x05050507, 0x05050701, 0x0505070f, 0x05050903, 0x05050b07, 0x05050b0f, + 0x05050f03, 0x05050f09, 0x05070101, 0x05070105, 0x0507010b, 0x05070303, 0x05070505, 0x05070509, + 0x05070703, 0x05070707, 0x05070905, 0x05070b01, 0x05070d0d, 0x05090103, 0x0509010f, 0x05090501, + 0x05090507, 0x05090705, 0x0509070b, 0x05090903, 0x05090f05, 0x05090f0b, 0x050b0109, 0x050b0303, + 0x050b0505, 0x050b070f, 0x050b0901, 0x050b0b07, 0x050b0f01, 0x050d0101, 0x050d0105, 0x050d010f, + 0x050d0503, 0x050d0b0b, 0x050d0d03, 0x050f010b, 0x050f0303, 0x050f050d, 0x050f0701, 0x050f0907, + 0x050f0b01, 0x07010105, 0x07010303, 0x07010307, 0x0701030b, 0x0701030f, 0x07010505, 0x07010703, + 0x07010707, 0x0701070b, 0x07010905, 0x07010909, 0x0701090f, 0x07010b03, 0x07010d07, 0x07010f03, + 0x07030103, 0x07030107, 0x0703010b, 0x07030309, 0x07030503, 0x07030507, 0x07030901, 0x07030d01, + 0x07030f05, 0x07030f0d, 0x07050101, 0x07050305, 0x07050501, 0x07050705, 0x07050709, 0x07050b01, + 0x07070103, 0x07070301, 0x07070309, 0x07070503, 0x07070507, 0x0707050f, 0x07070701, 0x07070903, + 0x07070907, 0x0707090f, 0x07070b0b, 0x07070f07, 0x07090107, 0x07090303, 0x0709030d, 0x07090505, + 0x07090703, 0x07090b05, 0x07090d01, 0x07090d09, 0x070b0103, 0x070b0301, 0x070b0305, 0x070b050b, + 0x070b0705, 0x070b0909, 0x070b0b0d, 0x070b0f07, 0x070d030d, 0x070d0903, 0x070f0103, 0x070f0107, + 0x070f0501, 0x070f0505, 0x070f070b, 0x09010101, 0x09010109, 0x09010305, 0x09010501, 0x09010509, + 0x0901050f, 0x09010705, 0x09010903, 0x09010b01, 0x09010f01, 0x09030105, 0x0903010f, 0x09030303, + 0x09030307, 0x09030505, 0x09030701, 0x0903070b, 0x09030907, 0x09030b03, 0x09030b0b, 0x09050103, + 0x09050107, 0x09050301, 0x0905030b, 0x09050503, 0x09050707, 0x09050901, 0x09050b0f, 0x09050d05, + 0x09050f01, 0x09070109, 0x09070303, 0x09070307, 0x09070501, 0x09070505, 0x09070703, 0x0907070b, + 0x09090101, 0x09090105, 0x09090509, 0x0909070f, 0x09090901, 0x09090f03, 0x090b010b, 0x090b010f, + 0x090b0503, 0x090b0d05, 0x090d0307, 0x090d0709, 0x090d0d01, 0x090f0301, 0x090f030b, 0x090f0701, + 0x090f0907, 0x090f0b03, 0x0b010105, 0x0b010301, 0x0b010309, 0x0b010505, 0x0b010901, 0x0b010909, + 0x0b01090f, 0x0b010b05, 0x0b010d0d, 0x0b010f09, 0x0b030103, 0x0b030107, 0x0b03010b, 0x0b030305, + 0x0b030503, 0x0b030705, 0x0b030f05, 0x0b050101, 0x0b050303, 0x0b050507, 0x0b050701, 0x0b05070d, + 0x0b050b07, 0x0b070105, 0x0b07010f, 0x0b070301, 0x0b07050f, 0x0b070909, 0x0b070b03, 0x0b070d0b, + 0x0b070f07, 0x0b090103, 0x0b090109, 0x0b090501, 0x0b090705, 0x0b09090d, 0x0b0b0305, 0x0b0b050d, + 0x0b0b0b03, 0x0b0b0b07, 0x0b0d0905, 0x0b0f0105, 0x0b0f0109, 0x0b0f0505, 0x0d010303, 0x0d010307, + 0x0d01030b, 0x0d010703, 0x0d010707, 0x0d010d01, 0x0d030101, 0x0d030501, 0x0d03050f, 0x0d030d09, + 0x0d050305, 0x0d050709, 0x0d050905, 0x0d050b0b, 0x0d050d05, 0x0d050f01, 0x0d070101, 0x0d070309, + 0x0d070503, 0x0d070901, 0x0d09050b, 0x0d090907, 0x0d090d05, 0x0d0b0101, 0x0d0b0107, 0x0d0b0709, + 0x0d0b0d01, 0x0d0d010b, 0x0d0d0901, 0x0d0f0303, 0x0d0f0307, 0x0f010101, 0x0f010109, 0x0f01010f, + 0x0f010501, 0x0f010505, 0x0f01070d, 0x0f010901, 0x0f010b09, 0x0f010d05, 0x0f030105, 0x0f030303, + 0x0f030509, 0x0f030907, 0x0f03090b, 0x0f050103, 0x0f050109, 0x0f050301, 0x0f05030d, 0x0f050503, + 0x0f050701, 0x0f050b03, 0x0f070105, 0x0f070705, 0x0f07070b, 0x0f070b07, 0x0f090103, 0x0f09010b, + 0x0f090307, 0x0f090501, 0x0f090b01, 0x0f0b0505, 0x0f0b0905, 0x0f0d0105, 0x0f0d0703, 0x0f0f0101, +GGML_TABLE_END() + +// TODO: fix name to kvalues_iq4_nl +GGML_TABLE_BEGIN(int8_t, kvalues_iq4nl, 16) + -127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113, +GGML_TABLE_END() + +// e2m1 values (doubled), shared by MXFP4 and NVFP4 +// ref: https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf +GGML_TABLE_BEGIN(int8_t, kvalues_fp4, 16) + 0, 1, 2, 3, 4, 6, 8, 12, 0, -1, -2, -3, -4, -6, -8, -12, +GGML_TABLE_END() +#define kvalues_mxfp4 kvalues_fp4 + +#define NGRID_IQ1S 2048 +#define IQ1S_DELTA 0.125f +#define IQ1M_DELTA 0.125f +#if defined(GGML_COMMON_IMPL_C) || defined(GGML_COMMON_IMPL_CPP) +GGML_TABLE_BEGIN(uint64_t, iq1s_grid, NGRID_IQ1S) + 0xffffffffffffffff, 0xffffffffffffff01, 0xffffffffffff0000, 0xffffffffffff01ff, + 0xffffffffffff0101, 0xffffffffff00ff00, 0xffffffffff000000, 0xffffffffff01ffff, + 0xffffffffff01ff01, 0xffffffffff0101ff, 0xffffffffff010101, 0xffffffff00ff0000, + 0xffffffff0000ff00, 0xffffffff000000ff, 0xffffffff00000001, 0xffffffff00010000, + 0xffffffff01ffffff, 0xffffffff01ffff01, 0xffffffff01ff01ff, 0xffffffff01ff0101, + 0xffffffff01000000, 0xffffffff0101ffff, 0xffffffff0101ff01, 0xffffffff010101ff, + 0xffffffff01010101, 0xffffff00ffff00ff, 0xffffff00ffff0000, 0xffffff00ff00ff00, + 0xffffff00ff0000ff, 0xffffff00ff000001, 0xffffff00ff000100, 0xffffff00ff000101, + 0xffffff00ff010000, 0xffffff0000ffff00, 0xffffff0000ff0001, 0xffffff0000ff0100, + 0xffffff000000ff01, 0xffffff0000000000, 0xffffff0000000101, 0xffffff000001ff00, + 0xffffff00000100ff, 0xffffff0000010001, 0xffffff00000101ff, 0xffffff0001ff0000, + 0xffffff000100ff00, 0xffffff00010000ff, 0xffffff0001000001, 0xffffff0001010000, + 0xffffff01ffffffff, 0xffffff01ffffff01, 0xffffff01ffff01ff, 0xffffff01ffff0101, + 0xffffff01ff000000, 0xffffff01ff01ffff, 0xffffff01ff01ff01, 0xffffff01ff0101ff, + 0xffffff01ff010101, 0xffffff0100ff0000, 0xffffff010000ff00, 0xffffff0100000100, + 0xffffff01000100ff, 0xffffff0100010100, 0xffffff0101ffffff, 0xffffff0101ffff01, + 0xffffff0101ff01ff, 0xffffff0101ff0101, 0xffffff010100ff00, 0xffffff0101000000, + 0xffffff0101000100, 0xffffff010101ffff, 0xffffff010101ff01, 0xffffff01010101ff, + 0xffffff0101010101, 0xffff00ffff00ff00, 0xffff00ffff0000ff, 0xffff00ffff000001, + 0xffff00ffff010000, 0xffff00ff00ffff00, 0xffff00ff00ff0100, 0xffff00ff00000000, + 0xffff00ff00000101, 0xffff00ff000100ff, 0xffff00ff00010000, 0xffff00ff0100ff00, + 0xffff00ff01000100, 0xffff00ff01010000, 0xffff0000ffffff00, 0xffff0000ffff00ff, + 0xffff0000ffff0000, 0xffff0000ffff0001, 0xffff0000ff000000, 0xffff0000ff0001ff, + 0xffff0000ff000101, 0xffff0000ff010100, 0xffff000000ffffff, 0xffff000000ff0000, + 0xffff000000ff0101, 0xffff00000000ffff, 0xffff00000000ff00, 0xffff0000000000ff, + 0xffff000000000000, 0xffff000000000001, 0xffff000000000100, 0xffff00000001ffff, + 0xffff00000001ff01, 0xffff000000010000, 0xffff0000000101ff, 0xffff000000010101, + 0xffff000001ffff00, 0xffff00000100ff00, 0xffff000001000000, 0xffff0000010001ff, + 0xffff000001000101, 0xffff00000101ff00, 0xffff0000010100ff, 0xffff000001010000, + 0xffff000001010001, 0xffff000001010100, 0xffff0001ff0000ff, 0xffff0001ff000100, + 0xffff000100ffff00, 0xffff000100ff00ff, 0xffff00010000ffff, 0xffff00010000ff01, + 0xffff000100000000, 0xffff0001000001ff, 0xffff00010001ffff, 0xffff00010001ff00, + 0xffff000100010001, 0xffff000100010100, 0xffff000101ff0000, 0xffff00010100ff00, + 0xffff0001010000ff, 0xffff000101000100, 0xffff01ffffffffff, 0xffff01ffffffff01, + 0xffff01ffffff01ff, 0xffff01ffffff0101, 0xffff01ffff000000, 0xffff01ffff01ffff, + 0xffff01ffff01ff01, 0xffff01ffff0101ff, 0xffff01ffff010101, 0xffff01ff00ff0000, + 0xffff01ff0000ff00, 0xffff01ff00000001, 0xffff01ff00010000, 0xffff01ff01ffffff, + 0xffff01ff01ffff01, 0xffff01ff01ff01ff, 0xffff01ff01ff0101, 0xffff01ff01000000, + 0xffff01ff0101ffff, 0xffff01ff0101ff01, 0xffff01ff010101ff, 0xffff01ff01010101, + 0xffff0100ffff0000, 0xffff0100ff00ff00, 0xffff0100ff0000ff, 0xffff0100ff000100, + 0xffff0100ff0100ff, 0xffff0100ff010000, 0xffff010000ffff00, 0xffff01000000ffff, + 0xffff01000000ff00, 0xffff010000000000, 0xffff01000001ff00, 0xffff0100000100ff, + 0xffff010000010100, 0xffff01000100ff00, 0xffff0100010000ff, 0xffff010001000001, + 0xffff010001000100, 0xffff010001010000, 0xffff0101ffffffff, 0xffff0101ffffff01, + 0xffff0101ffff01ff, 0xffff0101ffff0101, 0xffff0101ff000000, 0xffff0101ff01ffff, + 0xffff0101ff01ff01, 0xffff0101ff0101ff, 0xffff0101ff010101, 0xffff010100ff0000, + 0xffff01010000ff00, 0xffff010100000100, 0xffff01010001ff00, 0xffff010100010000, + 0xffff010101ffffff, 0xffff010101ffff01, 0xffff010101ff0000, 0xffff010101ff01ff, + 0xffff010101ff0101, 0xffff010101000000, 0xffff01010101ffff, 0xffff01010101ff01, + 0xffff0101010101ff, 0xffff010101010101, 0xff00ffffff00ffff, 0xff00ffffff00ff00, + 0xff00ffffff0000ff, 0xff00ffffff000100, 0xff00ffffff0100ff, 0xff00ffffff010000, + 0xff00ffff00ffff00, 0xff00ffff00ff00ff, 0xff00ffff0000ffff, 0xff00ffff00000000, + 0xff00ffff000001ff, 0xff00ffff0001ff00, 0xff00ffff000100ff, 0xff00ffff00010000, + 0xff00ffff00010100, 0xff00ffff0100ff00, 0xff00ffff010000ff, 0xff00ffff01000001, + 0xff00ffff0101ff00, 0xff00ffff01010000, 0xff00ff00ffffff00, 0xff00ff00ffff00ff, + 0xff00ff00ffff0001, 0xff00ff00ffff0100, 0xff00ff00ff00ffff, 0xff00ff00ff00ff01, + 0xff00ff00ff000000, 0xff00ff00ff0001ff, 0xff00ff00ff01ff00, 0xff00ff00ff0100ff, + 0xff00ff00ff010100, 0xff00ff0000ff0000, 0xff00ff0000ff0101, 0xff00ff000000ffff, + 0xff00ff000000ff00, 0xff00ff000000ff01, 0xff00ff00000000ff, 0xff00ff0000000000, + 0xff00ff0000000001, 0xff00ff0000000100, 0xff00ff000001ffff, 0xff00ff0000010000, + 0xff00ff0001ff00ff, 0xff00ff000100ff01, 0xff00ff0001000000, 0xff00ff000101ff00, + 0xff00ff00010100ff, 0xff00ff01ff00ff00, 0xff00ff01ff0000ff, 0xff00ff01ff000001, + 0xff00ff01ff010000, 0xff00ff0100ffffff, 0xff00ff0100ff0001, 0xff00ff0100ff0100, + 0xff00ff010000ff01, 0xff00ff0100000000, 0xff00ff01000001ff, 0xff00ff0100000101, + 0xff00ff01000100ff, 0xff00ff0100010001, 0xff00ff0101ff0000, 0xff00ff010100ff00, + 0xff00ff01010000ff, 0xff00ff0101000001, 0xff00ff0101010000, 0xff0000ffffffff00, + 0xff0000ffffff0001, 0xff0000ffffff0100, 0xff0000ffff0000ff, 0xff0000ffff000000, + 0xff0000ffff0001ff, 0xff0000ffff000100, 0xff0000ffff01ff00, 0xff0000ffff010001, + 0xff0000ff00ffff00, 0xff0000ff00ff0000, 0xff0000ff00ff0001, 0xff0000ff00ff01ff, + 0xff0000ff00ff0101, 0xff0000ff0000ff00, 0xff0000ff000000ff, 0xff0000ff00000000, + 0xff0000ff00000001, 0xff0000ff00000100, 0xff0000ff0001ff01, 0xff0000ff00010000, + 0xff0000ff000101ff, 0xff0000ff01ff00ff, 0xff0000ff01ff0100, 0xff0000ff0100ffff, + 0xff0000ff010000ff, 0xff0000ff01000000, 0xff0000ff010001ff, 0xff0000ff01000100, + 0xff0000ff01000101, 0xff0000ff0101ff00, 0xff0000ff010100ff, 0xff0000ff01010000, + 0xff0000ff01010100, 0xff000000ffffff01, 0xff000000ffff0000, 0xff000000ffff0101, + 0xff000000ff00ff00, 0xff000000ff0000ff, 0xff000000ff000000, 0xff000000ff000001, + 0xff000000ff000100, 0xff000000ff01ffff, 0xff000000ff01ff01, 0xff000000ff010000, + 0xff000000ff0101ff, 0xff000000ff010101, 0xff00000000ffff00, 0xff00000000ff00ff, + 0xff00000000ff0000, 0xff00000000ff0001, 0xff0000000000ff00, 0xff0000000000ff01, + 0xff000000000000ff, 0xff00000000000000, 0xff00000000000001, 0xff00000000000100, + 0xff00000000000101, 0xff0000000001ff00, 0xff000000000100ff, 0xff00000000010000, + 0xff00000000010001, 0xff00000000010100, 0xff00000001ffffff, 0xff00000001ffff01, + 0xff00000001ff00ff, 0xff00000001ff0000, 0xff00000001ff01ff, 0xff00000001ff0101, + 0xff0000000100ffff, 0xff0000000100ff00, 0xff000000010000ff, 0xff00000001000000, + 0xff00000001000001, 0xff00000001000100, 0xff00000001000101, 0xff0000000101ffff, + 0xff0000000101ff01, 0xff00000001010000, 0xff000001ffffff00, 0xff000001ffff00ff, + 0xff000001ffff0000, 0xff000001ffff0001, 0xff000001ff000000, 0xff000001ff000001, + 0xff000001ff0001ff, 0xff000001ff000101, 0xff000001ff01ff00, 0xff000001ff010001, + 0xff00000100ffffff, 0xff00000100ffff01, 0xff00000100ff00ff, 0xff00000100ff0000, + 0xff00000100ff01ff, 0xff00000100ff0101, 0xff0000010000ff00, 0xff00000100000000, + 0xff00000100000001, 0xff000001000001ff, 0xff00000100000100, 0xff0000010001ff00, + 0xff000001000100ff, 0xff00000100010000, 0xff000001000101ff, 0xff00000100010100, + 0xff00000100010101, 0xff00000101ff0001, 0xff00000101ff0101, 0xff0000010100ff01, + 0xff00000101000000, 0xff000001010100ff, 0xff00000101010100, 0xff0001ffff00ff00, + 0xff0001ffff000001, 0xff0001ffff010000, 0xff0001ff00ffff00, 0xff0001ff00ff00ff, + 0xff0001ff00ff0001, 0xff0001ff00ff0100, 0xff0001ff0000ffff, 0xff0001ff00000000, + 0xff0001ff000001ff, 0xff0001ff00000101, 0xff0001ff0001ffff, 0xff0001ff0001ff00, + 0xff0001ff000100ff, 0xff0001ff00010001, 0xff0001ff00010100, 0xff0001ff01ff0000, + 0xff0001ff0100ff00, 0xff0001ff010000ff, 0xff0001ff01010000, 0xff000100ff00ffff, + 0xff000100ff00ff01, 0xff000100ff000000, 0xff000100ff000101, 0xff000100ff01ff00, + 0xff000100ff010000, 0xff00010000ffff01, 0xff00010000ff00ff, 0xff00010000ff0000, + 0xff00010000ff01ff, 0xff0001000000ff00, 0xff000100000000ff, 0xff00010000000000, + 0xff00010000000001, 0xff00010000000100, 0xff00010000000101, 0xff0001000001ffff, + 0xff00010000010000, 0xff00010000010101, 0xff00010001ff0100, 0xff0001000100ff00, + 0xff0001000100ff01, 0xff00010001000000, 0xff000100010001ff, 0xff0001000101ff00, + 0xff00010001010001, 0xff00010001010100, 0xff000101ffff0100, 0xff000101ff000001, + 0xff000101ff0100ff, 0xff000101ff010001, 0xff00010100ff00ff, 0xff00010100ff0001, + 0xff00010100ff0100, 0xff0001010000ffff, 0xff0001010000ff01, 0xff00010100000000, + 0xff000101000001ff, 0xff0001010001ff00, 0xff00010100010001, 0xff00010100010100, + 0xff00010101ff0000, 0xff0001010100ff00, 0xff00010101000001, 0xff00010101000101, + 0xff01ffffffffffff, 0xff01ffffffffff01, 0xff01ffffffff01ff, 0xff01ffffffff0101, + 0xff01ffffff000000, 0xff01ffffff01ffff, 0xff01ffffff01ff01, 0xff01ffffff010000, + 0xff01ffffff0101ff, 0xff01ffffff010101, 0xff01ffff00ff0000, 0xff01ffff0000ff00, + 0xff01ffff00000100, 0xff01ffff0001ff00, 0xff01ffff00010000, 0xff01ffff01ffffff, + 0xff01ffff01ffff01, 0xff01ffff01ff01ff, 0xff01ffff01ff0101, 0xff01ffff01000000, + 0xff01ffff0101ffff, 0xff01ffff0101ff01, 0xff01ffff01010000, 0xff01ffff010101ff, + 0xff01ffff01010101, 0xff01ff00ffff0000, 0xff01ff00ff00ff00, 0xff01ff00ff0000ff, + 0xff01ff00ff000100, 0xff01ff00ff010000, 0xff01ff0000ffff01, 0xff01ff0000ff00ff, + 0xff01ff0000ff0100, 0xff01ff0000000000, 0xff01ff00000001ff, 0xff01ff0000000101, + 0xff01ff000001ff00, 0xff01ff00000100ff, 0xff01ff0000010000, 0xff01ff0000010001, + 0xff01ff0001ff0000, 0xff01ff000100ffff, 0xff01ff0001000001, 0xff01ff0001000100, + 0xff01ff0001010000, 0xff01ff01ffffff00, 0xff01ff01ffff01ff, 0xff01ff01ffff0101, + 0xff01ff01ff00ff00, 0xff01ff01ff000000, 0xff01ff01ff01ffff, 0xff01ff01ff01ff01, + 0xff01ff01ff0101ff, 0xff01ff01ff010101, 0xff01ff0100ff0000, 0xff01ff010000ff00, + 0xff01ff0100000001, 0xff01ff0100000100, 0xff01ff0100010000, 0xff01ff0101ffff00, + 0xff01ff0101ff01ff, 0xff01ff0101ff0101, 0xff01ff010100ff00, 0xff01ff0101000000, + 0xff01ff010101ffff, 0xff01ff010101ff01, 0xff01ff01010101ff, 0xff01ff0101010101, + 0xff0100ffffff0000, 0xff0100ffff0000ff, 0xff0100ffff000001, 0xff0100ffff000100, + 0xff0100ffff010000, 0xff0100ff00ff00ff, 0xff0100ff00ff0000, 0xff0100ff00ff0001, + 0xff0100ff00ff0100, 0xff0100ff0000ff01, 0xff0100ff00000000, 0xff0100ff000001ff, + 0xff0100ff00000101, 0xff0100ff00010001, 0xff0100ff01ff0000, 0xff0100ff0100ff00, + 0xff0100ff010000ff, 0xff0100ff01000100, 0xff0100ff0101ff00, 0xff0100ff01010000, + 0xff010000ffff0100, 0xff010000ff000000, 0xff010000ff01ff00, 0xff010000ff010100, + 0xff01000000ffffff, 0xff01000000ff0000, 0xff01000000ff01ff, 0xff0100000000ff00, + 0xff010000000000ff, 0xff01000000000000, 0xff01000000000100, 0xff0100000001ff01, + 0xff01000000010000, 0xff010000000101ff, 0xff01000001ff0100, 0xff0100000100ffff, + 0xff010000010000ff, 0xff01000001000000, 0xff010000010001ff, 0xff01000001000101, + 0xff0100000101ff00, 0xff010000010100ff, 0xff01000001010001, 0xff01000001010100, + 0xff010001ffff0000, 0xff010001ff00ffff, 0xff010001ff00ff01, 0xff010001ff000100, + 0xff010001ff010000, 0xff01000100ffff00, 0xff01000100ff0100, 0xff01000100000000, + 0xff0100010001ffff, 0xff0100010001ff00, 0xff01000100010100, 0xff01000101ff00ff, + 0xff01000101ff0001, 0xff0100010100ffff, 0xff01000101000101, 0xff0101ffffffffff, + 0xff0101ffffffff01, 0xff0101ffffff01ff, 0xff0101ffffff0101, 0xff0101ffff000000, + 0xff0101ffff01ffff, 0xff0101ffff01ff01, 0xff0101ffff0101ff, 0xff0101ffff010101, + 0xff0101ff00ff0000, 0xff0101ff0000ff00, 0xff0101ff000000ff, 0xff0101ff00010000, + 0xff0101ff01ffffff, 0xff0101ff01ffff01, 0xff0101ff01ff01ff, 0xff0101ff01ff0101, + 0xff0101ff0101ffff, 0xff0101ff0101ff01, 0xff0101ff010101ff, 0xff0101ff01010101, + 0xff010100ffff0100, 0xff010100ff00ff00, 0xff010100ff0000ff, 0xff010100ff000100, + 0xff010100ff010000, 0xff01010000ff0001, 0xff01010000ff0100, 0xff0101000000ff01, + 0xff01010000000000, 0xff0101000001ff00, 0xff010100000100ff, 0xff01010000010001, + 0xff01010000010100, 0xff01010001ff0000, 0xff0101000100ffff, 0xff01010001000001, + 0xff01010001000100, 0xff010100010100ff, 0xff01010001010000, 0xff010101ffffffff, + 0xff010101ffffff01, 0xff010101ffff01ff, 0xff010101ffff0101, 0xff010101ff01ffff, + 0xff010101ff01ff01, 0xff010101ff0101ff, 0xff010101ff010101, 0xff01010100ff0000, + 0xff0101010000ff00, 0xff01010100000001, 0xff01010100000100, 0xff01010100010000, + 0xff01010101ffffff, 0xff01010101ffff01, 0xff01010101ff01ff, 0xff01010101ff0101, + 0xff01010101000000, 0xff0101010101ffff, 0xff0101010101ff01, 0xff010101010101ff, + 0xff01010101010101, 0x00ffffffffff0000, 0x00ffffffff00ff00, 0x00ffffffff000001, + 0x00ffffffff010000, 0x00ffffff00ff0100, 0x00ffffff0000ff01, 0x00ffffff00000000, + 0x00ffffff000001ff, 0x00ffffff00000101, 0x00ffffff0001ff00, 0x00ffffff000100ff, + 0x00ffffff00010001, 0x00ffffff010000ff, 0x00ffffff01000100, 0x00ffffff0101ff00, + 0x00ffffff01010001, 0x00ffff00ffffffff, 0x00ffff00ffffff00, 0x00ffff00ffff00ff, + 0x00ffff00ffff0001, 0x00ffff00ffff0100, 0x00ffff00ff00ff01, 0x00ffff00ff000000, + 0x00ffff00ff000001, 0x00ffff00ff0001ff, 0x00ffff00ff000101, 0x00ffff00ff01ff00, + 0x00ffff00ff010001, 0x00ffff00ff010100, 0x00ffff0000ff0000, 0x00ffff0000ff01ff, + 0x00ffff0000ff0101, 0x00ffff000000ff00, 0x00ffff00000000ff, 0x00ffff0000000000, + 0x00ffff0000000001, 0x00ffff0000000100, 0x00ffff0000000101, 0x00ffff0000010000, + 0x00ffff00000101ff, 0x00ffff0000010101, 0x00ffff0001ffff00, 0x00ffff0001ff00ff, + 0x00ffff0001ff0001, 0x00ffff000100ffff, 0x00ffff000100ff01, 0x00ffff0001000000, + 0x00ffff000101ffff, 0x00ffff000101ff00, 0x00ffff000101ff01, 0x00ffff01ffff0000, + 0x00ffff01ff00ff00, 0x00ffff01ff0000ff, 0x00ffff01ff000001, 0x00ffff01ff010000, + 0x00ffff0100ffff00, 0x00ffff010000ff01, 0x00ffff0100000000, 0x00ffff0100000101, + 0x00ffff01000100ff, 0x00ffff0100010100, 0x00ffff0101ff0100, 0x00ffff01010000ff, + 0x00ffff0101010000, 0x00ff00ffffffff00, 0x00ff00ffff000000, 0x00ff00ffff000100, + 0x00ff00ffff010100, 0x00ff00ff00ff0000, 0x00ff00ff00ff01ff, 0x00ff00ff00ff0101, + 0x00ff00ff0000ff00, 0x00ff00ff000000ff, 0x00ff00ff00000000, 0x00ff00ff00000001, + 0x00ff00ff0001ff00, 0x00ff00ff0001ff01, 0x00ff00ff00010000, 0x00ff00ff000101ff, + 0x00ff00ff00010101, 0x00ff00ff01ffff00, 0x00ff00ff01ff0001, 0x00ff00ff01ff0100, + 0x00ff00ff0100ffff, 0x00ff00ff0100ff01, 0x00ff00ff01000000, 0x00ff00ff0101ffff, + 0x00ff00ff0101ff00, 0x00ff00ff01010100, 0x00ff0000ffffff00, 0x00ff0000ffffff01, + 0x00ff0000ffff0000, 0x00ff0000ffff0101, 0x00ff0000ff00ff00, 0x00ff0000ff0000ff, + 0x00ff0000ff000000, 0x00ff0000ff000001, 0x00ff0000ff000100, 0x00ff0000ff01ffff, + 0x00ff0000ff010000, 0x00ff0000ff010101, 0x00ff000000ffff00, 0x00ff000000ff00ff, + 0x00ff000000ff0000, 0x00ff000000ff0001, 0x00ff000000ff0100, 0x00ff00000000ffff, + 0x00ff00000000ff00, 0x00ff0000000000ff, 0x00ff000000000000, 0x00ff000000000001, + 0x00ff0000000001ff, 0x00ff000000000100, 0x00ff00000001ff00, 0x00ff0000000100ff, + 0x00ff000000010000, 0x00ff000000010001, 0x00ff000000010100, 0x00ff000001ffff01, + 0x00ff000001ff00ff, 0x00ff000001ff0000, 0x00ff000001ff01ff, 0x00ff00000100ff00, + 0x00ff0000010000ff, 0x00ff000001000000, 0x00ff000001000001, 0x00ff000001000100, + 0x00ff000001000101, 0x00ff000001010000, 0x00ff0000010101ff, 0x00ff000001010101, + 0x00ff0001ffffff00, 0x00ff0001ffff0000, 0x00ff0001ffff0100, 0x00ff0001ff0000ff, + 0x00ff0001ff000000, 0x00ff0001ff0001ff, 0x00ff0001ff000101, 0x00ff0001ff01ff00, + 0x00ff0001ff0100ff, 0x00ff0001ff010100, 0x00ff000100ffffff, 0x00ff000100ffff01, + 0x00ff000100ff0000, 0x00ff000100ff01ff, 0x00ff00010000ffff, 0x00ff00010000ff00, + 0x00ff00010000ff01, 0x00ff000100000000, 0x00ff000100000001, 0x00ff000100000100, + 0x00ff00010001ff01, 0x00ff000100010000, 0x00ff0001000101ff, 0x00ff000101ffff00, + 0x00ff000101ff0000, 0x00ff000101ff0101, 0x00ff0001010000ff, 0x00ff000101000000, + 0x00ff00010101ff00, 0x00ff0001010100ff, 0x00ff000101010001, 0x00ff01ffffff0000, + 0x00ff01ffff00ff00, 0x00ff01ffff000000, 0x00ff01ffff000101, 0x00ff01ffff010000, + 0x00ff01ff00ffff01, 0x00ff01ff00ff0100, 0x00ff01ff0000ffff, 0x00ff01ff00000000, + 0x00ff01ff000001ff, 0x00ff01ff0001ff00, 0x00ff01ff000100ff, 0x00ff01ff00010001, + 0x00ff01ff00010100, 0x00ff01ff01ff0000, 0x00ff01ff0100ff00, 0x00ff01ff010000ff, + 0x00ff01ff01000001, 0x00ff01ff01000100, 0x00ff01ff01010000, 0x00ff0100ffffff00, + 0x00ff0100ffff0000, 0x00ff0100ffff0001, 0x00ff0100ffff0101, 0x00ff0100ff00ffff, + 0x00ff0100ff0000ff, 0x00ff0100ff000000, 0x00ff0100ff0001ff, 0x00ff0100ff01ff00, + 0x00ff0100ff0100ff, 0x00ff0100ff010001, 0x00ff010000ffffff, 0x00ff010000ff0000, + 0x00ff010000ff0101, 0x00ff01000000ff00, 0x00ff01000000ff01, 0x00ff0100000000ff, + 0x00ff010000000000, 0x00ff010000000001, 0x00ff010000000100, 0x00ff01000001ffff, + 0x00ff01000001ff01, 0x00ff010000010000, 0x00ff010000010001, 0x00ff010000010101, + 0x00ff010001ff0001, 0x00ff010001ff0100, 0x00ff01000100ff01, 0x00ff010001000000, + 0x00ff010001000001, 0x00ff0100010001ff, 0x00ff01000101ff00, 0x00ff0100010100ff, + 0x00ff010001010001, 0x00ff010001010100, 0x00ff0101ff000001, 0x00ff010100ff00ff, + 0x00ff010100ff0001, 0x00ff010100ff0100, 0x00ff010100000000, 0x00ff0101000001ff, + 0x00ff010100000101, 0x00ff0101000100ff, 0x00ff010100010100, 0x00ff0101010000ff, + 0x00ff010101010000, 0x0000ffffffffff00, 0x0000ffffffff00ff, 0x0000ffffffff0000, + 0x0000ffffffff0001, 0x0000ffffffff0100, 0x0000ffffff00ff01, 0x0000ffffff000000, + 0x0000ffffff000101, 0x0000ffffff01ff00, 0x0000ffffff0100ff, 0x0000ffffff010100, + 0x0000ffff00ffffff, 0x0000ffff00ff0000, 0x0000ffff00ff01ff, 0x0000ffff0000ff00, + 0x0000ffff000000ff, 0x0000ffff00000000, 0x0000ffff00000001, 0x0000ffff00000100, + 0x0000ffff00010000, 0x0000ffff000101ff, 0x0000ffff01ff0001, 0x0000ffff01ff0100, + 0x0000ffff01000000, 0x0000ffff010001ff, 0x0000ffff0101ffff, 0x0000ffff0101ff00, + 0x0000ffff01010001, 0x0000ffff01010100, 0x0000ff00ffff0000, 0x0000ff00ffff01ff, + 0x0000ff00ffff0100, 0x0000ff00ffff0101, 0x0000ff00ff00ff00, 0x0000ff00ff0000ff, + 0x0000ff00ff000000, 0x0000ff00ff000001, 0x0000ff00ff0001ff, 0x0000ff00ff000100, + 0x0000ff00ff01ffff, 0x0000ff00ff010000, 0x0000ff00ff010001, 0x0000ff00ff0101ff, + 0x0000ff00ff010101, 0x0000ff0000ffff00, 0x0000ff0000ff00ff, 0x0000ff0000ff0000, + 0x0000ff0000ff0001, 0x0000ff0000ff0100, 0x0000ff000000ffff, 0x0000ff000000ff00, + 0x0000ff000000ff01, 0x0000ff00000000ff, 0x0000ff0000000000, 0x0000ff0000000001, + 0x0000ff00000001ff, 0x0000ff0000000100, 0x0000ff0000000101, 0x0000ff000001ff00, + 0x0000ff00000100ff, 0x0000ff0000010000, 0x0000ff0000010001, 0x0000ff0000010100, + 0x0000ff0001ffff01, 0x0000ff0001ff0000, 0x0000ff000100ff00, 0x0000ff00010000ff, + 0x0000ff0001000000, 0x0000ff0001000001, 0x0000ff0001000100, 0x0000ff000101ffff, + 0x0000ff0001010000, 0x0000ff0001010101, 0x0000ff01ffffff00, 0x0000ff01ffff0001, + 0x0000ff01ff00ff01, 0x0000ff01ff000000, 0x0000ff01ff000101, 0x0000ff01ff01ff00, + 0x0000ff01ff0100ff, 0x0000ff0100ffff01, 0x0000ff0100ff0000, 0x0000ff0100ff0101, + 0x0000ff010000ff00, 0x0000ff01000000ff, 0x0000ff0100000000, 0x0000ff0100000001, + 0x0000ff0100000100, 0x0000ff010001ff01, 0x0000ff0100010000, 0x0000ff0101ff0000, + 0x0000ff010100ffff, 0x0000ff010100ff01, 0x0000ff0101000000, 0x0000ff0101000100, + 0x0000ff0101000101, 0x0000ff01010100ff, 0x000000ffffff00ff, 0x000000ffffff0000, + 0x000000ffff00ff00, 0x000000ffff0000ff, 0x000000ffff000000, 0x000000ffff000001, + 0x000000ffff0001ff, 0x000000ffff000100, 0x000000ffff01ff00, 0x000000ffff010000, + 0x000000ffff0101ff, 0x000000ffff010101, 0x000000ff00ffff00, 0x000000ff00ff00ff, + 0x000000ff00ff0000, 0x000000ff00ff0001, 0x000000ff00ff0100, 0x000000ff00ff0101, + 0x000000ff0000ffff, 0x000000ff0000ff00, 0x000000ff000000ff, 0x000000ff00000000, + 0x000000ff00000001, 0x000000ff000001ff, 0x000000ff00000100, 0x000000ff00000101, + 0x000000ff0001ff00, 0x000000ff0001ff01, 0x000000ff000100ff, 0x000000ff00010000, + 0x000000ff00010001, 0x000000ff00010100, 0x000000ff01ffffff, 0x000000ff01ff01ff, + 0x000000ff01ff0101, 0x000000ff0100ff00, 0x000000ff010000ff, 0x000000ff01000000, + 0x000000ff01000001, 0x000000ff01000100, 0x000000ff0101ff00, 0x000000ff010100ff, + 0x000000ff01010000, 0x000000ff01010101, 0x00000000ffffff00, 0x00000000ffffff01, + 0x00000000ffff00ff, 0x00000000ffff0000, 0x00000000ffff0001, 0x00000000ffff0100, + 0x00000000ff00ffff, 0x00000000ff00ff00, 0x00000000ff00ff01, 0x00000000ff0000ff, + 0x00000000ff000000, 0x00000000ff000001, 0x00000000ff000100, 0x00000000ff000101, + 0x00000000ff01ff00, 0x00000000ff0100ff, 0x00000000ff010000, 0x00000000ff010001, + 0x00000000ff010100, 0x0000000000ffffff, 0x0000000000ffff00, 0x0000000000ffff01, + 0x0000000000ff00ff, 0x0000000000ff0000, 0x0000000000ff0001, 0x0000000000ff01ff, + 0x0000000000ff0100, 0x000000000000ffff, 0x000000000000ff00, 0x000000000000ff01, + 0x00000000000000ff, 0x0000000000000000, 0x0000000000000001, 0x00000000000001ff, + 0x0000000000000100, 0x0000000000000101, 0x000000000001ffff, 0x000000000001ff00, + 0x00000000000100ff, 0x0000000000010000, 0x0000000000010001, 0x00000000000101ff, + 0x0000000000010100, 0x0000000000010101, 0x0000000001ffff00, 0x0000000001ff00ff, + 0x0000000001ff0000, 0x0000000001ff0100, 0x0000000001ff0101, 0x000000000100ffff, + 0x000000000100ff00, 0x00000000010000ff, 0x0000000001000000, 0x0000000001000001, + 0x00000000010001ff, 0x0000000001000100, 0x000000000101ff00, 0x00000000010100ff, + 0x0000000001010000, 0x0000000001010001, 0x0000000001010100, 0x00000001ffffffff, + 0x00000001ffffff00, 0x00000001ffffff01, 0x00000001ffff00ff, 0x00000001ffff0001, + 0x00000001ffff01ff, 0x00000001ffff0100, 0x00000001ff00ff00, 0x00000001ff0000ff, + 0x00000001ff000000, 0x00000001ff0001ff, 0x00000001ff000100, 0x00000001ff01ffff, + 0x00000001ff01ff00, 0x00000001ff01ff01, 0x00000001ff0100ff, 0x00000001ff010000, + 0x00000001ff010001, 0x00000001ff0101ff, 0x00000001ff010100, 0x0000000100ffff00, + 0x0000000100ff0000, 0x0000000100ff0001, 0x0000000100ff01ff, 0x0000000100ff0100, + 0x0000000100ff0101, 0x000000010000ffff, 0x000000010000ff00, 0x000000010000ff01, + 0x00000001000000ff, 0x0000000100000000, 0x0000000100000001, 0x00000001000001ff, + 0x0000000100000100, 0x0000000100000101, 0x000000010001ff00, 0x00000001000100ff, + 0x0000000100010000, 0x0000000100010100, 0x0000000101ffff01, 0x0000000101ff0000, + 0x0000000101ff0001, 0x0000000101ff01ff, 0x0000000101ff0100, 0x0000000101ff0101, + 0x000000010100ff00, 0x0000000101000000, 0x0000000101000101, 0x000000010101ff01, + 0x0000000101010000, 0x0000000101010001, 0x00000001010101ff, 0x0000000101010100, + 0x000001ffffff00ff, 0x000001ffffff0000, 0x000001ffffff0001, 0x000001ffffff0100, + 0x000001ffff00ffff, 0x000001ffff000000, 0x000001ffff0001ff, 0x000001ffff01ff00, + 0x000001ffff010101, 0x000001ff00ff0000, 0x000001ff00ff01ff, 0x000001ff00ff0101, + 0x000001ff0000ff00, 0x000001ff000000ff, 0x000001ff00000000, 0x000001ff00000001, + 0x000001ff000001ff, 0x000001ff00000100, 0x000001ff0001ffff, 0x000001ff0001ff01, + 0x000001ff000100ff, 0x000001ff00010000, 0x000001ff01ffff01, 0x000001ff01ff0100, + 0x000001ff0100ffff, 0x000001ff0100ff01, 0x000001ff01000000, 0x000001ff010001ff, + 0x000001ff0101ff00, 0x000001ff01010100, 0x00000100ffffff00, 0x00000100ffffff01, + 0x00000100ffff0000, 0x00000100ffff0101, 0x00000100ff00ff00, 0x00000100ff0000ff, + 0x00000100ff000000, 0x00000100ff000001, 0x00000100ff000100, 0x00000100ff010000, + 0x0000010000ffff00, 0x0000010000ff00ff, 0x0000010000ff0000, 0x0000010000ff0001, + 0x0000010000ff0100, 0x000001000000ffff, 0x000001000000ff00, 0x000001000000ff01, + 0x00000100000000ff, 0x0000010000000000, 0x0000010000000001, 0x00000100000001ff, + 0x0000010000000100, 0x0000010000000101, 0x000001000001ff00, 0x00000100000100ff, + 0x0000010000010000, 0x0000010000010001, 0x0000010000010100, 0x0000010001ffff00, + 0x0000010001ff0000, 0x0000010001ff0100, 0x000001000100ff00, 0x00000100010000ff, + 0x0000010001000000, 0x0000010001000001, 0x00000100010001ff, 0x0000010001000100, + 0x0000010001010000, 0x00000101ffff00ff, 0x00000101ffff01ff, 0x00000101ff000000, + 0x00000101ff000101, 0x00000101ff01ffff, 0x00000101ff010000, 0x00000101ff010001, + 0x00000101ff010100, 0x0000010100ff0000, 0x0000010100ff01ff, 0x0000010100ff0100, + 0x000001010000ff00, 0x0000010100000000, 0x0000010100000001, 0x00000101000001ff, + 0x0000010100000100, 0x000001010001ff01, 0x0000010100010000, 0x00000101000101ff, + 0x0000010100010101, 0x0000010101ffff00, 0x0000010101ff0101, 0x000001010100ff01, + 0x0000010101000000, 0x0000010101000001, 0x00000101010001ff, 0x0000010101000101, + 0x000001010101ff00, 0x0001ffffffff0000, 0x0001ffffff0000ff, 0x0001ffffff000001, + 0x0001ffffff000100, 0x0001ffffff010000, 0x0001ffff00ff00ff, 0x0001ffff0000ffff, + 0x0001ffff00000000, 0x0001ffff00000001, 0x0001ffff000001ff, 0x0001ffff00000101, + 0x0001ffff0001ff00, 0x0001ffff000100ff, 0x0001ffff00010001, 0x0001ffff00010100, + 0x0001ffff01ffff00, 0x0001ffff01000001, 0x0001ffff01010000, 0x0001ff00ffffff00, + 0x0001ff00ffff00ff, 0x0001ff00ffff0001, 0x0001ff00ffff0100, 0x0001ff00ff00ff01, + 0x0001ff00ff000000, 0x0001ff00ff01ff00, 0x0001ff00ff01ff01, 0x0001ff00ff010001, + 0x0001ff00ff010100, 0x0001ff0000ff0000, 0x0001ff0000ff0100, 0x0001ff000000ff00, + 0x0001ff0000000000, 0x0001ff0000000001, 0x0001ff0000000100, 0x0001ff0000010000, + 0x0001ff0000010001, 0x0001ff0000010101, 0x0001ff0001ff00ff, 0x0001ff0001ff0101, + 0x0001ff000100ff01, 0x0001ff0001000000, 0x0001ff000101ff00, 0x0001ff0001010001, + 0x0001ff0001010100, 0x0001ff01ff00ff00, 0x0001ff01ff000001, 0x0001ff01ff000100, + 0x0001ff0100ffffff, 0x0001ff0100ffff00, 0x0001ff0100ff0001, 0x0001ff0100000000, + 0x0001ff0100000001, 0x0001ff01000001ff, 0x0001ff010001ffff, 0x0001ff0101ff0000, + 0x0001ff010100ff00, 0x0001ff0101000001, 0x0001ff0101010000, 0x000100ffff00ff00, + 0x000100ffff00ff01, 0x000100ffff000000, 0x000100ffff000001, 0x000100ffff000101, + 0x000100ffff01ff00, 0x000100ffff010001, 0x000100ffff010100, 0x000100ff00ffffff, + 0x000100ff00ffff01, 0x000100ff00ff0000, 0x000100ff00ff01ff, 0x000100ff00ff0101, + 0x000100ff0000ff00, 0x000100ff000000ff, 0x000100ff00000000, 0x000100ff00000001, + 0x000100ff00000100, 0x000100ff00000101, 0x000100ff0001ffff, 0x000100ff0001ff01, + 0x000100ff00010000, 0x000100ff01ff00ff, 0x000100ff01ff0000, 0x000100ff01ff0100, + 0x000100ff0100ffff, 0x000100ff0100ff01, 0x000100ff010000ff, 0x000100ff01000000, + 0x000100ff01000001, 0x000100ff010001ff, 0x000100ff01000101, 0x000100ff0101ff00, + 0x000100ff010100ff, 0x000100ff01010100, 0x00010000ffff0000, 0x00010000ffff01ff, + 0x00010000ffff0101, 0x00010000ff00ff00, 0x00010000ff000000, 0x00010000ff000001, + 0x00010000ff000100, 0x0001000000ff00ff, 0x0001000000ff0000, 0x0001000000ff0001, + 0x0001000000ff0100, 0x000100000000ffff, 0x000100000000ff00, 0x00010000000000ff, + 0x0001000000000000, 0x0001000000000001, 0x0001000000000100, 0x000100000001ff00, + 0x00010000000100ff, 0x0001000000010000, 0x0001000000010001, 0x0001000000010100, + 0x0001000001ff0001, 0x0001000001ff0100, 0x0001000001ff0101, 0x000100000100ff00, + 0x0001000001000000, 0x0001000001000001, 0x0001000001000100, 0x0001000001000101, + 0x000100000101ff01, 0x0001000001010000, 0x0001000001010001, 0x00010000010101ff, + 0x00010001ffffff01, 0x00010001ffff0100, 0x00010001ff000000, 0x00010001ff01ffff, + 0x00010001ff010001, 0x00010001ff0101ff, 0x00010001ff010100, 0x0001000100ffffff, + 0x0001000100ff0000, 0x0001000100ff01ff, 0x0001000100ff0101, 0x000100010000ff00, + 0x00010001000000ff, 0x0001000100000000, 0x0001000100000001, 0x00010001000001ff, + 0x0001000100000101, 0x000100010001ffff, 0x0001000100010000, 0x00010001000101ff, + 0x0001000101ffffff, 0x0001000101ffff01, 0x0001000101ff0000, 0x0001000101ff0101, + 0x00010001010000ff, 0x0001000101000001, 0x00010001010001ff, 0x0001000101000100, + 0x000100010101ffff, 0x00010001010100ff, 0x0001000101010001, 0x0001000101010101, + 0x000101ffff000001, 0x000101ffff000100, 0x000101ffff010000, 0x000101ff00ffff00, + 0x000101ff0000ff01, 0x000101ff00000000, 0x000101ff00000101, 0x000101ff0001ff00, + 0x000101ff00010100, 0x000101ff01ff0000, 0x000101ff0100ff00, 0x000101ff010001ff, + 0x000101ff01010001, 0x00010100ffffff00, 0x00010100ffff00ff, 0x00010100ff00ffff, + 0x00010100ff000000, 0x00010100ff01ff00, 0x00010100ff0100ff, 0x00010100ff010001, + 0x00010100ff010100, 0x0001010000ffffff, 0x0001010000ffff00, 0x0001010000ff0000, + 0x0001010000ff0001, 0x0001010000ff01ff, 0x000101000000ff00, 0x00010100000000ff, + 0x0001010000000000, 0x0001010000000001, 0x0001010000000100, 0x000101000001ffff, + 0x0001010000010000, 0x0001010000010101, 0x0001010001ffff01, 0x0001010001ff00ff, + 0x0001010001ff0101, 0x0001010001000000, 0x000101000101ff00, 0x00010100010100ff, + 0x0001010001010000, 0x0001010001010100, 0x00010101ff00ff00, 0x00010101ff000001, + 0x00010101ff0001ff, 0x0001010100ffff00, 0x0001010100ff00ff, 0x0001010100ff0100, + 0x000101010000ffff, 0x0001010100000000, 0x00010101000001ff, 0x0001010100000101, + 0x00010101000100ff, 0x0001010100010000, 0x0001010100010100, 0x0001010101ff0001, + 0x00010101010000ff, 0x00010101010001ff, 0x0001010101000101, 0x0001010101010001, + 0x01ffffffffffffff, 0x01ffffffffffff01, 0x01ffffffffff01ff, 0x01ffffffffff0101, + 0x01ffffffff01ffff, 0x01ffffffff01ff01, 0x01ffffffff0101ff, 0x01ffffffff010101, + 0x01ffffff00ff0000, 0x01ffffff0000ffff, 0x01ffffff0000ff00, 0x01ffffff000000ff, + 0x01ffffff00000001, 0x01ffffff00000100, 0x01ffffff00010000, 0x01ffffff01ffffff, + 0x01ffffff01ffff01, 0x01ffffff01ff01ff, 0x01ffffff01ff0101, 0x01ffffff01000000, + 0x01ffffff0101ffff, 0x01ffffff0101ff01, 0x01ffffff010101ff, 0x01ffffff01010101, + 0x01ffff00ffff0000, 0x01ffff00ff00ff00, 0x01ffff00ff0000ff, 0x01ffff00ff000001, + 0x01ffff00ff000100, 0x01ffff00ff010000, 0x01ffff0000ffff00, 0x01ffff0000ff00ff, + 0x01ffff0000ff0100, 0x01ffff000000ffff, 0x01ffff000000ff01, 0x01ffff0000000000, + 0x01ffff0000000001, 0x01ffff00000001ff, 0x01ffff0000000100, 0x01ffff00000100ff, + 0x01ffff0000010001, 0x01ffff0000010100, 0x01ffff0001ff0000, 0x01ffff0001ff0100, + 0x01ffff00010000ff, 0x01ffff0001000001, 0x01ffff0001000100, 0x01ffff0001010000, + 0x01ffff01ffffffff, 0x01ffff01ffffff01, 0x01ffff01ffff01ff, 0x01ffff01ffff0101, + 0x01ffff01ff000000, 0x01ffff01ff01ffff, 0x01ffff01ff01ff01, 0x01ffff01ff0101ff, + 0x01ffff01ff010101, 0x01ffff010000ff00, 0x01ffff01000000ff, 0x01ffff0100000100, + 0x01ffff0100010000, 0x01ffff0101ffffff, 0x01ffff0101ffff01, 0x01ffff0101ff01ff, + 0x01ffff0101ff0101, 0x01ffff0101000000, 0x01ffff010101ffff, 0x01ffff010101ff01, + 0x01ffff01010101ff, 0x01ffff0101010101, 0x01ff00ffff0000ff, 0x01ff00ffff000100, + 0x01ff00ff00ffff00, 0x01ff00ff00ff00ff, 0x01ff00ff0000ff00, 0x01ff00ff00000000, + 0x01ff00ff00000101, 0x01ff00ff0001ff00, 0x01ff00ff000100ff, 0x01ff00ff00010100, + 0x01ff00ff010000ff, 0x01ff00ff01000100, 0x01ff0000ffffff00, 0x01ff0000ffff0100, + 0x01ff0000ff00ff01, 0x01ff0000ff000000, 0x01ff0000ff000101, 0x01ff0000ff010001, + 0x01ff0000ff010100, 0x01ff000000ffffff, 0x01ff000000ffff00, 0x01ff000000ff0000, + 0x01ff000000ff01ff, 0x01ff00000000ff00, 0x01ff0000000000ff, 0x01ff000000000000, + 0x01ff000000000001, 0x01ff000000000100, 0x01ff000000000101, 0x01ff000000010000, + 0x01ff000000010001, 0x01ff0000000101ff, 0x01ff000000010101, 0x01ff000001ffff00, + 0x01ff000001ff00ff, 0x01ff000001ff0001, 0x01ff000001ff0100, 0x01ff00000100ffff, + 0x01ff00000100ff01, 0x01ff000001000000, 0x01ff0000010001ff, 0x01ff000001010001, + 0x01ff0001ff00ff00, 0x01ff0001ff000001, 0x01ff0001ff000100, 0x01ff0001ff010000, + 0x01ff000100ffff00, 0x01ff000100ff00ff, 0x01ff000100ff0100, 0x01ff000100ff0101, + 0x01ff00010000ffff, 0x01ff000100000000, 0x01ff000100000100, 0x01ff000100000101, + 0x01ff00010001ff00, 0x01ff000100010001, 0x01ff000100010101, 0x01ff000101ff0000, + 0x01ff00010100ff00, 0x01ff000101000101, 0x01ff0001010100ff, 0x01ff01ffffffffff, + 0x01ff01ffffffff01, 0x01ff01ffffff01ff, 0x01ff01ffffff0101, 0x01ff01ffff000000, + 0x01ff01ffff01ffff, 0x01ff01ffff01ff01, 0x01ff01ffff0101ff, 0x01ff01ffff010101, + 0x01ff01ff00ffff00, 0x01ff01ff00ff0000, 0x01ff01ff0000ff00, 0x01ff01ff000000ff, + 0x01ff01ff00000100, 0x01ff01ff00010000, 0x01ff01ff00010100, 0x01ff01ff01ffffff, + 0x01ff01ff01ffff01, 0x01ff01ff01ff01ff, 0x01ff01ff01ff0101, 0x01ff01ff01000000, + 0x01ff01ff0101ffff, 0x01ff01ff0101ff01, 0x01ff01ff010101ff, 0x01ff01ff01010101, + 0x01ff0100ffff0000, 0x01ff0100ffff0001, 0x01ff0100ff00ff00, 0x01ff0100ff0000ff, + 0x01ff0100ff000001, 0x01ff0100ff010000, 0x01ff010000ffff00, 0x01ff010000ff00ff, + 0x01ff010000ff0001, 0x01ff010000ff0100, 0x01ff01000000ffff, 0x01ff01000000ff01, + 0x01ff010000000000, 0x01ff010000000101, 0x01ff01000001ff00, 0x01ff0100000100ff, + 0x01ff010001ff0000, 0x01ff010001000001, 0x01ff010001000100, 0x01ff010001010000, + 0x01ff0101ffffffff, 0x01ff0101ffffff01, 0x01ff0101ffff01ff, 0x01ff0101ffff0101, + 0x01ff0101ff000000, 0x01ff0101ff01ffff, 0x01ff0101ff01ff01, 0x01ff0101ff0101ff, + 0x01ff0101ff010101, 0x01ff010100ff0000, 0x01ff01010000ff00, 0x01ff0101000000ff, + 0x01ff010100000001, 0x01ff010101ffffff, 0x01ff010101ffff01, 0x01ff010101ff01ff, + 0x01ff010101ff0101, 0x01ff010101000000, 0x01ff01010101ffff, 0x01ff01010101ff01, + 0x01ff0101010101ff, 0x01ff010101010101, 0x0100ffffffff0000, 0x0100ffffff00ff00, + 0x0100ffffff000001, 0x0100ffffff0001ff, 0x0100ffffff000100, 0x0100ffffff010000, + 0x0100ffff00ffff00, 0x0100ffff00ff0001, 0x0100ffff00ff0100, 0x0100ffff00000000, + 0x0100ffff000001ff, 0x0100ffff00000101, 0x0100ffff00010100, 0x0100ffff00010101, + 0x0100ffff01ff0000, 0x0100ffff0100ff00, 0x0100ffff010000ff, 0x0100ffff01000001, + 0x0100ffff01000100, 0x0100ffff01010000, 0x0100ff00ffffff00, 0x0100ff00ffff00ff, + 0x0100ff00ffff0001, 0x0100ff00ffff0100, 0x0100ff00ff00ffff, 0x0100ff00ff000000, + 0x0100ff00ff0001ff, 0x0100ff00ff000101, 0x0100ff00ff01ff00, 0x0100ff00ff0100ff, + 0x0100ff00ff010001, 0x0100ff00ff010100, 0x0100ff0000ffffff, 0x0100ff0000ff0000, + 0x0100ff000000ffff, 0x0100ff000000ff00, 0x0100ff00000000ff, 0x0100ff0000000000, + 0x0100ff0000000001, 0x0100ff0000000100, 0x0100ff000001ff01, 0x0100ff0000010000, + 0x0100ff0001ff00ff, 0x0100ff0001ff0001, 0x0100ff000100ff01, 0x0100ff0001000000, + 0x0100ff00010001ff, 0x0100ff000101ff00, 0x0100ff00010100ff, 0x0100ff0001010001, + 0x0100ff0001010100, 0x0100ff01ffff0000, 0x0100ff01ff00ff00, 0x0100ff01ff0000ff, + 0x0100ff01ff000100, 0x0100ff01ff010000, 0x0100ff0100ff00ff, 0x0100ff0100ff0001, + 0x0100ff0100ff0100, 0x0100ff010000ffff, 0x0100ff010000ff01, 0x0100ff0100000000, + 0x0100ff01000001ff, 0x0100ff0100010001, 0x0100ff0100010100, 0x0100ff0101ff0000, + 0x0100ff01010000ff, 0x0100ff0101000001, 0x0100ff0101010100, 0x010000ffffffff00, + 0x010000ffffff00ff, 0x010000ffffff0001, 0x010000ffff00ffff, 0x010000ffff000000, + 0x010000ffff0001ff, 0x010000ffff010001, 0x010000ff00ffffff, 0x010000ff00ff0101, + 0x010000ff0000ff00, 0x010000ff000000ff, 0x010000ff00000000, 0x010000ff00000001, + 0x010000ff000001ff, 0x010000ff00000100, 0x010000ff0001ffff, 0x010000ff0001ff00, + 0x010000ff0001ff01, 0x010000ff00010000, 0x010000ff01ff00ff, 0x010000ff01ff0001, + 0x010000ff0100ff01, 0x010000ff010000ff, 0x010000ff01000000, 0x010000ff010001ff, + 0x010000ff0101ff00, 0x010000ff01010100, 0x01000000ffffffff, 0x01000000ffff0000, + 0x01000000ffff01ff, 0x01000000ffff0101, 0x01000000ff00ffff, 0x01000000ff00ff00, + 0x01000000ff0000ff, 0x01000000ff000000, 0x01000000ff000001, 0x01000000ff000100, + 0x01000000ff01ff00, 0x01000000ff010000, 0x01000000ff010100, 0x01000000ff010101, + 0x0100000000ffff00, 0x0100000000ff00ff, 0x0100000000ff0000, 0x0100000000ff0001, + 0x0100000000ff0100, 0x010000000000ffff, 0x010000000000ff00, 0x010000000000ff01, + 0x01000000000000ff, 0x0100000000000000, 0x0100000000000001, 0x01000000000001ff, + 0x0100000000000100, 0x0100000000000101, 0x010000000001ff00, 0x01000000000100ff, + 0x0100000000010000, 0x0100000000010001, 0x0100000000010100, 0x0100000001ffff00, + 0x0100000001ff0000, 0x0100000001ff01ff, 0x010000000100ff00, 0x010000000100ff01, + 0x01000000010000ff, 0x0100000001000000, 0x0100000001000001, 0x0100000001000100, + 0x0100000001000101, 0x010000000101ffff, 0x010000000101ff01, 0x0100000001010000, + 0x01000000010101ff, 0x0100000001010101, 0x01000001ffffff00, 0x01000001ffff00ff, + 0x01000001ff00ffff, 0x01000001ff000000, 0x01000001ff000100, 0x01000001ff01ffff, + 0x01000001ff010001, 0x01000001ff010100, 0x0100000100ff0000, 0x0100000100ff01ff, + 0x0100000100ff0100, 0x010000010000ff00, 0x010000010000ff01, 0x0100000100000000, + 0x0100000100000001, 0x0100000100000100, 0x0100000100010000, 0x01000001000101ff, + 0x0100000101ffff01, 0x0100000101ff00ff, 0x0100000101ff0100, 0x0100000101ff0101, + 0x010000010100ff01, 0x01000001010000ff, 0x0100000101000000, 0x01000001010100ff, + 0x0100000101010001, 0x0100000101010100, 0x010001ffffff0000, 0x010001ffff000001, + 0x010001ffff000100, 0x010001ffff010000, 0x010001ff00ffff00, 0x010001ff00ff0001, + 0x010001ff0000ffff, 0x010001ff0000ff01, 0x010001ff00000000, 0x010001ff00000001, + 0x010001ff00000101, 0x010001ff000100ff, 0x010001ff00010000, 0x010001ff01ff0000, + 0x010001ff0100ff00, 0x010001ff01000001, 0x010001ff01000100, 0x010001ff01010000, + 0x01000100ffff00ff, 0x01000100ffff0001, 0x01000100ffff0100, 0x01000100ff00ffff, + 0x01000100ff00ff01, 0x01000100ff000000, 0x01000100ff0001ff, 0x01000100ff000101, + 0x01000100ff01ffff, 0x01000100ff01ff00, 0x01000100ff0100ff, 0x01000100ff010001, + 0x0100010000ffffff, 0x0100010000ffff01, 0x0100010000ff0000, 0x0100010000ff01ff, + 0x0100010000ff0101, 0x010001000000ff00, 0x01000100000000ff, 0x0100010000000000, + 0x0100010000000001, 0x0100010000000100, 0x010001000001ff01, 0x0100010000010000, + 0x0100010000010001, 0x0100010000010101, 0x0100010001ffff00, 0x0100010001ff00ff, + 0x010001000100ffff, 0x010001000100ff01, 0x0100010001000000, 0x0100010001000101, + 0x010001000101ff00, 0x0100010001010001, 0x01000101ffff0000, 0x01000101ff000000, + 0x01000101ff010000, 0x0100010100ff00ff, 0x0100010100ff0001, 0x0100010100ff0100, + 0x010001010000ffff, 0x0100010100000000, 0x01000101000001ff, 0x010001010001ff00, + 0x0100010101ff0000, 0x010001010100ff00, 0x01000101010000ff, 0x0100010101000000, + 0x0100010101000001, 0x0101ffffffffffff, 0x0101ffffffffff01, 0x0101ffffffff01ff, + 0x0101ffffffff0101, 0x0101ffffff000000, 0x0101ffffff01ffff, 0x0101ffffff01ff01, + 0x0101ffffff0101ff, 0x0101ffffff010101, 0x0101ffff00ff0000, 0x0101ffff0000ff00, + 0x0101ffff000000ff, 0x0101ffff00000001, 0x0101ffff00000100, 0x0101ffff01ffffff, + 0x0101ffff01ffff01, 0x0101ffff01ff01ff, 0x0101ffff01ff0101, 0x0101ffff01000000, + 0x0101ffff0101ffff, 0x0101ffff0101ff01, 0x0101ffff010101ff, 0x0101ffff01010101, + 0x0101ff00ffff0000, 0x0101ff00ffff0100, 0x0101ff00ff00ff00, 0x0101ff00ff0000ff, + 0x0101ff00ff000001, 0x0101ff00ff000100, 0x0101ff00ff000101, 0x0101ff0000ff0001, + 0x0101ff0000ff0100, 0x0101ff000000ff00, 0x0101ff0000000000, 0x0101ff00000001ff, + 0x0101ff0000000101, 0x0101ff000001ff00, 0x0101ff00000100ff, 0x0101ff0001ff0000, + 0x0101ff000100ffff, 0x0101ff000100ff01, 0x0101ff0001000001, 0x0101ff0001000100, + 0x0101ff01ffffff01, 0x0101ff01ffff01ff, 0x0101ff01ffff0101, 0x0101ff01ff00ffff, + 0x0101ff01ff000100, 0x0101ff01ff01ff01, 0x0101ff01ff0101ff, 0x0101ff01ff010101, + 0x0101ff0100ff0000, 0x0101ff010000ff00, 0x0101ff0100000001, 0x0101ff0100000100, + 0x0101ff0100010000, 0x0101ff0101ffffff, 0x0101ff0101ffff01, 0x0101ff0101ff01ff, + 0x0101ff0101ff0101, 0x0101ff0101000000, 0x0101ff010101ffff, 0x0101ff010101ff01, + 0x0101ff01010101ff, 0x0101ff0101010101, 0x010100ffff000100, 0x010100ffff010000, + 0x010100ff00ffff00, 0x010100ff00ff00ff, 0x010100ff0000ffff, 0x010100ff000000ff, + 0x010100ff00000000, 0x010100ff000001ff, 0x010100ff00000101, 0x010100ff0001ff00, + 0x010100ff00010000, 0x010100ff00010001, 0x010100ff000101ff, 0x010100ff00010100, + 0x010100ff01ff0000, 0x01010000ffff0001, 0x01010000ffff0100, 0x01010000ff00ffff, + 0x01010000ff00ff01, 0x01010000ff000000, 0x01010000ff0001ff, 0x01010000ff010001, + 0x01010000ff010100, 0x0101000000ffff01, 0x0101000000ff0000, 0x010100000000ff00, + 0x01010000000000ff, 0x0101000000000000, 0x0101000000000001, 0x0101000000000100, + 0x0101000000010000, 0x0101000000010101, 0x0101000001ffff00, 0x0101000001ff00ff, + 0x0101000001ff0000, 0x0101000001ff0001, 0x0101000001ff0100, 0x010100000100ff01, + 0x0101000001000000, 0x01010000010001ff, 0x01010001ffff0000, 0x01010001ff00ff00, + 0x01010001ff000001, 0x01010001ff000101, 0x01010001ff01ff00, 0x01010001ff010000, + 0x0101000100ff00ff, 0x0101000100ff0001, 0x0101000100ff0101, 0x010100010000ff01, + 0x0101000100000000, 0x0101000100000001, 0x01010001000001ff, 0x010100010001ffff, + 0x010100010001ff01, 0x0101000101ff0001, 0x010100010100ffff, 0x0101000101000000, + 0x0101000101000001, 0x0101000101000100, 0x010100010101ff00, 0x01010001010100ff, + 0x0101000101010001, 0x010101ffffffffff, 0x010101ffffffff01, 0x010101ffffff01ff, + 0x010101ffffff0101, 0x010101ffff01ffff, 0x010101ffff01ff01, 0x010101ffff0101ff, + 0x010101ffff010101, 0x010101ff0000ff00, 0x010101ff000000ff, 0x010101ff00000001, + 0x010101ff00000100, 0x010101ff01ffffff, 0x010101ff01ffff01, 0x010101ff01ff01ff, + 0x010101ff01ff0101, 0x010101ff01000000, 0x010101ff0101ffff, 0x010101ff0101ff01, + 0x010101ff010101ff, 0x010101ff01010101, 0x01010100ffff0000, 0x01010100ff0000ff, + 0x01010100ff000100, 0x01010100ff01ff00, 0x01010100ff010000, 0x0101010000ffff00, + 0x010101000000ffff, 0x0101010000000000, 0x0101010000000101, 0x010101000001ff00, + 0x0101010000010001, 0x0101010000010100, 0x010101000100ffff, 0x0101010001000001, + 0x01010101ffffffff, 0x01010101ffffff01, 0x01010101ffff01ff, 0x01010101ffff0101, + 0x01010101ff01ffff, 0x01010101ff01ff01, 0x01010101ff0101ff, 0x01010101ff010101, + 0x010101010000ff00, 0x01010101000000ff, 0x0101010100000001, 0x0101010101ffffff, + 0x0101010101ffff01, 0x0101010101ff01ff, 0x0101010101ff0101, 0x0101010101000000, + 0x010101010101ffff, 0x010101010101ff01, 0x01010101010101ff, 0x0101010101010101, +GGML_TABLE_END() +#else +GGML_TABLE_BEGIN(uint32_t, iq1s_grid_gpu, NGRID_IQ1S) + 0x00000000, 0x00000002, 0x00000101, 0x00000200, 0x00000202, 0x00010001, 0x00010101, 0x00020000, + 0x00020002, 0x00020200, 0x00020202, 0x01000101, 0x01010001, 0x01010100, 0x01010102, 0x01020101, + 0x02000000, 0x02000002, 0x02000200, 0x02000202, 0x02010101, 0x02020000, 0x02020002, 0x02020200, + 0x02020202, 0x00000110, 0x00000111, 0x00010011, 0x00010110, 0x00010112, 0x00010211, 0x00010212, + 0x00020111, 0x01000011, 0x01000112, 0x01000211, 0x01010012, 0x01010111, 0x01010212, 0x01020011, + 0x01020110, 0x01020112, 0x01020210, 0x02000111, 0x02010011, 0x02010110, 0x02010112, 0x02020111, + 0x00000020, 0x00000022, 0x00000220, 0x00000222, 0x00010121, 0x00020020, 0x00020022, 0x00020220, + 0x00020222, 0x01000121, 0x01010021, 0x01010221, 0x01020120, 0x01020221, 0x02000020, 0x02000022, + 0x02000220, 0x02000222, 0x02010021, 0x02010121, 0x02010221, 0x02020020, 0x02020022, 0x02020220, + 0x02020222, 0x00011001, 0x00011100, 0x00011102, 0x00021101, 0x01001001, 0x01001201, 0x01011101, + 0x01011202, 0x01021100, 0x01021101, 0x02011001, 0x02011201, 0x02021101, 0x00001011, 0x00001110, + 0x00001111, 0x00001112, 0x00011111, 0x00011210, 0x00011212, 0x00021211, 0x01001010, 0x01001111, + 0x01001212, 0x01011010, 0x01011011, 0x01011110, 0x01011111, 0x01011112, 0x01011211, 0x01021010, + 0x01021012, 0x01021111, 0x01021210, 0x01021212, 0x02001011, 0x02011011, 0x02011111, 0x02011210, + 0x02011212, 0x02021011, 0x02021110, 0x02021111, 0x02021112, 0x02021211, 0x00011120, 0x00011221, + 0x01001021, 0x01001120, 0x01011020, 0x01011022, 0x01011121, 0x01011220, 0x01021020, 0x01021021, + 0x01021122, 0x01021221, 0x02001121, 0x02011021, 0x02011120, 0x02011221, 0x00002000, 0x00002002, + 0x00002200, 0x00002202, 0x00012101, 0x00022000, 0x00022002, 0x00022200, 0x00022202, 0x01002101, + 0x01012001, 0x01012102, 0x01022101, 0x02002000, 0x02002002, 0x02002200, 0x02002202, 0x02012101, + 0x02022000, 0x02022002, 0x02022200, 0x02022202, 0x00002111, 0x00012011, 0x00012110, 0x00012211, + 0x00022110, 0x00022111, 0x01002011, 0x01012010, 0x01012011, 0x01012111, 0x01022011, 0x01022110, + 0x01022211, 0x02012011, 0x02012110, 0x02012112, 0x02012211, 0x02022111, 0x00002020, 0x00002022, + 0x00002220, 0x00002222, 0x00012121, 0x00022020, 0x00022022, 0x00022220, 0x00022222, 0x01002121, + 0x01012021, 0x01012221, 0x01022021, 0x01022121, 0x02002020, 0x02002022, 0x02002121, 0x02002220, + 0x02002222, 0x02012121, 0x02022020, 0x02022022, 0x02022220, 0x02022222, 0x00110000, 0x00110001, + 0x00110100, 0x00110201, 0x00120100, 0x00120101, 0x01100001, 0x01100100, 0x01110000, 0x01110101, + 0x01110200, 0x01120001, 0x01120100, 0x01120101, 0x01120201, 0x02110001, 0x02110100, 0x02110102, + 0x02120001, 0x02120101, 0x00100011, 0x00100110, 0x00100112, 0x00100211, 0x00110010, 0x00110012, + 0x00110111, 0x00110210, 0x00120011, 0x00120110, 0x00120211, 0x01100111, 0x01100212, 0x01110010, + 0x01110011, 0x01110012, 0x01110110, 0x01110111, 0x01110112, 0x01110211, 0x01120010, 0x01120111, + 0x02100110, 0x02110012, 0x02110111, 0x02120011, 0x02120110, 0x00110021, 0x00110120, 0x00110122, + 0x00120121, 0x01100020, 0x01100122, 0x01100221, 0x01110022, 0x01110121, 0x01110220, 0x01110222, + 0x01120120, 0x01120122, 0x02100121, 0x02110021, 0x02110120, 0x02110122, 0x02120121, 0x00101001, + 0x00101102, 0x00101201, 0x00111100, 0x00111101, 0x00111200, 0x00111201, 0x00121001, 0x00121102, + 0x01101001, 0x01101101, 0x01101102, 0x01101200, 0x01101202, 0x01111001, 0x01111100, 0x01111101, + 0x01111102, 0x01111201, 0x01121002, 0x01121101, 0x01121200, 0x02101100, 0x02101201, 0x02111000, + 0x02111100, 0x02111101, 0x02111200, 0x02111201, 0x02111202, 0x02121001, 0x02121100, 0x02121101, + 0x02121201, 0x00101012, 0x00101111, 0x00101212, 0x00111011, 0x00111110, 0x00111111, 0x00111112, + 0x00111211, 0x00121010, 0x00121012, 0x00121111, 0x00121210, 0x00121212, 0x01101011, 0x01101110, + 0x01101111, 0x01101112, 0x01111011, 0x01111012, 0x01111110, 0x01111111, 0x01111112, 0x01111211, + 0x01111212, 0x01121011, 0x01121110, 0x01121111, 0x01121112, 0x01121211, 0x02101010, 0x02101012, + 0x02101110, 0x02101111, 0x02101210, 0x02101212, 0x02111010, 0x02111011, 0x02111110, 0x02111111, + 0x02111112, 0x02111211, 0x02111212, 0x02121010, 0x02121012, 0x02121111, 0x00101021, 0x00101120, + 0x00101121, 0x00101122, 0x00111121, 0x00111122, 0x00111220, 0x00111222, 0x00121021, 0x00121122, + 0x01101020, 0x01101022, 0x01101120, 0x01101121, 0x01101220, 0x01101222, 0x01111021, 0x01111121, + 0x01111122, 0x01111220, 0x01111221, 0x01121021, 0x01121120, 0x01121121, 0x01121220, 0x01121221, + 0x01121222, 0x02101122, 0x02101222, 0x02111022, 0x02111121, 0x02121120, 0x02121221, 0x00112001, + 0x00112102, 0x00122101, 0x01102001, 0x01102100, 0x01102102, 0x01102201, 0x01112000, 0x01112101, + 0x01112200, 0x01112202, 0x01122000, 0x01122001, 0x01122100, 0x01122102, 0x01122201, 0x02102101, + 0x02112001, 0x02112100, 0x02122101, 0x00112010, 0x00112012, 0x00112111, 0x00112212, 0x00122011, + 0x00122111, 0x01102012, 0x01102110, 0x01102111, 0x01102210, 0x01112011, 0x01112110, 0x01112111, + 0x01112112, 0x01112211, 0x01112212, 0x01122010, 0x01122111, 0x01122212, 0x02102211, 0x02112011, + 0x02112012, 0x02112111, 0x02112210, 0x02122011, 0x02122112, 0x02122211, 0x00102221, 0x00112122, + 0x00122120, 0x00122122, 0x01102120, 0x01102122, 0x01102221, 0x01112020, 0x01112022, 0x01112121, + 0x01112220, 0x01122021, 0x01122122, 0x01122221, 0x02102121, 0x02112021, 0x02112122, 0x02112222, + 0x00200000, 0x00200002, 0x00200200, 0x00200202, 0x00210101, 0x00220000, 0x00220002, 0x00220101, + 0x00220200, 0x00220202, 0x01200101, 0x01210001, 0x01210201, 0x01220001, 0x01220101, 0x02200000, + 0x02200002, 0x02200200, 0x02200202, 0x02210101, 0x02220000, 0x02220002, 0x02220101, 0x02220200, + 0x02220202, 0x00200111, 0x00210011, 0x00210110, 0x00210211, 0x00220111, 0x01200012, 0x01200110, + 0x01200211, 0x01210111, 0x01210210, 0x01210212, 0x01220011, 0x01220110, 0x01220111, 0x01220112, + 0x02200111, 0x02210010, 0x02210112, 0x02210211, 0x02220111, 0x00200021, 0x00200220, 0x00200222, + 0x00210021, 0x00210121, 0x00220020, 0x00220022, 0x00220220, 0x00220222, 0x01200121, 0x01210021, + 0x01210122, 0x01210221, 0x01220121, 0x02200021, 0x02200220, 0x02200222, 0x02210021, 0x02210121, + 0x02220020, 0x02220022, 0x02220220, 0x02220222, 0x00201101, 0x00211100, 0x00211102, 0x00211201, + 0x00221101, 0x01201100, 0x01201101, 0x01201102, 0x01201201, 0x01211002, 0x01211101, 0x01211200, + 0x01211202, 0x01221102, 0x02201101, 0x02211001, 0x02211100, 0x02211201, 0x02221001, 0x02221101, + 0x00201211, 0x00211111, 0x00221011, 0x00221211, 0x01201010, 0x01201111, 0x01201210, 0x01211011, + 0x01211110, 0x01211111, 0x01211211, 0x01221012, 0x01221111, 0x01221210, 0x02201211, 0x02211010, + 0x02211110, 0x02211111, 0x02211210, 0x02211212, 0x02221011, 0x02221110, 0x02221112, 0x02221211, + 0x00201121, 0x00211020, 0x00211022, 0x00211221, 0x00221121, 0x01201021, 0x01201221, 0x01211121, + 0x01221020, 0x01221021, 0x01221221, 0x02201120, 0x02201122, 0x02211020, 0x02211222, 0x00202000, + 0x00202002, 0x00202200, 0x00202202, 0x00212101, 0x00222000, 0x00222002, 0x00222200, 0x00222202, + 0x01202101, 0x01212001, 0x01212100, 0x01222101, 0x02202000, 0x02202002, 0x02202200, 0x02202202, + 0x02222000, 0x02222002, 0x02222200, 0x02222202, 0x00202211, 0x00212011, 0x00212110, 0x00212211, + 0x00222111, 0x01202112, 0x01202211, 0x01212012, 0x01212111, 0x01222011, 0x01222110, 0x01222112, + 0x01222211, 0x02202111, 0x02212010, 0x02212112, 0x02212211, 0x02222110, 0x02222111, 0x00202020, + 0x00202022, 0x00202220, 0x00202222, 0x00222020, 0x00222022, 0x00222220, 0x00222222, 0x01202121, + 0x01212021, 0x01212122, 0x01212221, 0x01222121, 0x02202020, 0x02202022, 0x02202220, 0x02202222, + 0x02212121, 0x02222020, 0x02222022, 0x02222220, 0x02222222, 0x10000101, 0x10010001, 0x10010102, + 0x10020101, 0x11000201, 0x11010002, 0x11010101, 0x11010200, 0x11010202, 0x11020001, 0x11020100, + 0x11020102, 0x12010100, 0x12010201, 0x12020001, 0x12020102, 0x10000010, 0x10000011, 0x10000110, + 0x10000112, 0x10000211, 0x10010012, 0x10010111, 0x10010112, 0x10010210, 0x10010212, 0x10020011, + 0x10020112, 0x10020211, 0x11000111, 0x11000210, 0x11000212, 0x11010011, 0x11010110, 0x11010111, + 0x11010112, 0x11010211, 0x11010212, 0x11020111, 0x11020210, 0x11020212, 0x12000011, 0x12000110, + 0x12000112, 0x12010010, 0x12010012, 0x12010111, 0x12020010, 0x12020011, 0x12020012, 0x10000121, + 0x10010021, 0x10010120, 0x10010122, 0x10020121, 0x11000021, 0x11010022, 0x11010121, 0x11010222, + 0x11020120, 0x11020221, 0x12000221, 0x12010120, 0x12020121, 0x10001001, 0x10011101, 0x10011201, + 0x10021201, 0x11001101, 0x11001200, 0x11001202, 0x11011001, 0x11011100, 0x11011101, 0x11011102, + 0x11021001, 0x11021002, 0x11021101, 0x11021200, 0x11021202, 0x12001001, 0x12001102, 0x12001201, + 0x12011000, 0x12011002, 0x12011101, 0x12021000, 0x12021001, 0x12021201, 0x10001011, 0x10001012, + 0x10001111, 0x10001212, 0x10011011, 0x10011110, 0x10011111, 0x10011112, 0x10011211, 0x10021010, + 0x10021111, 0x10021212, 0x11001011, 0x11001110, 0x11001111, 0x11001112, 0x11001211, 0x11011010, + 0x11011011, 0x11011110, 0x11011111, 0x11011112, 0x11011210, 0x11011211, 0x11021011, 0x11021110, + 0x11021111, 0x11021112, 0x11021211, 0x12001012, 0x12001110, 0x12001111, 0x12001210, 0x12011011, + 0x12011110, 0x12011111, 0x12011112, 0x12011211, 0x12011212, 0x12021111, 0x12021210, 0x12021212, + 0x10001021, 0x10001121, 0x10001221, 0x10011120, 0x10011121, 0x10011220, 0x10011222, 0x10021021, + 0x10021120, 0x10021221, 0x11001020, 0x11001022, 0x11001121, 0x11001220, 0x11011020, 0x11011021, + 0x11011022, 0x11011121, 0x11011122, 0x11011221, 0x11021022, 0x11021121, 0x11021220, 0x12001021, + 0x12001121, 0x12001222, 0x12011120, 0x12011121, 0x12021021, 0x12021120, 0x12021122, 0x10002101, + 0x10012001, 0x10012101, 0x10012202, 0x10022101, 0x11002002, 0x11002201, 0x11012000, 0x11012101, + 0x11012200, 0x11022001, 0x11022100, 0x11022102, 0x11022201, 0x12002101, 0x12012001, 0x12012100, + 0x12012102, 0x12012201, 0x12022101, 0x10002011, 0x10002111, 0x10002112, 0x10002212, 0x10012010, + 0x10012110, 0x10012111, 0x10012210, 0x10022011, 0x10022110, 0x10022112, 0x11002010, 0x11002111, + 0x11002212, 0x11012011, 0x11012012, 0x11012110, 0x11012111, 0x11012112, 0x11012211, 0x11022010, + 0x11022012, 0x11022111, 0x11022112, 0x11022212, 0x12002112, 0x12002211, 0x12012012, 0x12012111, + 0x12012112, 0x12012210, 0x12022011, 0x12022110, 0x12022112, 0x12022211, 0x10012122, 0x11002120, + 0x11002122, 0x11002221, 0x11012121, 0x11012220, 0x11012222, 0x11022120, 0x11022221, 0x12012120, + 0x12022121, 0x10100001, 0x10100100, 0x10100101, 0x10100102, 0x10100201, 0x10110002, 0x10110101, + 0x10110202, 0x10120001, 0x10120100, 0x10120201, 0x11100000, 0x11100101, 0x11100200, 0x11110001, + 0x11110100, 0x11110101, 0x11110102, 0x11110201, 0x11120101, 0x11120200, 0x12100102, 0x12100201, + 0x12110101, 0x12110200, 0x12120000, 0x12120001, 0x12120102, 0x12120201, 0x10100111, 0x10100210, + 0x10100211, 0x10100212, 0x10110011, 0x10110110, 0x10110111, 0x10110112, 0x10110210, 0x10110211, + 0x10120010, 0x10120111, 0x10120112, 0x10120210, 0x10120212, 0x11100011, 0x11100110, 0x11100111, + 0x11100112, 0x11100211, 0x11110010, 0x11110011, 0x11110012, 0x11110110, 0x11110111, 0x11110112, + 0x11110210, 0x11110211, 0x11110212, 0x11120011, 0x11120110, 0x11120111, 0x11120112, 0x11120211, + 0x12100012, 0x12100111, 0x12110011, 0x12110110, 0x12110111, 0x12110112, 0x12110211, 0x12120010, + 0x12120111, 0x12120212, 0x10100021, 0x10100122, 0x10110022, 0x10110121, 0x10110222, 0x10120021, + 0x10120120, 0x11100022, 0x11100121, 0x11100222, 0x11110021, 0x11110120, 0x11110121, 0x11110122, + 0x11110221, 0x11120022, 0x11120121, 0x12100121, 0x12110020, 0x12110022, 0x12110121, 0x12110221, + 0x12110222, 0x12120120, 0x10101100, 0x10101101, 0x10111001, 0x10111100, 0x10111101, 0x10111102, + 0x10111200, 0x10111201, 0x10121001, 0x10121101, 0x10121200, 0x10121202, 0x11101001, 0x11101100, + 0x11101101, 0x11101102, 0x11101201, 0x11101202, 0x11111000, 0x11111001, 0x11111100, 0x11111101, + 0x11111102, 0x11111200, 0x11111201, 0x11111202, 0x11121001, 0x11121002, 0x11121100, 0x11121101, + 0x11121102, 0x11121201, 0x12101000, 0x12101200, 0x12101202, 0x12111001, 0x12111100, 0x12111101, + 0x12111102, 0x12111201, 0x12121001, 0x12121100, 0x12121101, 0x12121202, 0x10101011, 0x10101012, + 0x10101110, 0x10101111, 0x10101112, 0x10101211, 0x10111010, 0x10111011, 0x10111012, 0x10111110, + 0x10111111, 0x10111112, 0x10111211, 0x10111212, 0x10121011, 0x10121110, 0x10121111, 0x10121112, + 0x10121211, 0x11101010, 0x11101011, 0x11101012, 0x11101110, 0x11101111, 0x11101112, 0x11101210, + 0x11101211, 0x11111010, 0x11111011, 0x11111012, 0x11111110, 0x11111111, 0x11111112, 0x11111210, + 0x11111211, 0x11111212, 0x11121010, 0x11121011, 0x11121110, 0x11121111, 0x11121112, 0x11121210, + 0x11121211, 0x11121212, 0x12101011, 0x12101110, 0x12101111, 0x12101211, 0x12101212, 0x12111010, + 0x12111011, 0x12111110, 0x12111111, 0x12111112, 0x12111210, 0x12111211, 0x12121011, 0x12121110, + 0x12121111, 0x12121112, 0x12121211, 0x10101020, 0x10101021, 0x10101022, 0x10101120, 0x10101122, + 0x10101220, 0x10101221, 0x10111021, 0x10111120, 0x10111121, 0x10111220, 0x10111221, 0x10121020, + 0x10121021, 0x10121022, 0x10121120, 0x10121121, 0x10121122, 0x10121220, 0x10121221, 0x11101021, + 0x11101121, 0x11101122, 0x11101220, 0x11101221, 0x11101222, 0x11111020, 0x11111021, 0x11111022, + 0x11111120, 0x11111121, 0x11111122, 0x11111220, 0x11111221, 0x11111222, 0x11121021, 0x11121120, + 0x11121121, 0x11121221, 0x12101022, 0x12101121, 0x12101122, 0x12101220, 0x12101221, 0x12101222, + 0x12111021, 0x12111121, 0x12111222, 0x12121022, 0x12121121, 0x12121122, 0x12121220, 0x12121221, + 0x10102100, 0x10102101, 0x10102102, 0x10102201, 0x10112000, 0x10112101, 0x10112200, 0x10122001, + 0x10122202, 0x11102101, 0x11102200, 0x11102202, 0x11112001, 0x11112100, 0x11112101, 0x11112102, + 0x11112200, 0x11112201, 0x11122000, 0x11122002, 0x11122100, 0x11122101, 0x12102002, 0x12102201, + 0x12112000, 0x12112002, 0x12112101, 0x12112200, 0x12122001, 0x12122201, 0x10102011, 0x10102012, + 0x10102111, 0x10102212, 0x10112011, 0x10112110, 0x10112111, 0x10112112, 0x10112211, 0x10122111, + 0x11102011, 0x11102110, 0x11102111, 0x11102112, 0x11102211, 0x11112010, 0x11112011, 0x11112012, + 0x11112110, 0x11112111, 0x11112112, 0x11112210, 0x11112211, 0x11112212, 0x11122011, 0x11122110, + 0x11122111, 0x11122112, 0x11122211, 0x12102011, 0x12102111, 0x12102211, 0x12112011, 0x12112110, + 0x12112111, 0x12112112, 0x12112210, 0x12112211, 0x12122111, 0x10102120, 0x10102220, 0x10112121, + 0x10112222, 0x10122020, 0x10122121, 0x10122122, 0x10122221, 0x11102121, 0x11102220, 0x11102221, + 0x11112021, 0x11112121, 0x11112122, 0x11112220, 0x11112221, 0x11122022, 0x11122121, 0x11122220, + 0x11122222, 0x12102021, 0x12102222, 0x12112022, 0x12112121, 0x12112122, 0x12112220, 0x12112222, + 0x12122021, 0x10200101, 0x10210100, 0x10210102, 0x10210201, 0x10220101, 0x11200100, 0x11210000, + 0x11210101, 0x11210102, 0x11210200, 0x11210202, 0x11220001, 0x11220100, 0x11220102, 0x11220201, + 0x12200001, 0x12210102, 0x12220101, 0x10200011, 0x10200110, 0x10200112, 0x10200211, 0x10210012, + 0x10210111, 0x10220011, 0x10220012, 0x10220112, 0x10220211, 0x11200111, 0x11200211, 0x11210011, + 0x11210111, 0x11210112, 0x11210211, 0x11220111, 0x11220112, 0x11220212, 0x12200110, 0x12200212, + 0x12210012, 0x12210111, 0x12220011, 0x12220112, 0x12220211, 0x10210021, 0x10210122, 0x10210221, + 0x11200020, 0x11200021, 0x11200122, 0x11210121, 0x11210122, 0x11210220, 0x11220020, 0x12200121, + 0x12210021, 0x12210122, 0x12220121, 0x10211001, 0x10211002, 0x10211101, 0x10211102, 0x10211202, + 0x10221001, 0x10221102, 0x10221201, 0x11201000, 0x11201002, 0x11201101, 0x11201200, 0x11201202, + 0x11211001, 0x11211100, 0x11211101, 0x11211102, 0x11211201, 0x11211202, 0x11221000, 0x11221002, + 0x11221101, 0x12201100, 0x12201101, 0x12201201, 0x12211000, 0x12211002, 0x12211100, 0x12211101, + 0x12211102, 0x12211200, 0x12211202, 0x12221001, 0x12221100, 0x12221201, 0x10201111, 0x10201210, + 0x10201212, 0x10211011, 0x10211111, 0x10211112, 0x10211211, 0x11201110, 0x11201111, 0x11201112, + 0x11201211, 0x11211010, 0x11211011, 0x11211110, 0x11211111, 0x11211112, 0x11211211, 0x11221011, + 0x11221110, 0x11221111, 0x11221112, 0x11221211, 0x12201112, 0x12201211, 0x12201212, 0x12211011, + 0x12211111, 0x12211112, 0x12211211, 0x12211212, 0x12221012, 0x12221111, 0x12221112, 0x12221210, + 0x10201022, 0x10201221, 0x10211121, 0x10221020, 0x10221122, 0x10221220, 0x10221221, 0x11201020, + 0x11201121, 0x11201220, 0x11201222, 0x11211021, 0x11211120, 0x11211121, 0x11211122, 0x11211220, + 0x11211222, 0x11221020, 0x11221121, 0x11221220, 0x12201020, 0x12201022, 0x12201121, 0x12201222, + 0x12211120, 0x12211122, 0x12211220, 0x12211221, 0x12221020, 0x12221120, 0x12221122, 0x12221222, + 0x10212102, 0x10212201, 0x10222101, 0x11202001, 0x11212002, 0x11212101, 0x11212202, 0x11222001, + 0x11222201, 0x12202101, 0x12212001, 0x12212200, 0x12222102, 0x10202011, 0x10202110, 0x10212010, + 0x10212111, 0x10222011, 0x10222110, 0x10222112, 0x10222211, 0x11202010, 0x11202011, 0x11202111, + 0x11202112, 0x11202210, 0x11212011, 0x11212110, 0x11212111, 0x11212112, 0x11212211, 0x11222010, + 0x11222111, 0x11222212, 0x12202012, 0x12202110, 0x12202212, 0x12212111, 0x12222011, 0x12222110, + 0x12222111, 0x12222211, 0x10212021, 0x10212122, 0x10212220, 0x11202021, 0x11202120, 0x11202221, + 0x11212020, 0x11212121, 0x11212220, 0x11212222, 0x11222120, 0x11222121, 0x11222221, 0x12202122, + 0x12212120, 0x12212220, 0x12212222, 0x12222122, 0x20000000, 0x20000002, 0x20000200, 0x20000202, + 0x20020000, 0x20020002, 0x20020200, 0x20020202, 0x21000101, 0x21010000, 0x21010001, 0x21010100, + 0x21010102, 0x21010201, 0x21020101, 0x22000000, 0x22000002, 0x22000200, 0x22000202, 0x22010101, + 0x22020000, 0x22020002, 0x22020200, 0x22020202, 0x20000111, 0x20010011, 0x20010110, 0x20010112, + 0x20010211, 0x20020111, 0x21000011, 0x21000110, 0x21000211, 0x21010010, 0x21010012, 0x21010111, + 0x21010112, 0x21010210, 0x21010211, 0x21020110, 0x21020112, 0x21020211, 0x22000111, 0x22000211, + 0x22010110, 0x22010112, 0x22010211, 0x22020111, 0x20000020, 0x20000022, 0x20000220, 0x20000222, + 0x20010121, 0x20020020, 0x20020022, 0x20020220, 0x20020222, 0x21010021, 0x21010120, 0x21010221, + 0x21020121, 0x22000020, 0x22000022, 0x22000220, 0x22000222, 0x22010121, 0x22020020, 0x22020022, + 0x22020220, 0x22020222, 0x20011100, 0x20011201, 0x21001001, 0x21001100, 0x21011001, 0x21011101, + 0x21011202, 0x21021001, 0x21021100, 0x21021201, 0x22011100, 0x22011201, 0x20001011, 0x20001211, + 0x20011012, 0x20011111, 0x20011212, 0x20021112, 0x20021211, 0x21001010, 0x21001011, 0x21001111, + 0x21001210, 0x21011011, 0x21011110, 0x21011111, 0x21011112, 0x21011211, 0x21011212, 0x21021111, + 0x21021112, 0x21021210, 0x21021212, 0x22001011, 0x22001110, 0x22001112, 0x22001211, 0x22011010, + 0x22011012, 0x22011111, 0x22011210, 0x22021112, 0x20011021, 0x20011122, 0x20011221, 0x20021121, + 0x21001021, 0x21001120, 0x21001221, 0x21001222, 0x21011020, 0x21011121, 0x21011221, 0x21011222, + 0x21021021, 0x21021122, 0x21021222, 0x22001121, 0x22011021, 0x22011222, 0x22021120, 0x20002000, + 0x20002002, 0x20002200, 0x20002202, 0x20012101, 0x20022000, 0x20022002, 0x20022200, 0x20022202, + 0x21002001, 0x21002101, 0x21012001, 0x21012100, 0x21012201, 0x21022101, 0x21022201, 0x22002000, + 0x22002002, 0x22002200, 0x22002202, 0x22012101, 0x22022000, 0x22022002, 0x22022200, 0x22022202, + 0x20002111, 0x20002112, 0x20012011, 0x20012110, 0x20012112, 0x20022111, 0x21002011, 0x21002110, + 0x21002112, 0x21002211, 0x21012010, 0x21012012, 0x21012111, 0x21012212, 0x21022011, 0x21022110, + 0x22002111, 0x22012112, 0x22012211, 0x22022111, 0x20002020, 0x20002022, 0x20002220, 0x20002222, + 0x20012121, 0x20022020, 0x20022022, 0x20022220, 0x20022222, 0x21002121, 0x21012021, 0x21012120, + 0x21012122, 0x22002020, 0x22002022, 0x22002220, 0x22002222, 0x22012121, 0x22022020, 0x22022022, + 0x22022220, 0x22022222, 0x20100101, 0x20110001, 0x20110102, 0x20110200, 0x20110201, 0x20120101, + 0x21100001, 0x21100102, 0x21100201, 0x21110101, 0x21110200, 0x21110202, 0x21120201, 0x21120202, + 0x22100101, 0x22110001, 0x22110100, 0x22110102, 0x22110201, 0x22120101, 0x20100011, 0x20100110, + 0x20100112, 0x20100211, 0x20110010, 0x20110111, 0x20110210, 0x20110212, 0x20120011, 0x20120110, + 0x20120112, 0x20120211, 0x21100010, 0x21100111, 0x21110010, 0x21110011, 0x21110110, 0x21110111, + 0x21110112, 0x21110211, 0x21120012, 0x21120111, 0x22100110, 0x22100112, 0x22110012, 0x22110111, + 0x22110210, 0x22120011, 0x22120110, 0x22120112, 0x22120211, 0x20100121, 0x20110021, 0x20110120, + 0x20110221, 0x20120121, 0x21100120, 0x21100122, 0x21100221, 0x21110020, 0x21110022, 0x21110121, + 0x21110220, 0x21120122, 0x21120221, 0x22100121, 0x22110120, 0x22110122, 0x22120221, 0x20101001, + 0x20101100, 0x20101102, 0x20111000, 0x20111101, 0x20111200, 0x20121102, 0x21101000, 0x21101202, + 0x21111001, 0x21111100, 0x21111101, 0x21111102, 0x21111200, 0x21111201, 0x21121000, 0x21121001, + 0x21121002, 0x21121101, 0x22101100, 0x22101102, 0x22111002, 0x22111100, 0x22111101, 0x22111200, + 0x22121001, 0x22121201, 0x20101010, 0x20101111, 0x20101210, 0x20101212, 0x20111010, 0x20111011, + 0x20111110, 0x20111111, 0x20111112, 0x20111211, 0x20121011, 0x20121111, 0x20121211, 0x20121212, + 0x21101011, 0x21101110, 0x21101111, 0x21101112, 0x21101211, 0x21111010, 0x21111011, 0x21111012, + 0x21111110, 0x21111111, 0x21111112, 0x21111210, 0x21111211, 0x21111212, 0x21121011, 0x21121110, + 0x21121111, 0x21121112, 0x21121211, 0x22101011, 0x22101111, 0x22101210, 0x22111011, 0x22111012, + 0x22111110, 0x22111111, 0x22111112, 0x22111211, 0x22111212, 0x22121010, 0x22121012, 0x22121111, + 0x22121210, 0x22121212, 0x20101021, 0x20101120, 0x20111020, 0x20111121, 0x20111221, 0x20121020, + 0x20121122, 0x20121221, 0x21101121, 0x21101220, 0x21101221, 0x21111021, 0x21111022, 0x21111121, + 0x21111122, 0x21111221, 0x21121121, 0x21121220, 0x22101022, 0x22101120, 0x22101221, 0x22101222, + 0x22111022, 0x22111120, 0x22111121, 0x22121120, 0x22121122, 0x22121221, 0x20102101, 0x20112102, + 0x20112201, 0x20122101, 0x21102001, 0x21102102, 0x21112000, 0x21112002, 0x21112101, 0x21112102, + 0x21112202, 0x21122100, 0x21122101, 0x22102101, 0x22112001, 0x22112102, 0x22112201, 0x22122101, + 0x20102110, 0x20102112, 0x20102211, 0x20112010, 0x20112012, 0x20112111, 0x20112210, 0x20112212, + 0x20122010, 0x20122011, 0x20122110, 0x20122112, 0x21102010, 0x21102012, 0x21102111, 0x21102210, + 0x21102212, 0x21112011, 0x21112110, 0x21112111, 0x21112112, 0x21112211, 0x21122012, 0x21122111, + 0x21122112, 0x21122212, 0x22102011, 0x22102110, 0x22112010, 0x22112012, 0x22112111, 0x22112212, + 0x22122011, 0x22122112, 0x20102121, 0x20112121, 0x20122121, 0x21102120, 0x21102122, 0x21102221, + 0x21112020, 0x21112121, 0x21112220, 0x21122021, 0x22102121, 0x22112021, 0x22112120, 0x22112121, + 0x22112122, 0x20200000, 0x20200002, 0x20200200, 0x20200202, 0x20210101, 0x20220000, 0x20220002, + 0x20220200, 0x20220202, 0x21200101, 0x21210001, 0x21210100, 0x21210102, 0x21210201, 0x22200000, + 0x22200002, 0x22200200, 0x22200202, 0x22210101, 0x22220000, 0x22220002, 0x22220200, 0x22220202, + 0x20200111, 0x20200211, 0x20210011, 0x20210110, 0x20210112, 0x20210211, 0x20210212, 0x21200112, + 0x21200211, 0x21210011, 0x21210111, 0x21210210, 0x21210212, 0x21220011, 0x21220110, 0x22200111, + 0x22210010, 0x22210012, 0x22210112, 0x22210211, 0x20200022, 0x20200220, 0x20200222, 0x20210020, + 0x20210221, 0x20220022, 0x20220220, 0x20220222, 0x21200121, 0x21210021, 0x21210122, 0x21210221, + 0x21220121, 0x22200020, 0x22200022, 0x22200220, 0x22200222, 0x22210121, 0x22220020, 0x22220022, + 0x22220220, 0x22220222, 0x20211201, 0x20221101, 0x21201001, 0x21201100, 0x21211000, 0x21211100, + 0x21211101, 0x21211200, 0x21211202, 0x21221001, 0x21221101, 0x21221102, 0x21221200, 0x21221201, + 0x22201101, 0x20201112, 0x20201211, 0x20211010, 0x20211012, 0x20211111, 0x20211210, 0x20221112, + 0x20221211, 0x21201012, 0x21201111, 0x21211011, 0x21211110, 0x21211111, 0x21211112, 0x21211211, + 0x21221111, 0x21221212, 0x22201011, 0x22201110, 0x22201111, 0x22201112, 0x22201211, 0x22211012, + 0x22211111, 0x22211210, 0x20201121, 0x20211021, 0x20211122, 0x20211222, 0x20221021, 0x20221121, + 0x21201120, 0x21201122, 0x21201222, 0x21211022, 0x21211121, 0x21211122, 0x21211220, 0x21221020, + 0x21221022, 0x22201122, 0x22211020, 0x22211121, 0x22211122, 0x22211221, 0x22221021, 0x22221120, + 0x22221122, 0x20202000, 0x20202002, 0x20202200, 0x20202202, 0x20222000, 0x20222002, 0x20222200, + 0x20222202, 0x21212001, 0x21212100, 0x21212102, 0x21212201, 0x22202000, 0x22202002, 0x22202200, + 0x22202202, 0x22212101, 0x22222000, 0x22222002, 0x22222200, 0x22222202, 0x20202111, 0x20212110, + 0x20212211, 0x20222011, 0x20222111, 0x21202011, 0x21212010, 0x21212111, 0x21212212, 0x21222011, + 0x21222112, 0x21222211, 0x22212010, 0x22212112, 0x20202020, 0x20202022, 0x20202220, 0x20202222, + 0x20222020, 0x20222022, 0x20222220, 0x20222222, 0x21212021, 0x21212120, 0x21212122, 0x22202020, + 0x22202022, 0x22202220, 0x22202222, 0x22212121, 0x22222020, 0x22222022, 0x22222220, 0x22222222, +GGML_TABLE_END() +#endif + +#endif // GGML_COMMON_IMPL +#endif // GGML_COMMON_IMPL diff --git a/tools/_paths.py b/tools/_paths.py new file mode 100644 index 00000000..aaea7300 --- /dev/null +++ b/tools/_paths.py @@ -0,0 +1,28 @@ +"""tools/_paths.py - where the tools find llama.cpp's gguf-py (for the MTP packer and the fixtures). + +Order: $STRATA_GGUF_PY, then /third_party/llama.cpp/gguf-py (what setup.py clones), then the development +tree's ../../.ref/llama.cpp/gguf-py. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ENGINE = Path(__file__).resolve().parents[1] + + +def gguf_py() -> str: + cands = [os.environ.get("STRATA_GGUF_PY"), ENGINE / "third_party" / "llama.cpp" / "gguf-py", + ENGINE.parents[1] / ".ref" / "llama.cpp" / "gguf-py"] + for c in cands: + if c and (Path(c) / "gguf").is_dir(): + return str(c) + sys.exit("llama.cpp's gguf-py was not found: run setup.py (it clones llama.cpp into third_party/), " + "or set STRATA_GGUF_PY") + + +def add_gguf_py() -> None: + p = gguf_py() + if p not in sys.path: + sys.path.insert(0, p) diff --git a/tools/canonical_xcheck.py b/tools/canonical_xcheck.py new file mode 100644 index 00000000..e83819b3 --- /dev/null +++ b/tools/canonical_xcheck.py @@ -0,0 +1,799 @@ +"""tools/canonical_xcheck.py - P1.T3 in prototype: does the canonical form round-trip BIT-EXACTLY? + +P1.S6's release-blocking test is `dequant(canonical) == dequant_ggml(original)` bit for bit, for every tensor +of the real GGUF. This is that test in Python, before the C++ exists - the same order that worked for `ref/` +(the oracle before the engine), and faster to iterate. + +It starts with `Q2_0 -> S2`, which is both the largest mapping (31.64 GiB, the 202 routed-expert tensors) and +the one whose codebook has a non-obvious feature: + + dequantize_row_q2_0: value[j] = (code[j] - 1) * d code in {0,1,2,3} + +**The `- 1` is an offset in the codebook** - the representable values are {-d, 0, d, 2d}, not {0, d, 2d, 3d}. +A canonical form that stored the codes as unsigned 0..3 and decoded `code * d` would look right on most values +and be wrong on all of them by one step. That is why the source expression is transcribed rather than +re-derived, and why this test exists. + +`docs/pack-format.md` §2.1 says Q2_0 -> S2 splits codes and scales into separate planes with NO value change. +This checks exactly that claim. + +Run: python tools/canonical_xcheck.py [--tensors 4] [--gguf PATH] +""" +from __future__ import annotations + +import argparse +import collections +import mmap +import os +import pathlib +import struct +import sys +import time + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) +sys.path.insert(0, REPO) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(REPO, "ref")) + +import gguf_reader as G # noqa: E402 +from gguf_writer import dequantize_q2_0 # noqa: E402 +from load import _dequant_flat # noqa: E402 + +# default: $STRATA_SHARD1, else the development layout (/Q2_0 beside /Public/Engine) +SHARD1 = os.environ.get("STRATA_SHARD1") or os.path.join(os.path.dirname(os.path.dirname(REPO)), "Q2_0", + "Qwen3.8-Flash-Next-GSQ-RCO-Q2_0-00001-of-00002.gguf") + +QK = 64 # Q2_0 block: 64 elements +BLOCK_BYTES = 18 # 2-byte fp16 scale + 16 bytes of 2-bit codes + + +# ---------------------------------------------------------------- mappings +# +# Each mapping is (encode, decode, block_bytes, block_elems). The DECODE is transcribed from +# `ggml-quants.c` expression by expression - the plan's rule, and the thing that decides P1.T3. + +def to_s8_q8_0(raw: bytes): + """`Q8_0` -> S8: codes + an fp16 scale per 32 elements, `codebook` the affine `code - 128`. + + `dequantize_row_q8_0`: `y[j] = x[i].qs[j] * d` with `d = GGML_FP16_TO_FP32(x[i].d)`. + + The code plane is UNSIGNED (0..255) like every other form here, so the source's `int8_t` code is stored + `+128` and the decode subtracts 128 - the same integer-bias mechanism as Q2_0's `-1` and Q4_0's `-8`, and + bit-exact for the same reason. It used to be stored signed instead, which is the natural reading of + `int8_t qs[32]` and was self-consistent with its own decoder; the pack is what showed the ambiguity, + because the two Q8_0 tensors then decoded 128 codes away from where they belonged while all 12 other + types were fine. ONE code-plane contract, not two. + """ + n = len(raw) // 34 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 34) + # `.reshape(-1)` matters: the fp16 view of an (n,2) byte column is (n,1), and a decode doing + # `scales[:, None]` then broadcasts it to (n,1,1) - silently producing n TIMES too many elements instead + # of raising. All the encoders return 1-D scales for that reason. + scales = np.ascontiguousarray(a[:, 0:2]).view(np.float16).astype(np.float32).reshape(-1) + codes = np.ascontiguousarray(a[:, 2:34]).view(np.int8).astype(np.int16) + 128 + return codes.astype(np.uint8), scales + + +def from_s8_q8_0(parts): + codes, scales = parts + v = (codes.astype(np.int32) - 128).astype(np.float32) # the +128 is undone inside the integer + return (v * scales[:, None]).reshape(-1) + + +def to_s4_q4_0(raw: bytes): + """`Q4_0` -> S4: 4-bit codes + one fp16 scale per 32 elements. No separate offset. + + `dequantize_row_q4_0`: + x0 = (qs[j] & 0x0F) - 8 ; y[j + 0] = x0*d + x1 = (qs[j] >> 4) - 8 ; y[j + 16] = x1*d + + TWO things here are easy to get wrong and both would still produce plausible numbers: the low nibbles + fill the FIRST HALF of the block and the high nibbles the SECOND (they are NOT interleaved), and the + `- 8` is applied to the integer BEFORE the multiply, i.e. the codebook is -8..7 rather than 0..15. + """ + n = len(raw) // 18 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 18) + scales = np.ascontiguousarray(a[:, 0:2]).view(np.float16).astype(np.float32).reshape(-1) + qs = np.ascontiguousarray(a[:, 2:18]) # (n, 16) + codes = np.empty((n, 32), dtype=np.uint8) + codes[:, 0:16] = qs & 0x0F # low nibbles -> first half + codes[:, 16:32] = qs >> 4 # high nibbles -> second half + return codes, scales + + +def from_s4_q4_0(parts): + codes, scales = parts + v = (codes.astype(np.int32) - 8).astype(np.float32) # the -8 is inside the integer + return (v * scales[:, None]).reshape(-1) + + +def get_scale_min_k4(scales, j): + """`get_scale_min_k4`, ggml-quants.c L880, transcribed. + + The 6-bit scale and min of group `j` (0..7) live packed in the block's 12 scale bytes: the low four + groups keep their 6 bits in a byte, and the high four borrow the top two bits of the LOW groups' bytes. + Reading only `q[j]` gives the right answer for the first half of the groups and silently wrong ones for + the second - which is why this is transcribed rather than inferred from the layout. + """ + if j < 4: + return int(scales[j]) & 63, int(scales[j + 4]) & 63 + return ((int(scales[j + 4]) & 0xF) | ((int(scales[j - 4]) >> 6) << 4), + (int(scales[j + 4]) >> 4) | ((int(scales[j]) >> 6) << 4)) + + +def to_s4_q4_k(raw: bytes): + """`Q4_K` -> S4: 8 groups of 32 per 256-element block, each with `scale = d*sc` and `offset = -(min*m)`. + + `dequantize_row_q4_K` (L1529): + d = fp16(x.d) ; min = fp16(x.dmin) + for j in 0..256 step 64: + (sc,m) = get_scale_min_k4(is+0) ; d1 = d*sc ; m1 = min*m + (sc,m) = get_scale_min_k4(is+1) ; d2 = d*sc ; m2 = min*m + y[l] = d1 * (q[l] & 0xF) - m1 + y[l + 32] = d2 * (q[l] >> 4) - m2 + + TWO groups per 64-element step, taking the low and high nibbles of the SAME 32 scale bytes - so the + canonical group size is 32, not 64, and there are 8 groups per block. + """ + n = len(raw) // 144 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 144) + d = np.ascontiguousarray(a[:, 0:2]).view(np.float16).astype(np.float32).reshape(-1) + dmin = np.ascontiguousarray(a[:, 2:4]).view(np.float16).astype(np.float32).reshape(-1) + scb = a[:, 4:16] # (n, 12) the packed 6-bit scales and mins + qs = a[:, 16:144] # (n, 128) + + scales = np.empty((n, 8), dtype=np.float32) + offsets = np.empty((n, 8), dtype=np.float32) + for g in range(8): + sc = np.array([get_scale_min_k4(scb[i], g)[0] for i in range(n)], dtype=np.float32) + m = np.array([get_scale_min_k4(scb[i], g)[1] for i in range(n)], dtype=np.float32) + scales[:, g] = d * sc # f32 product of the same two factors ggml uses + offsets[:, g] = -(dmin * m) # negation is exact, so adding it is bit-identical + + codes = np.empty((n, 256), dtype=np.uint8) + for j in range(4): + blk = qs[:, j * 32:(j + 1) * 32] + codes[:, j * 64:j * 64 + 32] = blk & 0x0F # group is+0: LOW nibbles + codes[:, j * 64 + 32:j * 64 + 64] = blk >> 4 # group is+1: HIGH nibbles + return codes, scales, offsets + + +def from_s4_q4_k(parts): + codes, scales, offsets = parts + v = codes.astype(np.float32).reshape(codes.shape[0], 8, 32) + return (v * scales[:, :, None] + offsets[:, :, None]).reshape(-1) + + +def unpack_q3_k_scales(sb): + """The 12 scale bytes of a `Q3_K` block -> 16 six-bit scales, in group order. + + `dequantize_row_q3_K` L1323-1328, transcribed. As bytes, with `s` the 12 scale bytes: + group j (0..3) = (s[j] & 0x0F) | ((s[8+j] & 0x03) << 4) + group 4+j = (s[4+j] & 0x0F) | (((s[8+j] >> 2) & 0x03) << 4) + group 8+j = (s[j] >> 4) | (((s[8+j] >> 4) & 0x03) << 4) + group 12+j = (s[4+j] >> 4) | (((s[8+j] >> 6) & 0x03) << 4) + + The trap is the same shape as `get_scale_min_k4`'s and just as quiet: the first EIGHT groups take the + low nibbles of `s[0..7]`, the last eight the HIGH nibbles of those SAME bytes, and `s[8..11]` supply two + high bits each in that same order. A reader who assumes "12 bytes = 12 groups, one nibble each" gets + half the groups right. + """ + n = sb.shape[0] + out = np.empty((n, 16), dtype=np.int32) + for j in range(4): + t = sb[:, 8 + j].astype(np.int32) + lo, hi = sb[:, j].astype(np.int32), sb[:, 4 + j].astype(np.int32) + out[:, j] = (lo & 0x0F) | ((t & 0x03) << 4) + out[:, 4 + j] = (hi & 0x0F) | (((t >> 2) & 0x03) << 4) + out[:, 8 + j] = ((lo >> 4) & 0x0F) | (((t >> 4) & 0x03) << 4) + out[:, 12 + j] = ((hi >> 4) & 0x0F) | (((t >> 6) & 0x03) << 4) + return out + + +def to_s4_q3_k(raw: bytes): + """`Q3_K` -> S4: 16 groups of 16 per 256-element block, codebook 0..7 of the 0..15 a 4-bit plane holds. + + `dequantize_row_q3_K` (L1305): + d_all = fp16(x.d) + for half in {0, 128}: # `q` advances by 32 per half; `hm` does NOT advance + for j in 0..3: # shift = 2j, mask m = 1 << j + dl = d_all * (scales[is++] - 32) + for l in 0..15: y = dl * ((qs[l + 0] >> shift) & 3) - ((hm[l + 0] & m) ? 0 : 4) + dl = d_all * (scales[is++] - 32) + for l in 0..15: y = dl * ((qs[l + 16] >> shift) & 3) - ((hm[l + 16] & m) ? 0 : 4) + + Three things here are each independently easy to get wrong, and each would still yield plausible values: + + * **`hm` is indexed 0..31 in BOTH halves, but the BIT moves.** `q += 32` sits at the end of the `n` + loop, while the hmask is only ever read as `hm[l+0]`/`hm[l+16]` - so the second half re-reads the + SAME 32 mask bytes. What distinguishes the halves is `m`: it is declared outside the `n` loop and + only ever shifted, so it never resets, and the second half uses bits 4..7 where the first used + 0..3. `shift` DOES reset per half. Both facts are needed and they point opposite ways; getting + `m` wrong flips the sign of roughly half the elements of the second half, which is what the first + run of this mapping did. + * **the group size is 16, not 32** - 16 scales per half, hence 16 groups per 256-element block. + * **the `- 4` is an INTEGER bias on the code, not an offset in the value.** So the stored code is + `(2 bits) + (hm bit ? 4 : 0)`, which stays in 0..7, and the decode subtracts 4 before the multiply. + Folding it into a value-space offset instead, as `(c+4)*dl - 4*dl`, is NOT bit-exact: `fl(5a) - 4a` + is exact by Sterbenz but equals `a + 5ae`, not `a`. Q2_0's `- 1` and Q4_0's `- 8` are the same + integer-bias mechanism, which is why this is expressed the same way. + + The element order is `128*half + 32*j + 16*sub + l`, matching the sequential `*y++`. + """ + n = len(raw) // 110 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 110) + hmask = a[:, 0:32] # hmask[QK_K/8] + qs = a[:, 32:96] # qs[QK_K/4] + sb = a[:, 96:108] # scales[12] + d = np.ascontiguousarray(a[:, 108:110]).view(np.float16).astype(np.float32).reshape(-1) + + sc6 = unpack_q3_k_scales(sb) + scales = d[:, None] * (sc6 - 32).astype(np.float32) # integer bias, then ONE f32 multiply + codes = np.empty((n, 256), dtype=np.uint8) + for half in range(2): + for j in range(4): + for sub in range(2): + g = 8 * half + 2 * j + sub + base = half * 32 + sub * 16 + c2 = (qs[:, base:base + 16] >> (2 * j)) & 0x03 + # `m` does NOT reset between halves: it is `1 << (4*half + j)`, not `1 << j`. + m = (hmask[:, sub * 16:sub * 16 + 16] >> (4 * half + j)) & 0x01 + codes[:, g * 16:(g + 1) * 16] = c2 + 4 * m + return codes, scales + + +def from_s4_q3_k(parts): + codes, scales = parts + v = (codes.astype(np.int32) - 4).astype(np.float32) + return (v.reshape(-1, 16, 16) * scales[:, :, None]).reshape(-1) + + +# ---------------------------------------------------------------- the IQ4 codebook +# `kvalues_iq4nl`. `ggml-common.h` heads the IQ4 block definitions "Non-linear quants", and the name is +# load-bearing: this codebook is NOT affine, so unlike Q2_0's `-1`, Q3_K's `-4` and Q4_0's `-8` it cannot be +# expressed as a bias on the code. It is a 16-entry table lookup, and the canonical decode has to say so. +# Checked against two independent files before use, because a codebook is exactly the kind of constant that +# has silent variants: ggml-cpu/llamafile/sgemm.cpp L1361 and ggml-opencl/kernels/mul_mv_iq4_nl_f32.cl L30. +KV_IQ4NL = np.array([-127, -104, -83, -65, -49, -35, -22, -10, + 1, 13, 25, 38, 53, 69, 89, 113], dtype=np.float32) + + +def to_s4_iq4_nl(raw: bytes): + """`IQ4_NL` -> S4 with the IQ4 codebook: 32 codes, one fp16 scale, no offset. + + `dequantize_row_iq4_nl` (L2725): + d = fp16(x.d) + for j in 0..15: + y[j + 0] = d * kvalues_iq4nl[qs[j] & 0xf] + y[j + 16] = d * kvalues_iq4nl[qs[j] >> 4] + + Same nibble split as `Q4_0` - low nibbles fill the first half, high the second - but the code is an index + into the table rather than a value, so the whole `- 8` business has no analogue here. + """ + n = len(raw) // 18 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 18) + scales = np.ascontiguousarray(a[:, 0:2]).view(np.float16).astype(np.float32).reshape(-1) + qs = np.ascontiguousarray(a[:, 2:18]) # (n, 16) = QK4_NL/2 + codes = np.empty((n, 32), dtype=np.uint8) + codes[:, 0:16] = qs & 0x0F + codes[:, 16:32] = qs >> 4 + return codes, scales + + +def from_s4_iq4_nl(parts): + codes, scales = parts + return (KV_IQ4NL[codes] * scales[:, None]).reshape(-1) + + +def to_s4_iq4_xs(raw: bytes): + """`IQ4_XS` -> S4 with the IQ4 codebook: 8 groups of 32, each with `scale = d*(ls - 32)`. + + `dequantize_row_iq4_xs` (L2743): + d = fp16(x.d) + for ib in 0..7: # QK_K/32 groups + ls = ((scales_l[ib/2] >> 4*(ib%2)) & 0xf) | (((scales_h >> 2*ib) & 3) << 4) + dl = d * (ls - 32) + for j in 0..15: + y[j + 0] = dl * kvalues_iq4nl[qs[j] & 0xf] + y[j + 16] = dl * kvalues_iq4nl[qs[j] >> 4] + qs += 16 + + The 6-bit scale `ls` is split across TWO parallel arrays by element index: `scales_l` holds one nibble + per 32-element group (two groups per byte, low nibble first) and the `uint16_t scales_h` holds the top + two bits of each group's scale at bit offset `2*ib`. Reading a group's scale out of `scales_l` alone is + right for values under 16 and silently wrong above it. + """ + n = len(raw) // 136 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 136) + d = np.ascontiguousarray(a[:, 0:2]).view(np.float16).astype(np.float32).reshape(-1) + sh = np.ascontiguousarray(a[:, 2:4]).view(np.uint16).astype(np.int32).reshape(-1) # uint16 scales_h + sl = a[:, 4:8].astype(np.int32) # scales_l[QK_K/64] = 4 bytes + qs = a[:, 8:136] # qs[QK_K/2] = 128 bytes + + ls = np.empty((n, 8), dtype=np.int32) + for ib in range(8): + ls[:, ib] = ((sl[:, ib // 2] >> (4 * (ib % 2))) & 0xF) | (((sh >> (2 * ib)) & 3) << 4) + scales = d[:, None] * (ls - 32).astype(np.float32) # integer bias, then ONE f32 multiply + + codes = np.empty((n, 256), dtype=np.uint8) + for ib in range(8): + blk = qs[:, ib * 16:(ib + 1) * 16] + codes[:, ib * 32:ib * 32 + 16] = blk & 0x0F # low nibbles -> first 16 of the group + codes[:, ib * 32 + 16:ib * 32 + 32] = blk >> 4 # high nibbles -> second 16 + return codes, scales + + +def from_s4_iq4_xs(parts): + codes, scales = parts + return (KV_IQ4NL[codes].reshape(-1, 8, 32) * scales[:, :, None]).reshape(-1) + + +def to_s8_q5_0(raw: bytes): + """`Q5_0` -> S8: 5-bit codes, one fp16 scale per 32 elements, codebook bias `-16`. + + `dequantize_row_q5_0` (L500): + qh = the 4 bytes of x.qh read as a native uint32 + for j in 0..15: + xh_0 = ((qh >> (j + 0)) << 4) & 0x10 + xh_1 = ((qh >> (j + 12)) ) & 0x10 + x0 = ((qs[j] & 0x0F) | xh_0) - 16 ; y[j + 0] = x0*d + x1 = ((qs[j] >> 4) | xh_1) - 16 ; y[j + 16] = x1*d + + The 5th bit of element `j` is bit `j` of the 32-bit `qh`, and of element `j+16` is bit `j+16`: one bit per + element, taken from a field that is read as a single integer rather than as 4 bytes. `x1`'s shift of + `j + 12` plus the `& 0x10` is how the source reaches bit `j+16`; writing `j + 16` directly gives the same + answer, which is worth knowing before "fixing" it. + """ + n = len(raw) // 22 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 22) + scales = np.ascontiguousarray(a[:, 0:2]).view(np.float16).astype(np.float32).reshape(-1) + qh = np.ascontiguousarray(a[:, 2:6]).view("> j[None, :]) & 1) << 4).astype(np.uint8) + xh1 = (((qh[:, None] >> (j[None, :] + 16)) & 1) << 4).astype(np.uint8) + codes = np.empty((n, 32), dtype=np.uint8) + codes[:, 0:16] = (qs & 0x0F) | xh0 # low nibbles -> first half + codes[:, 16:32] = (qs >> 4) | xh1 # high nibbles -> second half + return codes, scales + + +def from_s8_q5_0(parts): + codes, scales = parts + v = (codes.astype(np.int32) - 16).astype(np.float32) # the -16 is inside the integer + return (v * scales[:, None]).reshape(-1) + + +def to_s8_q5_k(raw: bytes): + """`Q5_K` -> S8: 8 groups of 32, `scale = d*sc`, `offset = -(dmin*m)`; codes are 5-bit. + + `dequantize_row_q5_K` (L1731): + d = fp16(x.d) ; min = fp16(x.dmin) + is = 0 ; u1 = 1 ; u2 = 2 + for j in 0..256 step 64: + (sc,m) = get_scale_min_k4(is+0) ; d1 = d*sc ; m1 = min*m + (sc,m) = get_scale_min_k4(is+1) ; d2 = d*sc ; m2 = min*m + for l in 0..31: y++ = d1 * ((ql[l] & 0xF) + (qh[l] & u1 ? 16 : 0)) - m1 + for l in 0..31: y++ = d2 * ((ql[l] >> 4) + (qh[l] & u2 ? 16 : 0)) - m2 + ql += 32 ; is += 2 ; u1 <<= 2 ; u2 <<= 2 + + Identical in shape to `Q4_K` - same `get_scale_min_k4`, same 8 groups of 32, same low-nibbles-then-high + order - with a 5th bit added. `qh` does NOT advance inside the loop: the same 32 bytes are re-read by + all four 64-element steps, and what selects a distinct bit each time is `u1`/`u2` walking 1,4,16,64 and + 2,8,32,128. This is the Q3_K hmask trap again, and it is why the masks are written out rather than the + high bit being folded into `ql` once. + """ + n = len(raw) // 176 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 176) + d = np.ascontiguousarray(a[:, 0:2]).view(np.float16).astype(np.float32).reshape(-1) + dmin = np.ascontiguousarray(a[:, 2:4]).view(np.float16).astype(np.float32).reshape(-1) + scb = a[:, 4:16] # scales[K_SCALE_SIZE] - the same packed 6-bit pairs + qh = a[:, 16:48] # qh[QK_K/8] + qs = a[:, 48:176] # qs[QK_K/2] + + scales = np.empty((n, 8), dtype=np.float32) + offsets = np.empty((n, 8), dtype=np.float32) + for g in range(8): + sc = np.array([get_scale_min_k4(scb[i], g)[0] for i in range(n)], dtype=np.float32) + m = np.array([get_scale_min_k4(scb[i], g)[1] for i in range(n)], dtype=np.float32) + scales[:, g] = d * sc + offsets[:, g] = -(dmin * m) + + codes = np.empty((n, 256), dtype=np.uint8) + for j in range(4): + blk = qs[:, j * 32:(j + 1) * 32] + u1, u2 = 1 << (2 * j), 2 << (2 * j) + lo = (blk & 0x0F) + np.where(qh & u1, 16, 0).astype(np.uint8) + hi = (blk >> 4) + np.where(qh & u2, 16, 0).astype(np.uint8) + codes[:, j * 64:j * 64 + 32] = lo # group is+0: LOW nibbles + codes[:, j * 64 + 32:j * 64 + 64] = hi # group is+1: HIGH nibbles + return codes, scales, offsets + + +def from_s8_q5_k(parts): + codes, scales, offsets = parts + v = codes.astype(np.float32).reshape(codes.shape[0], 8, 32) + return (v * scales[:, :, None] + offsets[:, :, None]).reshape(-1) + + +def to_s8_q6_K(raw: bytes): + """`Q6_K` -> S8: 16 groups of 16, `scale = d*sc` with an int8 `sc`, codebook bias `-32`. + + `dequantize_row_q6_K` (L1939): + d = fp16(x.d) + for n in {0, 128}: # ql += 64, qh += 32, sc += 8 per half + for l in 0..31: + is = l/16 + q1 = ((ql[l + 0] & 0xF) | (((qh[l] >> 0) & 3) << 4)) - 32 + q2 = ((ql[l + 32] & 0xF) | (((qh[l] >> 2) & 3) << 4)) - 32 + q3 = ((ql[l + 0] >> 4) | (((qh[l] >> 4) & 3) << 4)) - 32 + q4 = ((ql[l + 32] >> 4) | (((qh[l] >> 6) & 3) << 4)) - 32 + y[l + 0] = d * sc[is + 0] * q1 + y[l + 32] = d * sc[is + 2] * q2 + y[l + 64] = d * sc[is + 4] * q3 + y[l + 96] = d * sc[is + 6] * q4 + + Four quads are built from ONE `qh[l]`, two bits each, while the low nibbles come from `ql[l]` and + `ql[l+32]` - and then the quads land at STRIDES of 32, not contiguously: q1 fills `l+0`, q2 fills `l+32`, + q3 fills `l+64`, q4 fills `l+96`. So the scale index is not `l/16` in any simple reading of the quads; + what it works out to is `(element / 16) % 8` within the half, i.e. plain consecutive 16-element groups. + + Two arithmetic details decide bit-exactness. `d * sc[is] * q` associates LEFT, so the group scale is + `fl(d * float(sc))` and the value is one further multiply - not `fl(d * fl(sc*q))`. And `sc` is `int8_t`, + so a negative scale is a sign flip and must be preserved, which is why the scale plane is read signed. + """ + n = len(raw) // 210 + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, 210) + ql = a[:, 0:128] # ql[QK_K/2] + qh = a[:, 128:192] # qh[QK_K/4] + sc = a[:, 192:208].view(np.int8) # scales[QK_K/16], SIGNED + d = np.ascontiguousarray(a[:, 208:210]).view(np.float16).astype(np.float32).reshape(-1) + + scales = d[:, None] * sc.astype(np.float32) # `d * sc` first, the leftmost multiply + + codes = np.empty((n, 256), dtype=np.uint8) + for half in range(2): + qlb = ql[:, half * 64:(half + 1) * 64] + qhb = qh[:, half * 32:(half + 1) * 32] + # (nibble source, qh bit offset) for quads 1..4; nibble 0..31 then 32..63, low then high + quads = [(qlb[:, 0:32] & 0x0F, 0), (qlb[:, 32:64] & 0x0F, 2), + (qlb[:, 0:32] >> 4, 4), (qlb[:, 32:64] >> 4, 6)] + for qidx, (nib, sh) in enumerate(quads): + seg = nib | (((qhb >> sh) & 0x03) << 4) + codes[:, half * 128 + qidx * 32: half * 128 + qidx * 32 + 32] = seg + return codes, scales + + +def from_s8_q6_K(parts): + codes, scales = parts + v = (codes.astype(np.int32) - 32).astype(np.float32) + return (v.reshape(-1, 16, 16) * scales[:, :, None]).reshape(-1) + + +MAPPINGS_HEADER = None # the registry is built after the functions, at the end of the mappings section + + +# ---------------------------------------------------------------- the float types (P-forms) +def to_p32_f32(raw: bytes): + """`F32` -> P32: the identity. Every other mapping is measured against this one's triviality.""" + v = np.frombuffer(raw, dtype=" P32: a bf16 is the top 16 bits of an f32, so widening shifts left and is exact. + + P32 and not P16: bf16 has the f32 exponent range and fp16 does not, so an fp16 container would send + large-magnitude weights to infinity. This is the one case where the wider container is the SAFE one. + """ + v = np.frombuffer(raw, dtype=" P16: keep the fp16, widen only to decode. fp16 -> fp32 is always exact.""" + return np.frombuffer(raw, dtype="> 2t) & 3`. + + This used to return the 16 PACKED bytes per block instead. It passed every check it was given, because + `from_s2` unpacked them again, and it was still wrong: `codes.size` is what the pack reads as a tensor's + element count, and for Q2_0 that came out at 1/4 of the truth. A representation that is only correct + when read back by its own decoder is not a canonical form - a pack has to be able to state the element + count on its own. + """ + n = len(raw) // BLOCK_BYTES + a = np.frombuffer(raw, dtype=np.uint8).reshape(n, BLOCK_BYTES) + scales = np.ascontiguousarray(a[:, 0:2]).view(np.float16).astype(np.float32).reshape(-1) + qb = a[:, 2:BLOCK_BYTES] # (n, 16) packed 2-bit codes + codes = np.empty((n, QK), dtype=np.uint8) + for t in range(4): + codes[:, t::4] = (qb >> (2 * t)) & 0x03 + return np.ascontiguousarray(codes), scales + + +def from_s2(parts): + """The decode a kernel would do. Transcribed from `dequantize_row_q2_0`, including the `- 1`. + + One integer subtract then ONE f32 multiply, in that order, for the reason docs/pack-format.md §3.1 gives: + folding the bias into the value domain is not bit-exact. + """ + codes, scales = parts + v = (codes.astype(np.int32) - 1).astype(np.float32) + return (v.reshape(-1, QK) * scales[:, None]).reshape(-1) + + +# The registry lives AFTER the functions it names. It has to: a module-level dict referencing `to_s2` +# before `to_s2` exists raises NameError at import, which is a mistake worth not repeating. +MAPPINGS = { + "Q2_0": Mapping(to_s2, from_s2, 18, 64, 64, "S2", None, -1, False), + "Q3_K": Mapping(to_s4_q3_k, from_s4_q3_k, 110, 256, 16, "S4", None, -4, False), + "Q4_0": Mapping(to_s4_q4_0, from_s4_q4_0, 18, 32, 32, "S4", None, -8, False), + "Q4_K": Mapping(to_s4_q4_k, from_s4_q4_k, 144, 256, 32, "S4", None, 0, True), + "Q5_0": Mapping(to_s8_q5_0, from_s8_q5_0, 22, 32, 32, "S8", None, -16, False), + "Q5_K": Mapping(to_s8_q5_k, from_s8_q5_k, 176, 256, 32, "S8", None, 0, True), + "Q6_K": Mapping(to_s8_q6_K, from_s8_q6_K, 210, 256, 16, "S8", None, -32, False), + "Q8_0": Mapping(to_s8_q8_0, from_s8_q8_0, 34, 32, 32, "S8", None, -128, False), + "IQ4_NL": Mapping(to_s4_iq4_nl, from_s4_iq4_nl, 18, 32, 32, "S4", "IQ4NL", 0, False), + "IQ4_XS": Mapping(to_s4_iq4_xs, from_s4_iq4_xs, 136, 256, 32, "S4", "IQ4NL", 0, False), + "F32": Mapping(to_p32_f32, from_p32_f32, 4, 1, 1, "P32", None, 0, False), + "BF16": Mapping(to_p32_bf16, from_p32_bf16, 2, 1, 1, "P32", None, 0, False), + "F16": Mapping(to_p16_f16, from_p16_f16, 2, 1, 1, "P16", None, 0, False), +} + + +def reference_values(tname: str, raw: bytes): + """The value this block MUST decode to - from an implementation other than the one under test. + + Q2_0 goes through `gguf_writer.dequantize_q2_0` and everything else through gguf-py, both reached via + `_dequant_flat`. The three float types are the exception and have to bypass it: `_dequant_flat` handles + them with this project's OWN `_bf16_to_f32` and numpy views, so for those the project's reference would + be checking itself. gguf-py implements all three independently, so it is used directly here. + """ + buf = np.frombuffer(raw, dtype=np.uint8) + if tname in ("F32", "F16", "BF16"): + from gguf import quants, GGMLQuantizationType as Q + return np.asarray(quants.dequantize(buf, Q[tname]), dtype=np.float32) + return np.asarray(_dequant_flat(tname, buf), dtype=np.float32) + + +def reference_name(tname: str) -> str: + """Which implementation the comparison is against - printed, because these are NOT equally strong. + + `gguf-py` is an independent implementation of the same C source. `project` is this project's own + decoder, so a pass there is a self-consistency result: it cannot detect a misreading of the format that + the decoder and the canonicaliser share. Q2_0 is the only `project` row and it is 31.64 GiB of the + artifact, so the distinction matters and is therefore never left implicit. + """ + return "project (Q2_0 decoder) - SELF-CONSISTENCY, oracle-backed by dequant_xcheck" \ + if tname == "Q2_0" else "gguf-py" + + +def open_shard(path): + """mmap the shard read-only. Slices of an mmap are ordinary bytes, which is all the reference needs. + + This replaced `fh.read()`, which loaded all 37.6 GB of shard 1 into RAM. It worked, which is exactly why + it was worth removing: a tool that only survives because the machine has 64 GB is not a tool that reports + its own limits, and it would have gone unnoticed until a machine with less memory ran it. + """ + fh = open(path, "rb") + mm = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ) + return mm, mm.size() + + +def data_section_offset(f, filelen: int) -> int: + """Byte offset of the tensor data, from the tensor table alone. + + `filelen - max(offset + expected_bytes)` needs no header parsing and no dependency on ref/load.py's + private accessors. + """ + return filelen - max(x.offset + (x.expected_bytes() or 0) for x in f.tensors) + + +def run_full(args) -> int: + """P1.T3 over the WHOLE artifact: every block of every tensor, streamed. + + P1.T3 is stated as "for every tensor of the real GGUF, dequant(canonical) == dequant_ggml(original) bit + for bit". A per-block identity is a pure function of that block's bytes, so a sample of blocks can only + ever be evidence ABOUT a subset; this mode removes the qualifier. It also reports the DATA-DEPENDENT + branches each type actually exercised, because those are the paths a sample can miss entirely: a scale + that never reaches 16 never tests the high bits of `IQ4_XS`'s `ls`, and an hmask whose top nibble is never + set never tests `Q3_K`'s second-half mask. + """ + f = G.GGUFFile(pathlib.Path(args.gguf)) + head, filelen = open_shard(args.gguf) + data_off = data_section_offset(f, filelen) + print("shard %s: %d tensors, data at byte %d" % (args.gguf, len(f.tensors), data_off)) + + by_type = collections.defaultdict(list) + want = {s.strip() for s in args.types.split(",")} + si, sn = (int(x) for x in args.split.split("/")) + if not 0 <= si < sn: + print("--split %s: need 0 <= I < N" % args.split) + return 2 + # One index over ALL tensors, not per type: a per-type split would put the whole of a 202-tensor type + # in one process and leave the others idle. + for idx, t in enumerate(f.tensors): + if idx % sn == si: + by_type[t.type_name].append(t) + if sn > 1: + print("split %d/%d: %d of %d tensors in this process" + % (si, sn, sum(len(v) for v in by_type.values()), len(f.tensors))) + for tn in sorted(by_type): + if tn not in MAPPINGS: + print(" %-8s %5d tensors NO MAPPING - not covered" % (tn, len(by_type[tn]))) + elif tn not in want: + print(" %-8s %5d tensors EXCLUDED by --types" % (tn, len(by_type[tn]))) + + total_t = total_b = total_bad = 0 + ok = True + for tname in sorted(by_type): + if tname not in MAPPINGS or tname not in want: + continue + m = MAPPINGS[tname] + nblocks_sec = max(1, args.chunk // m.block_bytes) # WHOLE blocks per chunk + n_t = n_b = n_bad = 0 + t0 = time.time() + seen = collections.Counter() + for t in by_type[tname]: + if t.elements % m.block_elems: + print(" %-34s %d elements not a multiple of %d - SKIPPED" % (t.name, t.elements, m.block_elems)) + ok = False + continue + nb = t.elements // m.block_elems + base = data_off + t.offset + first_bad = None + for s in range(0, nb, nblocks_sec): + cnt = min(nblocks_sec, nb - s) + raw = head[base + s * m.block_bytes: base + (s + cnt) * m.block_bytes] + if len(raw) != cnt * m.block_bytes: + print(" %-34s SHORT READ at block %d - instrument error" % (t.name, s)) + ok = False + break + ref = reference_values(tname, raw) + got = m.dec(m.enc(raw)) + if ref.shape != got.shape: + print(" %-34s SHAPE %s vs %s - instrument error" % (t.name, ref.shape, got.shape)) + ok = False + break + ne = np.nonzero(ref != got)[0] + n_b += cnt + if ne.size: + n_bad += ne.size + if first_bad is None: + first_bad = (s + int(ne[0]) // m.block_elems, int(ne[0])) + if args.branches: + codes = m.enc(raw)[0] + seen["code_hi=%d" % int(codes.max())] += 1 + seen["code_lo=%d" % int(codes.min())] += 1 + n_t += 1 + if first_bad is not None: + print(" %-34s FIRST MISMATCH at block %d element %d" % (t.name, first_bad[0], first_bad[1])) + ok = False + dt = time.time() - t0 + total_t += n_t + total_b += n_b + total_bad += n_bad + print(" %-8s %5d tensors %9d blocks %s %6.1fs ref=%s" + % (tname, n_t, n_b, "BIT-EXACT" if not n_bad else "*** %d BAD ***" % n_bad, dt, + reference_name(tname))) + if args.branches and seen: + print(" branch reach: %s" % " ".join("%s x%d" % kv for kv in sorted(seen.items()))) + print() + print("P1.T3 full: %d tensors, %d blocks, %d mismatched elements" % (total_t, total_b, total_bad)) + print("tools/canonical_xcheck.py --full " + ("PASS" if ok else "FAIL")) + return 0 if ok else 1 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--gguf", default=SHARD1) + ap.add_argument("--types", default=",".join(MAPPINGS), + help="source types to test, one mapping each; default is every mapped type") + ap.add_argument("--tensors", type=int, default=2, help="tensors per type") + ap.add_argument("--blocks", type=int, default=2048, help="blocks per tensor") + ap.add_argument("--full", action="store_true", + help="P1.T3 over every block of every tensor (overrides --tensors/--blocks)") + ap.add_argument("--chunk", type=int, default=64 << 20, help="bytes of source per streaming step") + ap.add_argument("--branches", action="store_true", help="report code range reached per type") + ap.add_argument("--split", default="0/1", + help="I/N: process only tensors whose index %% N == I, so N processes can share the " + "work. Q2_0's reference is a pure-Python loop at ~1.7 MB/s and Q2_0 is 34 GB of " + "the shard, so the full run is single-core-bound and worth splitting.") + args = ap.parse_args() + if args.full: + return run_full(args) + + f = G.GGUFFile(pathlib.Path(args.gguf)) + print("shard: %d tensors" % len(f.tensors)) + head, filelen = open_shard(args.gguf) + data_off = data_section_offset(f, filelen) + print("data section starts at byte %d (file %d bytes)" % (data_off, filelen)) + + ok = True + covered = 0 + for tname in args.types.split(","): + tname = tname.strip() + if tname not in MAPPINGS: + print(" %s: NO MAPPING in this tool yet" % tname) + continue + m = MAPPINGS[tname] + tensors = [t for t in f.tensors if t.type_name == tname] + covered += len(tensors) + print() + print("--- %s -> %s codebook=%s bias=%d offset=%s group=%d (%d tensors in the shard) ---" + % (tname, m.form, m.codebook or "affine", m.code_bias, m.has_offset, + m.group_elems, len(tensors))) + for t in tensors[: args.tensors]: + if t.elements % m.block_elems: + print(" %-34s %d elements is not a whole number of %d-element blocks - SKIPPED" + % (t.name, t.elements, m.block_elems)) + continue + want = min(args.blocks, t.elements // m.block_elems) * m.block_bytes + byte_off = data_off + t.offset + raw = head[byte_off: byte_off + want] + if len(raw) < want: + print(" %-34s SHORT READ (%d of %d) - instrument error" % (t.name, len(raw), want)) + ok = False + continue + ref = reference_values(tname, raw) + parts = m.enc(raw) + got = m.dec(parts) + if ref.shape != got.shape: + print(" %-34s SHAPE %s ref vs %s got - instrument error" % (t.name, ref.shape, got.shape)) + ok = False + continue + same = np.array_equal(ref, got) + d = np.abs(ref.astype(np.float64) - got.astype(np.float64)) + print(" %-34s %6d blocks %s max|d| %.3e" + % (t.name, want // m.block_bytes, "BIT-EXACT" if same else "*** MISMATCH ***", d.max())) + if not same: + bad = np.nonzero(ref != got)[0] + print(" first differing element %d: ref %r got %r" % (bad[0], ref[bad[0]], got[bad[0]])) + ok = False + if m.block_elems <= 256: # a value set is informative for a quantized block, noise for f32 + vals = np.unique(got[: m.block_elems]) + print(" first block's value set: %s" % np.array2string(vals, precision=6)) + print() + print("mapped types cover %d of %d tensors in this shard" % (covered, len(f.tensors))) + print("tools/canonical_xcheck.py " + ("PASS" if ok else "FAIL")) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/gguf_reader.py b/tools/gguf_reader.py new file mode 100644 index 00000000..237d5fdb --- /dev/null +++ b/tools/gguf_reader.py @@ -0,0 +1,142 @@ +"""Minimal GGUF v3 header reader - no ggml, no gguf-py. + +Written because the `gguf` PyPI package cannot open this artifact: its `GGMLQuantizationType` +enum has no member for type 42 (`Q2_0`), which is newer than the library. That is itself a P0.S6 +finding - the format this engine is specialized for is ahead of the standard tooling. + +This is the reference implementation for `src/artifact/` (architecture §15: "GGUF v3 reader (mmap, +no ggml dependency)"). It parses the header only; tensor data is never read. +""" +from __future__ import annotations + +import dataclasses +import pathlib +import struct +from typing import Any + +GGUF_MAGIC = 0x46554747 # "GGUF" little-endian + +# ggml_type values. 42 = Q2_0, the GSQ-RCO routed-expert encoding, absent from gguf-py as of 0.19. +GGML_TYPES: dict[int, str] = { + 0: "F32", 1: "F16", 2: "Q4_0", 3: "Q4_1", 4: "Q4_2", 5: "Q4_3", 6: "Q5_0", 7: "Q5_1", + 8: "Q8_0", 9: "Q8_1", 10: "Q2_K", 11: "Q3_K", 12: "Q4_K", 13: "Q5_K", 14: "Q6_K", + 15: "Q8_K", 16: "IQ2_XXS", 17: "IQ2_XS", 18: "IQ3_XXS", 19: "IQ1_S", 20: "IQ4_NL", + 21: "IQ3_S", 22: "IQ2_S", 23: "IQ4_XS", 24: "I8", 25: "I16", 26: "I32", 27: "I64", + 28: "F64", 29: "IQ1_M", 30: "BF16", 31: "Q4_0_4_4", 32: "Q4_0_4_8", 33: "Q4_0_8_8", + 34: "TQ1_0", 35: "TQ2_0", 36: "IQ4_NL_4_4", 37: "IQ4_NL_4_8", 38: "IQ4_NL_8_8", + 39: "MXFP4", 40: "NVFP4", 41: "Q4_0_8_8", 42: "Q2_0", +} + +# GGUF metadata value type ids +GGUF_META = { + 0: ("u8", 1), 1: ("i8", 1), 2: ("u16", 2), 3: ("i16", 2), 4: ("u32", 4), 5: ("i32", 4), + 6: ("f32", 4), 7: ("bool", 1), 8: ("string", None), 9: ("array", None), + 10: ("u64", 8), 11: ("i64", 8), 12: ("f64", 8), +} + +# ggml block geometry for the encodings this model uses: (block elements, bytes per block) +# This is what makes a per-tensor byte count checkable, and it is the contract the kernels share. +BLOCK_GEOMETRY: dict[str, tuple[int, int]] = { + "F32": (1, 4), "F16": (1, 2), "BF16": (1, 2), "F64": (1, 8), + "Q4_0": (32, 18), "Q4_1": (32, 20), "Q5_0": (32, 22), "Q5_1": (32, 24), + "Q8_0": (32, 34), "Q8_1": (32, 36), + "Q2_K": (256, 84), "Q3_K": (256, 110), "Q4_K": (256, 144), "Q5_K": (256, 176), + "Q6_K": (256, 210), "Q8_K": (256, 292), + "IQ2_XXS": (256, 66), "IQ2_XS": (256, 74), "IQ3_XXS": (256, 98), "IQ1_S": (256, 50), + "IQ4_NL": (32, 18), "IQ3_S": (256, 110), "IQ2_S": (256, 82), "IQ4_XS": (256, 136), + "IQ1_M": (256, 56), "Q2_0": (64, 18), "MXFP4": (32, 17), "NVFP4": (64, 36), +} + + +@dataclasses.dataclass +class TensorInfo: + name: str + shape: list[int] + type_id: int + type_name: str + offset: int + + @property + def elements(self) -> int: + n = 1 + for d in self.shape: + n *= d + return n + + def expected_bytes(self) -> int | None: + geom = BLOCK_GEOMETRY.get(self.type_name) + if geom is None: + return None + block_elems, block_bytes = geom + if self.elements % block_elems: + return None + return self.elements // block_elems * block_bytes + + +class GGUFFile: + def __init__(self, path: pathlib.Path): + self.path = pathlib.Path(path) + self.metadata: dict[str, Any] = {} + self.tensors: list[TensorInfo] = [] + self.version = 0 + self.alignment = 32 + with self.path.open("rb") as fh: + self._parse(fh) + self.data_start = self._data_start + + # ------------------------------------------------------------------ internals + def _parse(self, fh) -> None: + magic = struct.unpack(" str: + (n,) = struct.unpack(" dict[str, int]: + out: dict[str, int] = {} + for t in self.tensors: + out[t.type_name] = out.get(t.type_name, 0) + 1 + return dict(sorted(out.items(), key=lambda kv: -kv[1])) + + def find(self, needle: str) -> list[TensorInfo]: + return [t for t in self.tensors if needle in t.name] diff --git a/tools/gguf_writer.py b/tools/gguf_writer.py new file mode 100644 index 00000000..8ad3cd6e --- /dev/null +++ b/tools/gguf_writer.py @@ -0,0 +1,234 @@ +"""A minimal GGUF v3 WRITER, including `Q2_0`. + +Why this exists +--------------- +P1.S1 of the plan says the tiny synthetic model is "written as a real GGUF via `gguf-py`" using +"at least `Q2_0`". That does not work: `gguf-py` has no `GGMLQuantizationType` member for type 42, so +`GGMLQuantizationType(42)` raises `ValueError`. Verified in round 39 and again here. + +`src/artifact/` needs a writer regardless - P1.S2 requires a reader with no ggml dependency, and the +writer is its companion - so this is that writer in Python, where it can be validated immediately +against `tools/gguf_reader.py` and against llama.cpp. + +Scope: enough to build the P1.S1 tiny model. It is NOT a general-purpose quantizer; the only +quantizer implemented is `Q2_0`, and its contract is `docs/q2_0-contract.md`: + block = 64 elements, fp16 scale, 16 bytes of 2-bit codes, LSB-first, 4 codes per byte + d = amax, code = clamp(round(w/d) + 1, 0, 3), symbol = code - 1 -> {-1, 0, +1, +2} + +Self-checking: `python tools/gguf_writer.py` writes a synthetic v3 file, reads it back with +`gguf_reader.py`, and asserts the tensors and metadata survived byte-exactly. +""" +from __future__ import annotations + +import dataclasses +import pathlib +import struct +import sys + +import numpy as np + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from gguf_reader import GGUF_MAGIC, GGUFFile # noqa: E402 + +# ggml_type ids we can write. Values match GGML_TYPES in gguf_reader.py. +TYPE_IDS = {"F32": 0, "F16": 1, "Q2_0": 42} +DEFAULT_ALIGNMENT = 32 + +_META_IDS = {"u8": 0, "i8": 1, "u16": 2, "i16": 3, "u32": 4, "i32": 5, "f32": 6, + "bool": 7, "string": 8, "array": 9, "u64": 10, "i64": 11, "f64": 12} + + +# ------------------------------------------------------------------ Q2_0, per docs/q2_0-contract.md +def quantize_q2_0(w: np.ndarray) -> bytes: + """(n,) float32 -> concatenated 18-byte blocks. n must be a multiple of 64.""" + w = np.asarray(w, dtype=np.float32).reshape(-1) + if w.size % 64: + raise ValueError(f"Q2_0 needs a multiple of 64 elements, got {w.size}") + blocks = w.reshape(-1, 64) + out = bytearray() + for b in blocks: + amax = float(np.max(np.abs(b))) + d = amax + # d = amax (NOT amax/2) - the grid is asymmetric, symbols land in {-1,0,+1,+2}. + code = np.zeros(64, dtype=np.uint8) if d == 0 else \ + np.clip(np.rint(b / d).astype(np.int32) + 1, 0, 3).astype(np.uint8) + packed = np.zeros(16, dtype=np.uint8) + # j -> byte j//4, bit (j%4)*2 ; four codes per byte, least significant first. + for j in range(64): + packed[j // 4] |= np.uint8(code[j] << ((j % 4) * 2)) + out += struct.pack(" np.ndarray: + """Inverse of the above, for the round-trip check. Mirrors `dequantize_row_q2_0`.""" + nblocks = len(raw) // 18 + out = np.empty(nblocks * 64, dtype=np.float32) + for i in range(nblocks): + d = struct.unpack_from("> ((j % 4) * 2)) & 0x03 + out[i * 64 + j] = (c - 1) * d + return out + + +# ------------------------------------------------------------------ writer +@dataclasses.dataclass +class _Tensor: + name: str + shape: list[int] # GGUF order: dim 0 varies fastest + type_name: str + data: bytes + + +class GGUFWriter: + def __init__(self, alignment: int = DEFAULT_ALIGNMENT): + self.alignment = alignment + self.metadata: dict[str, tuple[str, object]] = {} + self.tensors: list[_Tensor] = [] + + # ---- metadata + def add(self, key: str, value, type_name: str | None = None) -> None: + if type_name is None: + type_name = {bool: "bool", int: "i64", float: "f64", str: "string"}.get(type(value)) + if type_name is None: + if isinstance(value, list) and value: + elem = {bool: "bool", int: "i32", float: "f32", str: "string"}.get(type(value[0])) + if elem is None: + raise TypeError(f"unsupported array element {type(value[0])} for {key}") + type_name = "array:" + elem + else: + raise TypeError(f"cannot infer a GGUF type for {key}") + self.metadata[key] = (type_name, value) + + # ---- tensors + def add_f32(self, name: str, arr: np.ndarray) -> None: + a = np.ascontiguousarray(arr, dtype=" None: + """`arr` is (rows, in_features) row-major with every row `shape[0]` long; `shape` is the FULL + GGUF shape. The shape must be passed explicitly, not inferred as [in, rows]: a fused expert + tensor is [in, out, experts], and inferring 2-D produced `[64, 4096]` where llama.cpp expects + `[64, 256, 16]`. Q2_0 blocks run along dim 0 only, so the extra axes are just a layout. + """ + a = np.asarray(arr, dtype=np.float32) + in_features = shape[0] + if a.ndim != 2 or a.shape[1] != in_features: + raise ValueError(f"{name}: expected (rows, {in_features}), got {a.shape}") + if a.shape[0] * in_features != int(np.prod(shape)): + raise ValueError(f"{name}: {a.shape} does not cover shape {shape}") + per_row = quantize_q2_0(a.reshape(-1)) # row-major: row 0 first + self.tensors.append(_Tensor(name, list(shape), "Q2_0", per_row)) + + # ---- serialisation + def _kv_bytes(self, key: str, type_name: str, value) -> bytes: + out = bytearray() + out += struct.pack(" bytes: + if type_name == "string": + b = v.encode("utf-8") + return struct.pack(" pathlib.Path: + path = pathlib.Path(path) + body = bytearray() + body += struct.pack(" int: + rng = np.random.default_rng(7) + w = GGUFWriter() + w.add("general.architecture", "qwen4exp") + w.add("qwen4exp.block_count", 8, "u32") + w.add("qwen4exp.test.array", [1, 2, 3, 4], "array:i32") + w.add("general.name", "strata tiny") + + A = rng.standard_normal((16, 64)).astype(np.float32) + B = rng.standard_normal((4, 8)).astype(np.float32) + w.add_q2_0("blk.0.ffn_gate_exps.weight", A, 64) + w.add_f32("blk.0.ssm_a", B) + + p = pathlib.Path(__file__).resolve().parent.parent / "bench" / "tiny-selftest.gguf" + w.write(p) + print(f"wrote {p.name} ({p.stat().st_size} B)") + + g = GGUFFile(p) + ok = True + print(f" version {g.version} tensors {len(g.tensors)} metadata {len(g.metadata)}") + if g.metadata.get("general.architecture") != "qwen4exp": print(" FAIL arch"); ok = False + if g.metadata.get("qwen4exp.block_count") != 8: print(" FAIL u32"); ok = False + if g.metadata.get("qwen4exp.test.array") != [1, 2, 3, 4]: print(" FAIL array"); ok = False + + by = {t.name: t for t in g.tensors} + t = by.get("blk.0.ffn_gate_exps.weight") + if t is None or t.type_name != "Q2_0" or t.shape != [64, 16]: + print(f" FAIL tensor dir: {t}"); ok = False + else: + with p.open("rb") as fh: + fh.seek(g.data_start + t.offset) + raw = fh.read(16 * 18) + back = dequantize_q2_0(raw) + err = np.abs(back - A.reshape(-1)).max() + rel = float(np.abs(back - A.reshape(-1)).mean() / np.abs(A).mean()) + print(f" Q2_0 round trip: max abs {err:.4f} mean rel {rel*100:.2f}% " + f"(quantization error, not a write bug)") + # The real check: re-quantizing the DEQUANTIZED values must be a fixed point. + again = dequantize_q2_0(quantize_q2_0(back)) + if not np.array_equal(again, back): + print(" FAIL Q2_0 is not a fixed point under requantization"); ok = False + else: + print(" Q2_0 fixed point: PASS (requantizing the dequantized values is exact)") + print("SELF-CHECK " + ("PASS" if ok else "FAIL")) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(_selfcheck()) diff --git a/tools/iq_pack.py b/tools/iq_pack.py new file mode 100644 index 00000000..2c5d54f1 --- /dev/null +++ b/tools/iq_pack.py @@ -0,0 +1,221 @@ +"""tools/iq_pack.py - plan v0.3 P6: a native pack for any of the model files (Q2_0, IQ2_XS, IQ3_XXS). + + python tools/iq_pack.py --gguf -00001-of-00002.gguf --out pack/iq3_xxs (standalone) + python tools/iq_pack.py --gguf -00001-of-00002.gguf --base pack/full --out ... (share dense.bin) + +The i-quant experts cannot be re-expressed in the Q2_0 pack form, so this pack keeps every quantized tensor in +its GGUF form: + + experts.bin per layer, 512 blobs of [gate rows | up rows | down rows], the raw GGUF slices. Blob + size is per layer (the files mix IQ1_M ... IQ3_S gate/up and Q2_0 / IQ4_NL down). + native_experts.txt one line per layer: layer gu_type d_type offset blob_bytes + index.txt the table the engine loads. Quantized dense tensors, token_embd and output are served + natively from the GGUF by the engine (--native): their rows carry shape only. + dense.bin standalone: the BF16/F16/F32 tensors exactly as the GGUF stores them (index kinds 4/5/2). + With --base: the base (Q2_0) pack's dense.bin, hard-linked - the float tensors are + byte-identical in all three model files (checked) - plus extra.bin for tensors that are + float here but quantized in the base pack (blk.1.ple_key). + tokenizer/ exported from the GGUF (tools/strata_tokenizer.py). +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import shutil +import subprocess +import sys + +import numpy as np + +HERE = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import gguf_reader as G # noqa: E402 + +FLOAT = {"BF16", "F32", "F16"} +ROLES = ("gate", "up", "down") +N_EXPERT = 512 +ALIGN = 64 + + +def read_index(path: pathlib.Path): + rows, header = {}, [] + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("#"): + header.append(line) + continue + f = line.split() + rows[f[0]] = f + return header, rows + + +def tensor_bytes(mm, g, t) -> np.ndarray: + n = t.expected_bytes() + return mm[g.data_start + t.offset: g.data_start + t.offset + n] + + +def is_expert(name: str) -> bool: + return name.startswith("blk.") and name.endswith(("_exps.weight",)) + + +def index_standalone(src, out, g, T, mm) -> int: + """Every non-expert tensor of shard 1: floats into dense.bin as stored, quantized ones native-only.""" + rows, at = [], 0 + served = 0 + with open(out / "dense.bin", "wb") as fo: + for t in g.tensors: + if is_expert(t.name): + continue + if len(t.shape) > 2: + print("tensor %s has %d dimensions; the index holds two" % (t.name, len(t.shape))) + return 1 + ne0 = int(t.shape[0]) + ne1 = int(t.shape[1]) if len(t.shape) > 1 else 0 + if t.type_name in FLOAT: + raw = tensor_bytes(mm, g, t).tobytes() + kind = {"BF16": "4", "F16": "5", "F32": "2"}[t.type_name] + rows.append([t.name, "0", kind, str(at), str(len(raw)), "0", str(len(raw)), str(ne0), str(ne1), + "0", "0", "1"] + ["0"] * 7) + fo.write(raw) + pad = (-len(raw)) % ALIGN + fo.write(b"\0" * pad) + at += len(raw) + pad + else: + served += 1 + rows.append([t.name, "0", "0", "0", "0", "0", "0", str(ne0), str(ne1), "8", "0", "32"] + ["0"] * 7) + write_index(out, rows, src, served, 0) + return 0 + + +def write_index(out, rows, src, served, n_extra): + at = 0 + for r in rows: + r[5] = str(at) + at += (int(r[6]) + ALIGN - 1) // ALIGN * ALIGN + with open(out / "index.txt", "w", encoding="utf-8", newline="\n") as fo: + fo.write("# strata pack index v3 -- generated by tools/iq_pack.py (native experts) from %s\n" % src.name) + fo.write("# align %d pool %d tensors %d\n" % (ALIGN, at, len(rows))) + for r in rows: + fo.write(" ".join(r) + "\n") + print("index.txt: %d tensors, %d served natively, %d in extra.bin, arena %.2f GiB" + % (len(rows), served, n_extra, at / 2**30)) + + +def index_from_base(a, src, base, out, g, T, mm) -> int: + base_src = pathlib.Path(json.loads((base / "manifest.json").read_text(encoding="utf-8"))["source"]["shard1"]) + if not base_src.exists(): + print("cannot find the base pack's shard 1 from its manifest.json") + return 1 + bg = G.GGUFFile(base_src) + BT = {t.name: t for t in bg.tensors} + bmm = np.memmap(base_src, dtype=np.uint8, mode="r") + header, rows = read_index(base / "index.txt") + new_rows, extra = [], [] + served = 0 + for name, f in rows.items(): + t, bt = T.get(name), BT.get(name) + if t is None or bt is None: + print("tensor %s missing from one of the models" % name) + return 1 + if t.type_name in FLOAT and bt.type_name in FLOAT: + if t.type_name != bt.type_name or t.shape != bt.shape or \ + not np.array_equal(tensor_bytes(mm, g, t), tensor_bytes(bmm, bg, bt)): + print("float tensor %s differs from the base model; this pack cannot reuse its dense.bin" % name) + return 1 + new_rows.append(list(f)) + elif t.type_name in FLOAT: + if t.type_name != "BF16": + print("unexpected float type %s for %s" % (t.type_name, name)) + return 1 + nbytes = t.expected_bytes() + off = sum(len(b) + (-len(b)) % ALIGN for b in extra) + extra.append(tensor_bytes(mm, g, t).tobytes()) + # file 3 = extra.bin, raw BF16 (index kind 4) + new_rows.append([name, "3", "4", str(off), str(nbytes), "0", str(nbytes), f[7], f[8]] + ["0"] * 10) + else: + served += 1 + new_rows.append([name, f[1], "0", "0", "0", "0", "0", f[7], f[8], "8", "0", "32"] + ["0"] * 7) + write_index(out, new_rows, src, served, len(extra)) + with open(out / "extra.bin", "wb") as fo: + for b in extra: + fo.write(b) + fo.write(b"\0" * ((-len(b)) % ALIGN)) + dense = out / "dense.bin" + if not dense.exists(): + try: + os.link(base / "dense.bin", dense) + except OSError: + shutil.copyfile(base / "dense.bin", dense) + if (base / "tokenizer").exists() and not (out / "tokenizer").exists(): + shutil.copytree(base / "tokenizer", out / "tokenizer") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--gguf", required=True, help="the model's shard 1") + ap.add_argument("--base", help="optional: a Q2_0 canonical pack whose dense.bin holds the shared float tensors") + ap.add_argument("--out", required=True) + ap.add_argument("--skip-experts", action="store_true", help="rewrite the index only") + ap.add_argument("--experts-bin", action="store_true", + help="also write experts.bin (the engine otherwise reads the experts from the GGUF itself)") + a = ap.parse_args() + src = pathlib.Path(a.gguf).resolve() + base = pathlib.Path(a.base).resolve() if a.base else None + out = pathlib.Path(a.out) + out.mkdir(parents=True, exist_ok=True) + + g = G.GGUFFile(src) + T = {t.name: t for t in g.tensors} + mm = np.memmap(src, dtype=np.uint8, mode="r") + if a.base: + rc = index_from_base(a, src, base, out, g, T, mm) + else: + rc = index_standalone(src, out, g, T, mm) + if rc: + return rc + if not (out / "tokenizer" / "vocab.json").exists(): + subprocess.run([sys.executable, str(HERE / "strata_tokenizer.py"), "--gguf", str(src), "--out", str(out)], + check=True) # writes /tokenizer/ + + # ---- the experts + n_layers = 1 + max(int(n.split(".")[1]) for n in T if n.startswith("blk.") and n.endswith("_exps.weight")) + layout, offset = [], 0 + for l in range(n_layers): + ts = [T["blk.%d.ffn_%s_exps.weight" % (l, r)] for r in ROLES] + per = [t.expected_bytes() // N_EXPERT for t in ts] + if per[0] != per[1] or ts[0].type_name != ts[1].type_name: + print("layer %d: gate and up differ in type" % l) + return 1 + blob = per[0] + per[1] + per[2] + layout.append((l, ts[0].type_id, ts[2].type_id, offset, blob, ts)) + offset += blob * N_EXPERT + with open(out / "native_experts.txt", "w", encoding="utf-8", newline="\n") as fo: + fo.write("# strata native experts v2: layer gu_type d_type offset blob_bytes gate_off up_off down_off " + "(n_expert %d, total %d; the last three are absolute offsets in %s)\n" % (N_EXPERT, offset, src.name)) + for l, gt, dt, off, blob, ts in layout: + fo.write("%d %d %d %d %d %d %d %d\n" % (l, gt, dt, off, blob, *[g.data_start + t.offset for t in ts])) + if a.skip_experts or not a.experts_bin: + if (out / "experts.bin").exists() and not a.experts_bin: + print("note: %s/experts.bin exists; the engine reads it instead of the GGUF" % out) + return 0 + path = out / "experts.bin" + if path.exists() and path.stat().st_size == offset: + print("experts.bin exists with the right size; not rewritten") + return 0 + with open(path, "wb") as fo: + for l, gt, dt, off, blob, ts in layout: + parts = [tensor_bytes(mm, g, t).reshape(N_EXPERT, -1) for t in ts] + chunk = np.concatenate(parts, axis=1) # (512, blob): gate | up | down per expert + assert chunk.shape == (N_EXPERT, blob) + fo.write(chunk.tobytes()) + if l % 8 == 0: + print(" layer %2d %-8s/%-7s blob %8d at %.2f GiB" % (l, ts[0].type_name, ts[2].type_name, blob, + off / 2**30), flush=True) + print("experts.bin: %d layers, %.2f GiB" % (n_layers, offset / 2**30)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/mtp_fetch.py b/tools/mtp_fetch.py new file mode 100644 index 00000000..82defb5a --- /dev/null +++ b/tools/mtp_fetch.py @@ -0,0 +1,121 @@ +"""tools/mtp_fetch.py - plan v0.3, P0.3/P6: the MTP block from the BF16 checkpoint, without the checkpoint. + +The GSQ-RCO GGUF ships no MTP head. The BF16 checkpoint (Qwen/Qwen3.8-Flash-Next, 360 GB in 131 shards) does: +31 `mtp.*` tensors scattered over 28 shards. Safetensors puts a JSON header (name -> dtype, shape, byte range) +at the start of each shard, so HTTP range requests can read the headers and then only the MTP tensors. + + python tools/mtp_fetch.py inventory --out DIR # headers only (a few KB per shard) + python tools/mtp_fetch.py fetch --out DIR [--only SUBSTR] # the MTP tensors themselves, resumable + +`fetch` writes one raw file per tensor plus `mtp-manifest.json` (dtype, shape, source shard, byte range, +sha256). It never downloads anything but the ranges named in the headers. Nothing here runs a model. +""" +import argparse +import hashlib +import json +import os +import struct +import sys +import time +import urllib.request + +REPO = "https://huggingface.co/Qwen/Qwen3.8-Flash-Next/resolve/main/" +DTYPE_BYTES = {"BF16": 2, "F16": 2, "F32": 4, "F8_E4M3": 1, "I64": 8, "I32": 4} + + +def get(url, start=None, end=None, retries=4): + for attempt in range(retries): + try: + req = urllib.request.Request(url, headers={"User-Agent": "strata-mtp-fetch"}) + if start is not None: + req.add_header("Range", "bytes=%d-%d" % (start, end)) + with urllib.request.urlopen(req, timeout=120) as r: + data = r.read() + if start is not None and len(data) != end - start + 1: + raise IOError("short range read: %d of %d" % (len(data), end - start + 1)) + return data + except Exception as e: # network errors are retried, then surfaced + if attempt == retries - 1: + raise + time.sleep(2 ** attempt) + print("retry %s: %s" % (url, e), file=sys.stderr) + + +def shard_header(shard): + url = REPO + shard + n = struct.unpack(" np.ndarray: + raw = np.fromfile(path, dtype=" np.ndarray: + """[..., n] float32 -> bytes of Q2_0 blocks (fp16 d, 16 bytes of 2-bit codes, code = q + 1, 4 per byte).""" + x = w.reshape(-1, 64).astype(np.float32) + amax = np.abs(x).max(axis=1, keepdims=True) + best_err = np.full((x.shape[0], 1), np.inf, dtype=np.float32) + best_d = np.zeros_like(amax) + # Candidate scales span amax/2 (the +2 level reaches the max) to amax (the ggml reference); 17 steps. + for f in np.linspace(0.5, 1.0, 17, dtype=np.float32): + d = amax * f + inv = np.where(d > 0, 1.0 / np.where(d > 0, d, 1.0), 0.0) + q = np.clip(np.rint(x * inv), -1, 2) + err = ((q * d - x) ** 2).sum(axis=1, keepdims=True) + better = err < best_err + best_err = np.where(better, err, best_err) + best_d = np.where(better, d, best_d) + d16 = best_d.astype(np.float16) + d = d16.astype(np.float32) # quantize against the STORED scale + inv = np.where(d > 0, 1.0 / np.where(d > 0, d, 1.0), 0.0) + codes = (np.clip(np.rint(x * inv), -1, 2) + 1).astype(np.uint8) # 0..3 + c = codes.reshape(-1, 16, 4) + packed = (c[:, :, 0] | (c[:, :, 1] << 2) | (c[:, :, 2] << 4) | (c[:, :, 3] << 6)).astype(np.uint8) + out = np.empty((x.shape[0], 18), dtype=np.uint8) + out[:, :2] = d16.view(np.uint8).reshape(-1, 2) + out[:, 2:] = packed + return out.reshape(-1) + + +def q4_0(w: np.ndarray) -> np.ndarray: + """ggml quantize_row_q4_0_ref: d = max / -8 (signed max), code = clamp(round(x/d + 8.5) truncated, 0, 15).""" + x = w.reshape(-1, 32).astype(np.float32) + idx = np.abs(x).argmax(axis=1) + mx = x[np.arange(x.shape[0]), idx][:, None] + d = mx / -8.0 + inv = np.where(d != 0, 1.0 / np.where(d != 0, d, 1.0), 0.0) + q = np.minimum(15, np.trunc(x * inv + 8.5)).astype(np.uint8) + packed = (q[:, :16] | (q[:, 16:] << 4)).astype(np.uint8) + out = np.empty((x.shape[0], 18), dtype=np.uint8) + out[:, :2] = d.astype(np.float16).view(np.uint8).reshape(-1, 2) + out[:, 2:] = packed + return out.reshape(-1) + + +def q8_0(w: np.ndarray) -> np.ndarray: + x = w.reshape(-1, 32).astype(np.float32) + d = np.abs(x).max(axis=1, keepdims=True) / 127.0 + inv = np.where(d > 0, 1.0 / np.where(d > 0, d, 1.0), 0.0) + q = np.rint(x * inv).astype(np.int8) + out = np.empty((x.shape[0], 34), dtype=np.uint8) + out[:, :2] = d.astype(np.float16).view(np.uint8).reshape(-1, 2) + out[:, 2:] = q.view(np.uint8) + return out.reshape(-1) + + +def dequant(kind: str, blob: np.ndarray, n: int) -> np.ndarray: + if kind == "q2_0": + b = blob.reshape(-1, 18) + d = b[:, :2].copy().view(np.float16).astype(np.float32) + qs = b[:, 2:] + codes = np.stack([(qs >> s) & 3 for s in (0, 2, 4, 6)], axis=2).reshape(-1, 64).astype(np.float32) + return ((codes - 1.0) * d).reshape(-1)[:n] + if kind == "q4_0": + b = blob.reshape(-1, 18) + d = b[:, :2].copy().view(np.float16).astype(np.float32) + qs = b[:, 2:] + q = np.concatenate([qs & 15, qs >> 4], axis=1).astype(np.float32) + return ((q - 8.0) * d).reshape(-1)[:n] + b = blob.reshape(-1, 34) + d = b[:, :2].copy().view(np.float16).astype(np.float32) + return (b[:, 2:].view(np.int8).astype(np.float32) * d).reshape(-1)[:n] + + +QUANT = {"q2_0": (q2_0, gguf.GGMLQuantizationType.Q2_0, 64, 18), + "q4_0": (q4_0, gguf.GGMLQuantizationType.Q4_0, 32, 18), + "q8_0": (q8_0, gguf.GGMLQuantizationType.Q8_0, 32, 34)} + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--src", required=True, help="directory written by tools/mtp_fetch.py fetch") + ap.add_argument("--experts", choices=sorted(QUANT), default="q2_0") + ap.add_argument("--out", required=True) + ap.add_argument("--check-experts", type=int, default=8, help="experts per tensor used for the error report") + a = ap.parse_args() + src = Path(a.src) + manifest = json.loads((src / "mtp-manifest.json").read_text()) + fn, qtype, block, block_bytes = QUANT[a.experts] + w = gguf.GGUFWriter(a.out, "qwen4exp-mtp") + w.add_string("strata.mtp.source", "Qwen/Qwen3.8-Flash-Next BF16 checkpoint, mtp.* tensors") + w.add_string("strata.mtp.source_sha256", hashlib.sha256( + json.dumps({t["name"]: t["sha256"] for t in manifest}, sort_keys=True).encode()).hexdigest()) + w.add_string("strata.mtp.expert_format", a.experts) + w.add_string("strata.mtp.expert_quantizer", "per-block MSE scale search" if a.experts == "q2_0" else "ggml reference") + report = [] + for t in sorted(manifest, key=lambda t: t["name"]): + name, shape = t["name"], t["shape"] + t0 = time.time() + if name in EXPERT_TENSORS: + # Streamed one expert at a time from a read-only view: the fused gate_up tensor alone is 6.7 GB as + # float32, so it is never materialized whole. + raw = np.memmap(src / t["file"], dtype="/mtp-bf16/mtp-q2_0.gguf --out /mtp-bf16/rt + +Writes + experts.bin 512 routed experts in the engine's blob layout (`include/strata/kernels/cpu/expert.hpp`): gate/up + rows interleaved (2r = gate r, 2r+1 = up r), then down rows; the Q2_0 codes in one plane and the fp16 + scales in another. A lossless relayout of the GGUF's Q2_0 blocks (same bytes, `cpu_expert_fixture.py`). + dense.bin every other tensor: the large projections quantized to Q8_0 (ggml's reference rounding) so the + engine's multi-column MMVQ can run them; the hyper-connection and router weights kept BF16; the + RMSNorm weights as F32 with the Gemma "+1" applied (vLLM GemmaRMSNorm scales by 1 + w). + dense.txt one line per tensor: name kind rows cols offset bytes (kind = q8_0 | bf16 | f32) +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import numpy as np + +STRATA = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _paths import add_gguf_py # noqa: E402 +add_gguf_py() +import gguf # noqa: E402 + +H, FF, NE = 2560, 640, 512 +BLOB = 3 * (H * FF * 18 // 64) +Q8 = {"fc_embedding.weight", "fc_hidden.weight", "self_attn.q_proj.weight", "self_attn.k_proj.weight", + "self_attn.v_proj.weight", "self_attn.o_proj.weight", "self_attn.indexer.index_qk_proj.weight", + "mlp.shared_expert.gate_proj.weight", "mlp.shared_expert.up_proj.weight", "mlp.shared_expert.down_proj.weight"} + + +def q8_0(x: np.ndarray) -> bytes: + """ggml quantize_row_q8_0_ref over rows of a 2-D float32 array: 32-value blocks, fp16 d = amax / 127.""" + b = x.reshape(-1, 32).astype(np.float32) + amax = np.abs(b).max(axis=1) + d = amax / 127.0 + inv = np.where(d > 0, 1.0 / np.where(d > 0, d, 1.0), 0.0).astype(np.float32) + v = b * inv[:, None] + q = (np.sign(v) * np.floor(np.abs(v) + 0.5)).astype(np.int8) + out = np.empty((b.shape[0], 34), dtype=np.uint8) + out[:, :2] = d.astype(np.float16).view(np.uint8).reshape(-1, 2) + out[:, 2:] = q.view(np.uint8) + return out.tobytes() + + +def blob_of(gu: np.ndarray, dn: np.ndarray) -> bytes: + """gu: (1280, 720) Q2_0 rows (gate rows 0..639, up rows 640..1279); dn: (2560, 180).""" + gub = gu.reshape(2 * FF, H // 64, 18) + inter = np.empty_like(gub) + inter[0::2] = gub[:FF] + inter[1::2] = gub[FF:] + dnb = dn.reshape(H, FF // 64, 18) + parts = [inter[:, :, 2:].tobytes(), dnb[:, :, 2:].tobytes(), inter[:, :, :2].tobytes(), dnb[:, :, :2].tobytes()] + out = b"".join(parts) + assert len(out) == BLOB, len(out) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--gguf", required=True) + ap.add_argument("--out", required=True) + a = ap.parse_args() + out = Path(a.out) + out.mkdir(parents=True, exist_ok=True) + r = gguf.GGUFReader(a.gguf) + tens = {t.name: t for t in r.tensors} + gu = np.asarray(tens["mtp.layers.0.mlp.experts.gate_up_proj"].data) + dn = np.asarray(tens["mtp.layers.0.mlp.experts.down_proj"].data) + assert gu.shape == (NE, 2 * FF, 720) and dn.shape == (NE, H, 180), (gu.shape, dn.shape) + with open(out / "experts.bin", "wb") as f: + for e in range(NE): + f.write(blob_of(gu[e], dn[e])) + lines = [] + off = 0 + with open(out / "dense.bin", "wb") as f: + for name, t in tens.items(): + if "experts." in name: + continue + short = name[len("mtp."):] + short = short[len("layers.0."):] if short.startswith("layers.0.") else short + data = np.asarray(t.data) + if int(t.tensor_type) == 0: # F32 norm weights: raw GemmaRMSNorm w -> 1 + w + arr = (data.astype(np.float32) + 1.0) + raw, kind, rows, cols = arr.tobytes(), "f32", 1, arr.size + else: # BF16 + u16 = data.view(np.uint16) if data.dtype != np.uint16 else data + u16 = u16.reshape(data.shape[0], -1) + rows, cols = u16.shape + if short in Q8: + f32 = (u16.astype(np.uint32) << 16).view(np.float32) + raw, kind = q8_0(f32), "q8_0" + else: + raw, kind = u16.tobytes(), "bf16" + pad = (-off) % 256 + f.write(b"\0" * pad) + off += pad + f.write(raw) + lines.append(f"{short} {kind} {rows} {cols} {off} {len(raw)}") + off += len(raw) + (out / "dense.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"experts.bin {NE * BLOB} B, dense.bin {off} B, {len(lines)} tensors -> {out}") + for l in lines: + print(" " + l) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/pack_index.py b/tools/pack_index.py new file mode 100644 index 00000000..02678acb --- /dev/null +++ b/tools/pack_index.py @@ -0,0 +1,260 @@ +"""tools/pack_index.py - emit a flat index of the pack's dense tensors for the engine's loader. + +WHY AN INDEX AND NOT A JSON PARSER. The manifest is JSON and the engine is C++. A JSON parser in the engine +would be a new, unaudited component whose failure mode is a wrong byte offset - which decodes to a plausible +weight and produces plausible logits. Python already parses the manifest correctly and is already the tool +that WROTE it, so this flattens what the loader needs into one line per tensor. + +WHY TEXT AND NOT BINARY. `pack/full/index.txt` is ~150 KB for 1,079 tensors. A fixed-size binary record +would save 60 KB and cost a struct-packing contract between two languages that must agree on padding, +endianness and field order - three ways to be wrong for no benefit at this size. Text is also readable when +a load produces the wrong number, which is the only time this file matters. + +THE ENGINE LAYOUT IS NOT THE PACK LAYOUT, and that is the point of `dst_*`. Two independent differences: + + 1. A tensor whose source type is BF16 or F16 is stored in the pack PROMOTED to 32 bits, because that is + lossless and it keeps the canonical decoder simple. A running engine does not have to hold it that way: + re-rounding to 16 bits costs nothing at all, because the value came FROM 16 bits. That is 1.374 GiB of + the 5.305 GiB dense file, and per `tools/pack_budget.py` it is the difference between the dense weights + fitting in VRAM beside the expert cache and not fitting at all. + + 2. **A SCALE PLANE MAY BE 2 BYTES IN THE PACK AND IS ALWAYS 4 IN THE ENGINE.** This is the one that hid + for a whole round. 90 of the 303 quantized tensors - every Q2_0 (all 58 expert tensors), Q4_0, Q5_0, + Q8_0 and IQ4_NL - carry fp16 scales, because that is what the SOURCE ggml block stores (a Q5_0 block is + `{fp16 d; ...}`). 213 tensors (IQ4_XS, Q3_K, Q4_K, Q5_K, Q6_K) carry f32 scales. The manifest has said + which is which all along, in `scales_fp16`. + + `src/core/layer.cpp` did not read that field. It assumed 4 bytes per scale, so it computed a scale plane + twice the real size, the three planes did not add up to the tensor, and the loader REFUSED - which is how + this was found, and the refusal is the only reason it was not a silent wrong answer. The first diagnosis + was "the manifest's `group_elems` must be wrong, the real group is 64". It is not: `group_elems` is 32 + and the scale plane is 2 bytes wide. **The unexamined input was the WIDTH, not the count.** An audit of + all 303 tensors against `scales_fp16`/`offsets_fp16` found zero inconsistencies, which is what settled it. + + The engine widens fp16 scales to f32 at load. That is EXACT - fp16 is a subset of f32 - and it costs + 11,407,360 B (10.88 MiB) of the 4.5 GiB pool, against teaching three kernel families a second scale + width. Note the asymmetry that makes this cheap and the BF16 re-rounding large: re-rounding loses + precision and saves GiB; widening loses nothing and costs MiB. + + `offsets_fp16` is also read, and it is FALSE for all 54 tensors that have an offset plane. It is + honoured anyway rather than assumed, because the next pack may not be. + +Columns: + + name file kind src_off src_bytes dst_off dst_bytes ne0 ne1 code_bits code_bias group_elems codebook + has_offset codes_bytes scales_bytes offset_bytes scales_fp16 + +`kind` is what the loader must DO, spelled out here rather than left for it to infer - the four cases are +distinguishable in the manifest but only by combining `source_type` with `values_fp16`, and getting that +combination wrong produces a plausible tensor: + + 0 VERBATIM copy straight through (BF16-free: F16 already 2 B/elem, F32, and the planes below) + 1 BF16_IN_F32 the pack holds the bf16 value promoted to f32; take the HIGH 16 BITS. Exact, not a + rounding: a bf16 value IS the top half of its f32 image. + 2 F32 copy 4 B/elem + 3 F16_IN_F32 the pack holds an f16 value promoted to f32; needs a real f32->f16 conversion with + round-to-nearest-even, NOT a truncation + +`ne0`/`ne1` are the manifest's own shape in its own order, with ne0 the CONTIGUOUS axis. `form` is the +canonical form letter from the manifest (P16/P32/S2/S4/S8); the SForm attributes are 0 for kinds 1-3. + +`codes_bytes`/`scales_bytes`/`offset_bytes` are the THREE ENGINE-FORM PLANE SIZES, in the order they sit in +the destination. They are emitted rather than left for the loader to re-derive, because re-deriving is where +the width bug lived: two places computing one layout is two places to disagree, and the loader cannot check a +layout it invented. The loader instead checks what it was TOLD: `scales_bytes/2` when `scales_fp16`, and the +three planes summing to both `src_bytes` (source widths) and `dst_bytes` (engine widths). Non-quantized +tensors carry `codes_bytes == dst_bytes` and two zeroes. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +FILE_ID = {"dense.bin": 0, "embd.bin": 1, "experts.bin": 2} +VERBATIM, BF16_IN_F32, F32_COPY, F16_IN_F32 = 0, 1, 2, 3 + +# WHICH ACTIVATION THIS WEIGHT WANTS, decided HERE because it is a property of `source_type` and the engine's +# `SForm` cannot recover it. The legacy formats (Q2_0, Q4_0, Q5_0, Q8_0, IQ4_NL) have `vec_dot_type` Q8_0; the +# K-quants and IQ4_XS have Q8_K. **NO COMBINATION OF THE S-FORM FIELDS TELLS THEM APART**: Q5_0 and Q5_K are +# both 8-bit with bias -16 (only `has_offset` differs), and IQ4_NL and IQ4_XS are both 4-bit with the IQ4NL +# codebook and no offset - they differ in NOTHING the S-form carries. That is why this column exists rather +# than the engine re-deriving the rule from `code_bits`. +ACT_Q8_0, ACT_Q8_K = 0, 1 +VDT = { + "Q2_0": ACT_Q8_0, "Q4_0": ACT_Q8_0, "Q5_0": ACT_Q8_0, "Q8_0": ACT_Q8_0, "IQ4_NL": ACT_Q8_0, + "Q3_K": ACT_Q8_K, "Q4_K": ACT_Q8_K, "Q5_K": ACT_Q8_K, "Q6_K": ACT_Q8_K, "IQ4_XS": ACT_Q8_K, +} + +# The plane key is `offsets`, NOT `mins`. The first version of this list said `mins` - which is what ggml calls +# the quantity conceptually, and the manifest does not - and a missing key in a loop like this is not an error, +# it is a SHORTER SPAN: 54 Q4_K/Q5_K tensors loaded with their offset plane never copied. Keys now come from +# the manifest, and the layout is verified against the recorded plane offsets rather than assumed. +PLANE_KEYS = ("codes", "scales", "offsets") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--pack", default="pack/full") + ap.add_argument("-o", "--out", default=None, help="default: /index.txt") + args = ap.parse_args() + out_path = args.out or os.path.join(args.pack, "index.txt") + + with open(os.path.join(args.pack, "manifest.json"), "r", encoding="utf-8") as fh: + man = json.load(fh) + align = int(man.get("align", 64)) + + def align_up(n: int) -> int: + return (n + align - 1) // align * align + + rows = [] + dst = 0 + n_reduced = 0 + saved = 0 + widened = 0 + n_widened = 0 + kinds: dict[int, int] = {} + for name, v in man["tensors"].items(): + f = v.get("file") + if f not in FILE_ID: + print("unknown file %r for %s" % (f, name), file=sys.stderr) + return 1 + shape = v.get("shape") or [0, 0] + ne0 = int(shape[0]) if len(shape) > 0 else 0 + ne1 = int(shape[1]) if len(shape) > 1 else 0 + elems = int(v.get("elements", 0)) + st = v.get("source_type") or "?" + + codes_bytes = scales_bytes = offset_bytes = 0 + scales_fp16 = 0 + widen_here = 0 + act_kind = ACT_Q8_0 + if "values" in v: + src_off = int(v["values"]["offset"]) + src_bytes = int(v["values"]["bytes"]) + else: + planes = [k for k in PLANE_KEYS if isinstance(v.get(k), dict)] + lo = min(int(v[k]["offset"]) for k in planes) + hi = max(int(v[k]["offset"]) + int(v[k].get("bytes", 0)) for k in planes) + src_off, src_bytes = lo, hi - lo + + # the planes must be CONTIGUOUS, in order, because the loader reads them as one span. If a future + # pack interleaves them this refuses; the alternative is reading the scale plane as offsets and + # decoding a plausible weight. + expect = src_off + for k in PLANE_KEYS: + if k not in planes: + continue + if int(v[k]["offset"]) != expect: + print("%s: plane %r starts at %d but %d was expected - planes are not contiguous" + % (name, k, int(v[k]["offset"]), expect), file=sys.stderr) + return 1 + expect += int(v[k].get("bytes", 0)) + if expect != src_off + src_bytes: + print("%s: plane span %d does not end at %d" % (name, expect, src_off + src_bytes), + file=sys.stderr) + return 1 + + if v.get("has_offset") and "offsets" not in planes: + print("%s says has_offset but has no `offsets` plane (found %s)" % (name, planes), + file=sys.stderr) + return 1 + + act_kind = VDT.get(st, ACT_Q8_0) # refuses below if the type is unknown + code_bits = int(v.get("code_bits") or 0) + group_elems = int(v.get("group_elems") or 0) + if code_bits == 0 or group_elems == 0: + print("%s: a quantized tensor with code_bits=%d group_elems=%d" % (name, code_bits, group_elems), + file=sys.stderr) + return 1 + if ne0 % group_elems: + print("%s: ne0 %d is not a multiple of group_elems %d" % (name, ne0, group_elems), + file=sys.stderr) + return 1 + n_groups = ne0 // group_elems + codes_bytes = elems * code_bits // 8 + scales_fp16 = 1 if v.get("scales_fp16") else 0 + offs_fp16 = 1 if v.get("offsets_fp16") else 0 + # engine-form plane sizes: ALWAYS 4 B per scale. fp16 in the pack is widened; the load is exact. + scales_bytes = ne1 * n_groups * 4 + offset_bytes = ne1 * n_groups * 4 if v.get("has_offset") else 0 + src_planes = (codes_bytes + + ne1 * n_groups * (2 if scales_fp16 else 4) + + (ne1 * n_groups * (2 if offs_fp16 else 4) if v.get("has_offset") else 0)) + if src_planes != int(v["codes"]["bytes"]) + int(v["scales"]["bytes"]) \ + + int(v.get("offsets", {}).get("bytes", 0)): + print("%s: derived planes %d disagree with the manifest's own plane bytes %d" + % (name, src_planes, int(v["codes"]["bytes"]) + int(v["scales"]["bytes"]) + + int(v.get("offsets", {}).get("bytes", 0))), file=sys.stderr) + return 1 + if src_planes != src_bytes: + print("%s: derived planes %d do not fill the span %d" % (name, src_planes, src_bytes), + file=sys.stderr) + return 1 + if scales_fp16: + widen_here = scales_bytes - ne1 * n_groups * 2 + widened += widen_here + n_widened += 1 + + # ---- the engine form, as an explicit KIND rather than a flag to interpret + vf = bool(v.get("values_fp16")) + if st == "BF16": + kind = BF16_IN_F32 + dst_bytes = elems * 2 + saved += src_bytes - dst_bytes + n_reduced += 1 + elif st == "F16" and not vf: + kind = F16_IN_F32 + dst_bytes = elems * 2 + saved += src_bytes - dst_bytes + n_reduced += 1 + elif st == "F16": + kind = VERBATIM # already 2 bytes on disk + dst_bytes = src_bytes + elif st == "F32": + kind = F32_COPY + dst_bytes = elems * 4 + else: + kind = VERBATIM + dst_bytes = codes_bytes + scales_bytes + offset_bytes + # THE ENGINE FORM OF A QUANTIZED TENSOR DIFFERS FROM THE PACK BY EXACTLY THE WIDENING, and saying + # so here is what makes the two plane sizes a CHECK rather than two independent guesses. + if dst_bytes != src_bytes + widen_here: + print("%s: engine form %d != pack %d + widening %d" % (name, dst_bytes, src_bytes, widen_here), + file=sys.stderr) + return 1 + + cb = v.get("codebook") + rows.append((name, FILE_ID[f], kind, src_off, src_bytes, dst, dst_bytes, ne0, ne1, + int(v.get("code_bits") or 0), int(v.get("code_bias") or 0), + int(v.get("group_elems") or 0), 1 if cb == "IQ4NL" else 0, + 1 if v.get("has_offset") else 0, + codes_bytes, scales_bytes, offset_bytes, scales_fp16, act_kind)) + dst = align_up(dst + dst_bytes) + kinds[kind] = kinds.get(kind, 0) + 1 + + with open(out_path, "w", encoding="utf-8", newline="\n") as fh: + fh.write("# strata pack index v3 -- generated by tools/pack_index.py; do not hand-edit\n") + fh.write("# align %d pool %d tensors %d\n" % (align, dst, len(rows))) + fh.write("# name file kind src_off src_bytes dst_off dst_bytes ne0 ne1 code_bits code_bias " + "group_elems codebook has_offset codes_bytes scales_bytes offset_bytes scales_fp16 act_kind\n") + for r in rows: + fh.write("%s %d %d %d %d %d %d %d %d %d %d %d %d %d %d %d %d %d %d\n" % r) + + names = {VERBATIM: "VERBATIM", BF16_IN_F32: "BF16_IN_F32", F32_COPY: "F32", F16_IN_F32: "F16_IN_F32"} + print("%s: %d tensors, engine pool %d B (%.3f GiB)" % (out_path, len(rows), dst, dst / 2**30)) + for k in sorted(kinds): + print(" kind %-12s %4d tensors" % (names[k], kinds[k])) + print(" %d tensors re-rounded to 16 bits, saving %d B (%.3f GiB)" + % (n_reduced, saved, saved / 2**30)) + print(" %d tensors widened fp16 scales -> f32, costing %d B (%.3f GiB)" + % (n_widened, widened, widened / 2**30)) + print(" compare with tools/pack_budget.py: the pack's dense+embd engine figure should match this pool") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/pack_layer.py b/tools/pack_layer.py new file mode 100644 index 00000000..f9263690 --- /dev/null +++ b/tools/pack_layer.py @@ -0,0 +1,123 @@ +"""Strata P0.S9 - build the pack-layout expert arena from the real GGUF. + +The architecture (§3.2) defines one expert blob as 1,382,400 bytes: + + gate_up.codes 2 x 640 rows x 640 B rows interleaved g0,u0,g1,u1,... (share input x) + down.codes 2,560 rows x 160 B + gate_up.scales 2 x 640 x 40 x FP16 + down.scales 2,560 x 10 x FP16 + +and that totals exactly 819,200 + 409,600 + 102,400 + 51,200 = 1,382,400 B, matching the artifact's +Q2_0 expert size to the byte. The conversion is a pure relayout: Q2_0 codes and their FP16 block +scale are copied unchanged, so dequant(pack) == dequant(ggml) bit-exactly. + +Why build this at all rather than read the GGUF in place: the GGUF stores gate, up and down as three +separate tensors, so a fused gate/up pass would read two streams from three arrays. The pack +interleaves gate and up ROWS so one pass over the activation vector serves both, which is the whole +point of the blob layout. + +Expert index is the LAST axis of [in, out, experts] (P0.S6), and ne[0] varies fastest, so expert `e` +is one CONTIGUOUS 460,800-byte run inside each role tensor. Extraction is a strided copy. + +Run: python tools/pack_layer.py [n_layers] (default 1; 48 = the full 34 GB arena) +""" +from __future__ import annotations + +import json +import pathlib +import struct +import sys + +import numpy as np + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from gguf_reader import GGUFFile # noqa: E402 + +REPO = pathlib.Path(__file__).resolve().parents[1] +STRATA = REPO.parents[1] # the development layout's default shards (setup.py always passes --gguf) +SHARD1 = STRATA / "Q2_0" / "Qwen3.8-Flash-Next-GSQ-RCO-Q2_0-00001-of-00002.gguf" + +H, FF, NE = 2560, 640, 512 +QK, BLOCK_BYTES = 64, 18 # Q2_0 +ROW_BYTES_GU = H * 2 // 8 # 640 B of codes for one 2,560-wide row +ROW_BYTES_D = FF * 2 // 8 # 160 B of codes for one 640-wide row +SCALES_GU = H // QK # 40 blocks per gate/up row +SCALES_D = FF // QK # 10 blocks per down row +ROLE_BYTES = H * FF * BLOCK_BYTES // QK # 460,800 B per role per expert +BLOB_BYTES = 3 * ROLE_BYTES # 1,382,400 + +assert BLOB_BYTES == 1_382_400, BLOB_BYTES +assert 2 * 640 * ROW_BYTES_GU + 2560 * ROW_BYTES_D + 2 * 640 * SCALES_GU * 2 \ + + 2560 * SCALES_D * 2 == BLOB_BYTES + + +def blob_offsets() -> dict[str, int]: + gu_codes = 0 + d_codes = gu_codes + 2 * FF * ROW_BYTES_GU + gu_scales = d_codes + H * ROW_BYTES_D + d_scales = gu_scales + 2 * FF * SCALES_GU * 2 + end = d_scales + H * SCALES_D * 2 + assert end == BLOB_BYTES, end + return {"gate_up_codes": gu_codes, "down_codes": d_codes, + "gate_up_scales": gu_scales, "down_scales": d_scales} + + +OFF = blob_offsets() + + +def build(n_layers: int, out_path: pathlib.Path) -> dict: + g = GGUFFile(SHARD1) + by_name = {t.name: t for t in g.tensors} + src = open(SHARD1, "rb") + manifest = {"blob_bytes": BLOB_BYTES, "offsets": OFF, "layers": [], "n_layers": n_layers} + total = n_layers * NE * BLOB_BYTES + out_path.parent.mkdir(parents=True, exist_ok=True) + + with out_path.open("wb") as out: + for layer in range(n_layers): + roles = {} + for role in ("gate", "up", "down"): + t = by_name[f"blk.{layer}.ffn_{role}_exps.weight"] + roles[role] = t + assert t.type_name == "Q2_0", t.type_name + # expert e is a contiguous run inside the role tensor + per_expert = t.elements // NE + assert per_expert * BLOCK_BYTES // QK == ROLE_BYTES, per_expert + layer_off = out.tell() + # vectorized over the layer's 512 experts (the same bytes the per-block loop wrote; tools/strata_pack.py + # verify checks them): each role is (512 experts, rows, blocks per row, 18 bytes) + def role_blocks(role, rows, per_row): + t = roles[role] + n = NE * ROLE_BYTES + src.seek(g.data_start + t.offset) + a = np.frombuffer(src.read(n), dtype=np.uint8) + assert a.size == n, (role, a.size) + return a.reshape(NE, rows, per_row, BLOCK_BYTES) + gate = role_blocks("gate", FF, SCALES_GU) + up = role_blocks("up", FF, SCALES_GU) + down = role_blocks("down", H, SCALES_D) + gu = np.stack([gate, up], axis=2).reshape(NE, 2 * FF, SCALES_GU, BLOCK_BYTES) # rows g0,u0,g1,u1,... + blobs = np.concatenate([ + gu[:, :, :, 2:].reshape(NE, -1), # gate/up codes + down[:, :, :, 2:].reshape(NE, -1), # down codes + gu[:, :, :, :2].reshape(NE, -1), # gate/up fp16 scales + down[:, :, :, :2].reshape(NE, -1), # down fp16 scales + ], axis=1) + assert blobs.shape == (NE, BLOB_BYTES), blobs.shape + out.write(blobs.tobytes()) + manifest["layers"].append({"layer": layer, "offset": layer_off, + "role_offsets": {r: by_name[f"blk.{layer}.ffn_{r}_exps.weight"].offset + for r in ("gate", "up", "down")}}) + print(f" layer {layer}: wrote {NE} blobs at {layer_off}") + src.close() + manifest["total_bytes"] = total + (out_path.with_suffix(".json")).write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return manifest + + +if __name__ == "__main__": + n = int(sys.argv[1]) if len(sys.argv) > 1 else 1 + dest = REPO / "pack" / ("layer0.bin" if n == 1 else f"experts-{n}l.bin") + print(f"building {n} layer(s) -> {dest} ({(n*NE*BLOB_BYTES)/2**30:.2f} GiB)") + m = build(n, dest) + print(f"done: {m['total_bytes']/2**30:.2f} GiB, {n*NE} blobs") diff --git a/tools/strata_pack.py b/tools/strata_pack.py new file mode 100644 index 00000000..6ad7cf5b --- /dev/null +++ b/tools/strata_pack.py @@ -0,0 +1,413 @@ +"""Strata P1.S6/S7 - the pack builder and verifier. + +This is `strata-pack build` / `strata-pack verify` / `strata-pack info`. It is the Python implementation of +`docs/pack-format.md`; the C++ `tools/strata-pack` is a port of it, and if the two disagree the C++ is wrong. + +WHY PYTHON FIRST. The canonicalisation in `tools/canonical_xcheck.py` is measured bit-exact over every block +of every tensor (757,139,840 blocks across 1021 tensors, plus Q2_0), against gguf-py and, for Q2_0, backed by +`dequant_xcheck` against ggml itself. That makes the Python mapping table the specification of record. The +pack is Phase 2's prerequisite and can be produced from it today; the port then has a golden artifact to be +tested against instead of being the thing everything else waits on. + +THE ONE RULE. A pack is only valid if, for every tensor, `decode(pack) == dequant_ggml(source)` BIT FOR BIT. +`verify` is that test and it is not optional - it re-reads the written pack from disk and compares it against +the source shard. A pack that has not passed `verify` does not exist. + +SCALE PRECISION. A canonical scale is an FP32 product (`fl(d*sc)`), which is generally NOT representable in +FP16. The packer therefore stores scales as FP16 only when the round-trip is exact and as FP32 otherwise, and +records which per tensor. It never has to decide correctly: `verify` re-checks bit-exactness, so a wrong +choice is a failed build rather than a silently lossy pack. The expert arena is the exception and is FP16 by +construction - architecture §3.2 fixes the blob at exactly 1,382,400 bytes and pack_layer.py asserts it. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import struct +import sys + +import numpy as np + +HERE = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HERE.parent / "ref")) + +import gguf_reader as G # noqa: E402 +import pack_layer as PL # noqa: E402 +from canonical_xcheck import (MAPPINGS, KV_IQ4NL, open_shard, # noqa: E402 + data_section_offset, reference_values) + +FORMAT_VERSION = 1 +ALIGN = 64 # every plane starts on a 64-byte boundary (P1.S7) +STRATA = pathlib.Path(__file__).resolve().parents[3] # the development layout; build takes the shard from --gguf +SHARD2 = STRATA / "Q2_0" / "Qwen3.8-Flash-Next-GSQ-RCO-Q2_0-00002-of-00002.gguf" + +EXPERT_RE = ("ffn_gate_exps.weight", "ffn_up_exps.weight", "ffn_down_exps.weight") +EMBD_NAME = "token_embd.weight" + + +def is_expert(name: str) -> bool: + return name.startswith("blk.") and name.endswith(EXPERT_RE) + + +def code_bits(form: str) -> int: + return {"S2": 2, "S4": 4, "S8": 8}[form] + + +def pack_codes(codes: np.ndarray, bits: int) -> bytes: + """Pack one code per element into bytes, LSB-first, `8//bits` codes per byte. + + This is Q2_0's own convention (4 codes per byte, byte j/4, bits (j%4)*2), kept for every form so the + kernel has one unpacking loop rather than one per source type. + """ + flat = codes.reshape(-1).astype(np.uint8) + if bits == 8: + return flat.tobytes() + per = 8 // bits + if flat.size % per: + raise ValueError("code count %d is not a multiple of %d per byte" % (flat.size, per)) + g = flat.reshape(-1, per).astype(np.uint16) + out = np.zeros(g.shape[0], dtype=np.uint16) + for k in range(per): + out |= (g[:, k] & ((1 << bits) - 1)) << (k * bits) + return out.astype(np.uint8).tobytes() + + +def unpack_codes(buf: bytes, bits: int, n: int) -> np.ndarray: + if bits == 8: + return np.frombuffer(buf, dtype=np.uint8)[:n].copy() + per = 8 // bits + b = np.frombuffer(buf, dtype=np.uint8) + out = np.empty((b.size, per), dtype=np.uint8) + for k in range(per): + out[:, k] = (b >> (k * bits)) & ((1 << bits) - 1) + return out.reshape(-1)[:n].copy() + + +def choose_scale_dtype(scales: np.ndarray) -> tuple[np.ndarray, bool]: + """FP16 when the round-trip is exact, else FP32. Returns (bytes, is_fp16).""" + as16 = scales.astype(np.float16) + if np.array_equal(as16.astype(np.float32), scales): + return as16.tobytes(), True + return scales.astype(" the canonical planes, as (codes, scales, offsets_or_None, mapping).""" + m = MAPPINGS[tname] + parts = m.enc(raw) + if len(parts) == 3: + return parts[0], parts[1], parts[2], m + return parts[0], parts[1], None, m + + +def tensor_entry(name, meta, tname, raw, seg_offset): + """Encode one tensor and return (blob_bytes, manifest_entry).""" + m = MAPPINGS[tname] + + body = bytearray() + base = seg_offset + + def put(b: bytes) -> dict: + nonlocal body + pad = (-len(body)) % ALIGN + body += b"\0" * pad + off = base + len(body) + body += b + return {"offset": off, "bytes": len(b)} + + common = { + "name": name, "source_type": tname, "form": m.form, "codebook": m.codebook, + "code_bias": m.code_bias, "has_offset": m.has_offset, + "block_elems": m.block_elems, "block_bytes": m.block_bytes, "group_elems": m.group_elems, + "shape": list(meta.shape), "source_offset": meta.offset, + } + + # ---- P16/P32 are pass-through: one value plane, no codes, no scale, no offset + if m.form in ("P16", "P32"): + vals = np.ascontiguousarray(m.dec(m.enc(raw)), dtype=np.float32).reshape(-1) + if m.form == "P32": + vb, v16 = vals.astype(" np.ndarray: + """Decode a tensor from the pack, using ONLY what the manifest says. + + Deliberately manifest-driven: if the manifest does not carry enough to decode the tensor, this cannot + silently fall back on knowledge of the source type, and the omission becomes a failure here. + """ + if ent["form"] in ("P16", "P32"): + vb = head[ent["values"]["offset"]: ent["values"]["offset"] + ent["values"]["bytes"]] + return np.frombuffer(vb, dtype=" str: + h = hashlib.sha256() + with open(path, "rb") as f: + while True: + b = f.read(chunk) + if not b: + break + h.update(b) + return h.hexdigest() + + +def build(gguf: pathlib.Path, out_dir: pathlib.Path, n_layers: int | None, skip_hash: bool) -> int: + out_dir.mkdir(parents=True, exist_ok=True) + g = G.GGUFFile(gguf) + head, flen = open_shard(gguf) + data_off = data_section_offset(g, flen) + print("source %s: %d tensors, data at %d" % (gguf.name, len(g.tensors), data_off)) + + n_layers = 48 if n_layers is None else n_layers + man = { + "format": "strata-pack", "format_version": FORMAT_VERSION, + "source": {"shard1": str(gguf), "shard2": str(SHARD2)}, + "n_layers": n_layers, "align": ALIGN, + "codebooks": {"IQ4NL": [float(x) for x in KV_IQ4NL]}, + "tensors": {}, "experts": {}, "n_experts_per_layer": PL.NE, + } + + # ---- experts.bin: delegated to pack_layer so the blob layout has exactly ONE implementation + # pack_layer reads its own hardcoded SHARD1, so a different --gguf would silently take the expert arena + # from a different file than the dense tensors. Refuse rather than produce a mixed-source pack. + # the expert arena and the dense tensors come from the same file: point pack_layer at it + PL.SHARD1 = pathlib.Path(gguf).resolve() + shard2 = pathlib.Path(str(PL.SHARD1).replace("00001-of-00002", "00002-of-00002")) + man["source"]["shard2"] = str(shard2) + exp_path = out_dir / "experts.bin" + print("experts.bin: %d layers, %.2f GiB" % (n_layers, n_layers * PL.NE * PL.BLOB_BYTES / 2**30)) + em = PL.build(n_layers, exp_path) + man["experts"] = {"blob_bytes": em["blob_bytes"], "offsets": em["offsets"], + "layers": em["layers"], "total_bytes": em["total_bytes"], + "scales_fp16": True, "source_type": "Q2_0", "form": "S2"} + + # ---- dense.bin and embd.bin + dense = [] + embd = [] + for t in g.tensors: + if is_expert(t.name): + continue + (embd if t.name == EMBD_NAME else dense).append(t) + + for fname, group in (("dense.bin", dense), ("embd.bin", embd)): + path = out_dir / fname + seg_base = 0 + with open(path, "wb") as fh: + for t in group: + if t.type_name not in MAPPINGS: + print(" %s: NO MAPPING for %s - refusing to build" % (t.name, t.type_name)) + return 1 + raw = head[data_off + t.offset: data_off + t.offset + (t.expected_bytes() or 0)] + if len(raw) != (t.expected_bytes() or 0): + print(" %s: SHORT READ - refusing to build" % t.name) + return 1 + blob, ent = tensor_entry(t.name, t, t.type_name, raw, seg_base) + ent["file"] = fname + fh.write(blob) + seg_base += len(blob) + man["tensors"][t.name] = ent + print("%s: %d tensors, %.2f GiB" % (fname, len(group), path.stat().st_size / 2**30)) + + man["files"] = {"experts.bin": exp_path.stat().st_size, + "dense.bin": (out_dir / "dense.bin").stat().st_size, + "embd.bin": (out_dir / "embd.bin").stat().st_size} + if not skip_hash: + print("hashing shards (this is the slow part)...") + man["source"]["shard1_sha256"] = sha256(gguf) + man["source"]["shard2_sha256"] = sha256(shard2) if shard2.exists() else None + # shard 2 is NOT copied: the PLE table stays where it is and the manifest records how to find it + if shard2.exists(): + g2 = G.GGUFFile(shard2) + t2 = [t for t in g2.tensors if t.name == "per_layer_token_embd.weight"][0] + man["shard2_tensor"] = {"name": t2.name, "type": t2.type_name, "shape": list(t2.shape), + "offset": t2.offset, "elements": t2.elements, + "path": str(shard2)} + (out_dir / "manifest.json").write_text(json.dumps(man, indent=1), encoding="utf-8") + print("manifest.json: %d tensor entries" % len(man["tensors"])) + return 0 + + +def rebuild_role(blob: bytes, role: str) -> bytes: + """Rebuild one role's raw Q2_0 bytes from an expert blob - the exact inverse of pack_layer's layout. + + The arena stores codes and scales in SEPARATE PLANES and interleaves gate/up ROWS (g0,u0,g1,u1,...), so + a verifier that cannot invert that cannot check the arena. Written as the inverse on purpose: this is + the only independent statement about the blob layout in the tree, and if it and `pack_layer` were the + same code the arena would be unverified by construction. + """ + O, out = PL.OFF, bytearray() + n_blk = PL.H * PL.FF // PL.QK + for blk in range(n_blk): + if role == "down": + row, b_in = blk // PL.SCALES_D, blk % PL.SCALES_D + co = O["down_codes"] + row * PL.ROW_BYTES_D + b_in * 16 + so = O["down_scales"] + (row * PL.SCALES_D + b_in) * 2 + else: + row, b_in = blk // PL.SCALES_GU, blk % PL.SCALES_GU + slot = 2 * row + (0 if role == "gate" else 1) + co = O["gate_up_codes"] + slot * PL.ROW_BYTES_GU + b_in * 16 + so = O["gate_up_scales"] + (slot * PL.SCALES_GU + b_in) * 2 + out += blob[so:so + 2] # fp16 d first, as block_q2_0 has it + out += blob[co:co + 16] # then the 16 packed code bytes + return bytes(out) + + +def verify_experts(gguf: pathlib.Path, out_dir: pathlib.Path, man: dict, every: int) -> int: + """Verify the expert arena: 85% of the pack by bytes and the part Phase 2 reads 480x per token.""" + g = G.GGUFFile(gguf) + head, flen = open_shard(gguf) + data_off = data_section_offset(g, flen) + by_name = {t.name: t for t in g.tensors} + exp_mm, _ = open_shard(out_dir / "experts.bin") + blob = man["experts"]["blob_bytes"] + n_layers = man["n_layers"] + checked = bad = 0 + for layer in range(n_layers): + for e in range(0, PL.NE, every): + base = (layer * PL.NE + e) * blob + b = exp_mm[base:base + blob] + for role in ("gate", "up", "down"): + t = by_name["blk.%d.ffn_%s_exps.weight" % (layer, role)] + per_expert = t.elements // PL.NE + nbytes = per_expert * PL.BLOCK_BYTES // PL.QK + src = head[data_off + t.offset + e * nbytes: data_off + t.offset + (e + 1) * nbytes] + ref = reference_values("Q2_0", src) + got = MAPPINGS["Q2_0"].dec(canonicalise(t.name, "Q2_0", rebuild_role(b, role))[:2]) + checked += 1 + if not np.array_equal(ref, got): + print(" layer %d expert %d %-4s *** MISMATCH *** max|d| %.3e" + % (layer, e, role, np.abs(ref.astype(np.float64) - got.astype(np.float64)).max())) + bad += 1 + print("experts: %d layer/expert/role checks (%d layers, every %dth expert), %d bad" + % (checked, n_layers, every, bad)) + return bad + + +def verify(gguf: pathlib.Path, out_dir: pathlib.Path, limit: int | None, expert_every: int) -> int: + """P1.T3 over the pack: decode it from disk and compare with the source, bit for bit.""" + man = json.loads((out_dir / "manifest.json").read_text(encoding="utf-8")) + g = G.GGUFFile(gguf) + head, flen = open_shard(gguf) + data_off = data_section_offset(g, flen) + by_name = {t.name: t for t in g.tensors} + files = {} + bad = 0 + n_t = n_el = 0 + for i, (name, ent) in enumerate(sorted(man["tensors"].items())): + if limit is not None and i >= limit: + break + if ent["file"] not in files: + files[ent["file"]] = open_shard(out_dir / ent["file"])[0] + t = by_name[name] + raw = head[data_off + t.offset: data_off + t.offset + (t.expected_bytes() or 0)] + ref = reference_values(t.type_name, raw) + got = decode_entry(files[ent["file"]], ent) + n_t += 1 + n_el += ref.size + if ref.shape != got.shape or not np.array_equal(ref, got): + d = np.abs(ref.astype(np.float64) - got.astype(np.float64)) if ref.shape == got.shape else None + print(" %-40s *** MISMATCH *** %s" % (name, ("max|d| %.3e" % d.max()) if d is not None else + "shape %s vs %s" % (ref.shape, got.shape))) + bad += 1 + elif i < 3 or i % 200 == 0: + print(" %-40s bit-exact (%d elements, %s)" % (name, ref.size, ent["form"])) + print() + print("verify: %d tensors, %d elements, %d bad" % (n_t, n_el, bad)) + if expert_every > 0: + bad += verify_experts(gguf, out_dir, man, expert_every) + print("tools/strata_pack.py verify " + ("PASS" if bad == 0 else "FAIL")) + return 0 if bad == 0 else 1 + + +def info(out_dir: pathlib.Path) -> int: + man = json.loads((out_dir / "manifest.json").read_text(encoding="utf-8")) + print("strata-pack v%d layers=%d" % (man["format_version"], man["n_layers"])) + for f, n in man.get("files", {}).items(): + print(" %-12s %12.3f GiB" % (f, n / 2**30)) + forms = {} + for e in man["tensors"].values(): + forms[e["form"]] = forms.get(e["form"], 0) + 1 + print(" dense/embd by form: %s" % ", ".join("%s=%d" % kv for kv in sorted(forms.items()))) + print(" experts: %d blobs x %d B, %s" % (man["experts"]["layers"].__len__() * man["n_experts_per_layer"], + man["experts"]["blob_bytes"], man["experts"]["source_type"])) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + b = sub.add_parser("build") + b.add_argument("--gguf", required=True) + b.add_argument("--out", required=True) + b.add_argument("--layers", type=int, default=None, help="expert layers to emit (default 48)") + b.add_argument("--skip-hash", action="store_true") + v = sub.add_parser("verify") + v.add_argument("--gguf", required=True) + v.add_argument("--out", required=True) + v.add_argument("--limit", type=int, default=None) + v.add_argument("--expert-every", type=int, default=32, + help="verify every Nth expert per layer in the arena (0 disables; the arena is 85%% of " + "the pack and verifying NONE of it is how a pack passes while being wrong)") + n = sub.add_parser("info") + n.add_argument("--out", required=True) + args = ap.parse_args() + if args.cmd == "build": + return build(pathlib.Path(args.gguf), pathlib.Path(args.out), args.layers, args.skip_hash) + if args.cmd == "verify": + return verify(pathlib.Path(args.gguf), pathlib.Path(args.out), args.limit, args.expert_every) + return info(pathlib.Path(args.out)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/strata_tokenizer.py b/tools/strata_tokenizer.py new file mode 100644 index 00000000..d81da51c --- /dev/null +++ b/tools/strata_tokenizer.py @@ -0,0 +1,266 @@ +"""tools/strata_tokenizer.py - the tokenizer, as the reference implementation of record. + +WHY THIS EXISTS AND WHERE IT SITS. The GGUF carries the whole tokenizer in metadata: `tokenizer.ggml.model` += `gpt2`, 248,320 `tokens`, 247,587 `merges`, `token_type`, `pre` = `qwen35`, and a Jinja `chat_template`. +None of that needs the 35 GB of weights, so it is extracted into the pack's `tokenizer/` directory +(docs/pack-format.md §6) and this module is what reads it back. A C++ port follows and is tested against +this one. + +THE DECISIVE PROPERTY IS THE ROUND TRIP. Byte-level BPE maps each BYTE to a printable unicode character so +that any UTF-8 input is representable with no UNK token. Every failure mode of that mapping - the wrong +offset for a byte, a merge applied in the wrong order, a pre-tokenizer split that drops a character - still +produces plausible token ids. `decode(encode(s)) == s` is the check that sees them, and it is exact because +the byte layer is lossless. It is asserted over a corpus chosen to hit the boundaries: multi-byte UTF-8, +emoji (4-byte), combining marks, whitespace runs, and C0 control bytes. +""" +from __future__ import annotations + +import json +import pathlib +import sys + +import regex + +# ------------------------------------------------------------------ byte <-> unicode (GPT-2 byte-level BPE) +def bytes_to_unicode() -> dict[int, str]: + """The GPT-2 byte encoder: 256 bytes -> 256 printable characters, reversibly. + + Bytes 33..126, 161..172 and 174..255 map to themselves; the remaining 68 (space, newline, the C0 + controls and the high range that would be invisible) are shifted into 256+n so that no byte is + unprintable. The shift is the whole trick and getting its ORDER wrong is invisible in the output ids. + """ + bs = (list(range(0x21, 0x7F)) + list(range(0xA1, 0xAD)) + list(range(0xAE, 0x100))) + cs = bs[:] + n = 0 + for b in range(256): + if b not in bs: + bs.append(b) + cs.append(256 + n) + n += 1 + return dict(zip(bs, (chr(c) for c in cs))) + + +BYTE_TO_UNICODE = bytes_to_unicode() +UNICODE_TO_BYTE = {v: k for k, v in BYTE_TO_UNICODE.items()} + +# The `qwen35` pre-tokenizer, transcribed from the ORACLE rather than from the family resemblance: +# `.ref/llama.cpp/src/llama-vocab.cpp` L396, `case LLAMA_VOCAB_PRE_TYPE_QWEN35`. The commented-out line +# above it is the `tokenizer.json` original, and llama.cpp's active version differs from it - it spells the +# contraction classes out instead of using `(?i:...)`, which is behaviourally the same. +# +# THE `\p{M}` IS THE WHOLE POINT OF THIS BEING TRANSCRIBED. The QWEN3 pattern two cases earlier (L389) is +# the same shape with `\p{L}` where this has `[\p{L}\p{M}]` and without `\p{M}` in the punctuation negation. +# Writing the QWEN3 pattern for `qwen35` looks right, round-trips perfectly, and is wrong: a combining mark +# is `\p{M}`, so without it the mark gets swallowed into the following punctuation run and a token like `_j` +# never forms. It cost 3 strings out of 1875 and only `tokenize_oracle_check` could see it. +QWEN35_PATTERN = ( + r"(?:'[sS]|'[tT]|'[rR][eE]|'[vV][eE]|'[mM]|'[lL][lL]|'[dD])" + r"|[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+" + r"|\p{N}" + r"| ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*" + r"|\s*[\r\n]+" + r"|\s+(?!\S)" + r"|\s+" +) + + +class Tokenizer: + def __init__(self, tokens: list[str], merges: list[str], token_types: list[int] | None = None, + pre: str = "qwen35", special_ids: dict[str, int] | None = None): + self.tokens = tokens + self.pre = pre + self.token_types = token_types + self.special_ids = special_ids or {} + self.ids = {t: i for i, t in enumerate(tokens)} + if len(self.ids) != len(tokens): + raise ValueError("vocabulary has duplicate tokens: %d entries, %d unique" + % (len(tokens), len(self.ids))) + # Merge rules as (left, right) -> rank. A merge list that is not a total order over its pairs, or one + # naming a token that is not in the vocabulary, would make BPE silently produce different ids than the + # model was trained with, so both are checked here rather than discovered as a bad answer later. + self.ranks: dict[tuple[str, str], int] = {} + for i, m in enumerate(merges): + parts = m.split(" ") + if len(parts) != 2: + raise ValueError("merge %d is not a pair: %r" % (i, m)) + if parts[0] not in self.ids or parts[1] not in self.ids: + raise ValueError("merge %d names a token outside the vocabulary: %r" % (i, m)) + self.ranks[(parts[0], parts[1])] = i + self._re = regex.compile(QWEN35_PATTERN) + + # The literals matched directly instead of being run through BPE. GGUF token types: 3 = CONTROL, + # 4 = USER_DEFINED. The two classes behave DIFFERENTLY and llama.cpp's own tokenizer settled which: + # + # * type 4 (USER_DEFINED: ``, ``, ``, ...) is matched ALWAYS, + # with or without parse_special. + # * type 3 (CONTROL: `<|im_start|>`, `<|im_end|>`, `<|endoftext|>`, ...) is matched ONLY when + # parse_special is set. + # + # Measured, not assumed: with parse_special=False the oracle still emitted `` as one + # token and mine emitted four, and the three disagreements were exactly the type-4 cases while every + # type-3 case agreed. Treating both classes alike costs 3 strings in 1229 and silently changes a chat + # prompt, because `<|im_end|>` decomposed into ordinary pieces is not the token the model expects. + self.special_tokens: dict[str, int] = {} + if token_types: + for i, ty in enumerate(token_types): + if ty in (3, 4): + self.special_tokens[tokens[i]] = i + always = [t for t, i in self.special_tokens.items() if token_types and token_types[i] == 4] + # Longest literal first, or `<|im_end|>` could match a shorter prefix of itself. `regex.escape` so a + # token containing regex metacharacters (several do: `<|`, `[`, `(`) is matched literally. + self._always_re = self._alt(always) + self._special_re = self._alt(list(self.special_tokens)) + + @staticmethod + def _alt(literals: list[str]): + if not literals: + return None + return regex.compile("|".join(regex.escape(s) for s in sorted(literals, key=len, reverse=True))) + + # -------------------------------------------------------------- constructors + @classmethod + def from_gguf(cls, path) -> "Tokenizer": + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + from gguf_reader import GGUFFile + md = GGUFFile(pathlib.Path(path)).metadata + need = ["tokenizer.ggml.tokens", "tokenizer.ggml.merges"] + missing = [k for k in need if k not in md] + if missing: + raise ValueError("GGUF is missing %s; it does not carry a tokenizer" % ", ".join(missing)) + model = md.get("tokenizer.ggml.model") + if model != "gpt2": + raise ValueError("expected a byte-level BPE tokenizer (model 'gpt2'), got %r" % model) + special = {k: int(md[k]) for k in md + if k.startswith("tokenizer.ggml.") and k.endswith("_token_id")} + return cls(list(md["tokenizer.ggml.tokens"]), list(md["tokenizer.ggml.merges"]), + list(md.get("tokenizer.ggml.token_type") or []) or None, + md.get("tokenizer.ggml.pre", "qwen35"), special) + + # -------------------------------------------------------------- the algorithm + def _bpe(self, word: str) -> list[str]: + """Merge `word` (already byte-mapped) by LOWEST RANK first, repeatedly - not left to right. + + Applying merges in list order rather than rank order is the classic BPE bug: it produces a different + segmentation and a plausible token count. + """ + parts = list(word) + while len(parts) > 1: + best, best_rank = None, None + for i in range(len(parts) - 1): + r = self.ranks.get((parts[i], parts[i + 1])) + if r is not None and (best_rank is None or r < best_rank): + best, best_rank = i, r + if best is None: + break + parts[best:best + 2] = [parts[best] + parts[best + 1]] + return parts + + def _encode_plain(self, text: str) -> list[int]: + out: list[int] = [] + for piece in self._re.findall(text): + mapped = "".join(BYTE_TO_UNICODE[b] for b in piece.encode("utf-8")) + for tok in self._bpe(mapped): + i = self.ids.get(tok) + if i is None: + raise KeyError("BPE produced a token outside the vocabulary: %r" % tok) + out.append(i) + return out + + def _encode_matching(self, text: str, pat) -> list[int]: + """Encode `text`, emitting any literal `pat` matches as single tokens and BPE-ing the rest. + + The split happens on the RAW text, before the byte mapping, because a special token's string is a + literal to match rather than bytes to decompose. Everything between the matches is tokenized + normally - which is why a near-miss like `<|im_star` still costs ordinary tokens. + """ + if pat is None: + return self._encode_plain(text) + out: list[int] = [] + pos = 0 + for m in pat.finditer(text): + if m.start() > pos: + out.extend(self._encode_plain(text[pos:m.start()])) + out.append(self.special_tokens[m.group(0)]) + pos = m.end() + if pos < len(text): + out.extend(self._encode_plain(text[pos:])) + return out + + def encode(self, text: str, parse_special: bool = False) -> list[int]: + """Tokenize `text`. + + `parse_special` controls only the type-3 CONTROL literals such as `<|im_end|>`; the type-4 + USER_DEFINED ones such as `` are matched either way. See the note in `__init__`. + """ + return self._encode_matching(text, self._special_re if parse_special else self._always_re) + + def decode(self, ids: list[int], errors: str = "replace") -> str: + raw = bytearray() + for i in ids: + if i < 0 or i >= len(self.tokens): + raise IndexError("token id %d is outside the vocabulary (%d)" % (i, len(self.tokens))) + for ch in self.tokens[i]: + b = UNICODE_TO_BYTE.get(ch) + if b is None: + raise KeyError("token %d contains a character outside the byte alphabet: %r" % (i, ch)) + raw.append(b) + return raw.decode("utf-8", errors=errors) + + +# ------------------------------------------------------------------ the pack's tokenizer/ directory +def extract(gguf_path, out_dir) -> dict: + """Write the tokenizer into `/tokenizer/` so the engine never opens the weight shards for it.""" + out = pathlib.Path(out_dir) / "tokenizer" + out.mkdir(parents=True, exist_ok=True) + tk = Tokenizer.from_gguf(gguf_path) + cfg = { + "model": "gpt2", + "pre": tk.pre, + "vocab_size": len(tk.tokens), + "n_merges": len(tk.ranks), + "special_ids": tk.special_ids, + "add_bos_token": False, + # The pattern is SHIPPED, not recomputed by the reader: it is transcribed from llama.cpp for the + # declared `pre` type, and a C++ port that re-derived it would be free to get `\p{M}` wrong again. + "pre_pattern": QWEN35_PATTERN, + "pre_pattern_source": ".ref/llama.cpp src/llama-vocab.cpp L396 (LLAMA_VOCAB_PRE_TYPE_QWEN35)", + } + (out / "vocab.json").write_text(json.dumps(tk.ids, ensure_ascii=False), encoding="utf-8") + (out / "merges.txt").write_text("\n".join("%s %s" % k for k, _ in + sorted(tk.ranks.items(), key=lambda kv: kv[1])), encoding="utf-8") + (out / "token_type.json").write_text(json.dumps(tk.token_types), encoding="utf-8") + (out / "tokenizer.json").write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8") + return cfg + + +def main() -> int: + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("--gguf", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--check", action="store_true", help="round-trip a corpus and report") + args = ap.parse_args() + cfg = extract(args.gguf, args.out) + print("tokenizer/: vocab %d, merges %d, pre %s, specials %s" + % (cfg["vocab_size"], cfg["n_merges"], cfg["pre"], cfg["special_ids"])) + if args.check: + tk = Tokenizer.from_gguf(args.gguf) + corpus = ["", "Hello, world!", " leading and trailing ", "a\n\n\nb", + "def f(x):\n\treturn x # comment\n", "\u4f60\u597d\uff0c\u4e16\u754c", "\u0645\u0631\u062d\u0628\u0627", + "\U0001f600\U0001f680\U0001f1fa\U0001f1f8", "e\u0301\u0301 combining", "\x00\x01\x7f control", + "\u00a0non-breaking\u00a0space", "1234567890", "MixedCASE_and-dashes", "\r\n\r\n", "\u2028\u2029", + "x" * 5000] + bad = 0 + for s in corpus: + ids = tk.encode(s) + back = tk.decode(ids) + if back != s: + print(" *** ROUND TRIP FAILED *** %r -> %d ids -> %r" % (s[:40], len(ids), back[:40])) + bad += 1 + print("round trip: %d strings, %d failed" % (len(corpus), bad)) + return 0 if bad == 0 else 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vision/CMakeLists.txt b/tools/vision/CMakeLists.txt new file mode 100644 index 00000000..83682037 --- /dev/null +++ b/tools/vision/CMakeLists.txt @@ -0,0 +1,44 @@ +# tools/vision - strata-vision, the image encoder for Strata's multimodal path (optional; setup.py builds it when +# the vision encoder is chosen). Uses llama.cpp's mtmd library at the same pinned commit as the engine's ggml. +# +# cmake -S tools/vision -B build-vision -G Ninja -DCMAKE_BUILD_TYPE=Release -DLLAMA_DIR=third_party/llama.cpp +# [-DSTRATA_VISION_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=native] +# cmake --build build-vision --target strata-vision +cmake_minimum_required(VERSION 3.21) +project(strata_vision C CXX) + +set(LLAMA_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/llama.cpp" CACHE PATH "llama.cpp checkout") +option(STRATA_VISION_CUDA "run the vision encoder on the GPU (builds ggml-cuda)" OFF) +option(STRATA_PORTABLE "build for other PCs: AVX2 baseline, static MSVC runtime" OFF) +if(STRATA_PORTABLE AND MSVC) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded") +endif() + +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_COMMON OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_SERVER OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_APP OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_MTMD ON CACHE BOOL "" FORCE) +set(LLAMA_OPENSSL OFF CACHE BOOL "" FORCE) +set(LLAMA_CURL OFF CACHE BOOL "" FORCE) +if(STRATA_PORTABLE) + set(GGML_NATIVE OFF CACHE BOOL "" FORCE) + set(GGML_OPENMP OFF CACHE BOOL "" FORCE) # no vcomp140.dll / libgomp needed on the user's PC + foreach(_f GGML_AVX GGML_AVX2 GGML_FMA GGML_F16C GGML_BMI2) + set(${_f} ON CACHE BOOL "" FORCE) + endforeach() +else() + set(GGML_NATIVE ON CACHE BOOL "" FORCE) +endif() +set(GGML_BACKEND_DL OFF CACHE BOOL "" FORCE) +set(GGML_CUDA ${STRATA_VISION_CUDA} CACHE BOOL "" FORCE) + +add_subdirectory(${LLAMA_DIR} llama EXCLUDE_FROM_ALL) + +add_executable(strata-vision strata_vision.cpp) +target_link_libraries(strata-vision PRIVATE mtmd llama) +target_compile_features(strata-vision PRIVATE cxx_std_17) +set_target_properties(strata-vision PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) diff --git a/tools/vision/strata_vision.cpp b/tools/vision/strata_vision.cpp new file mode 100644 index 00000000..0c53c835 --- /dev/null +++ b/tools/vision/strata_vision.cpp @@ -0,0 +1,158 @@ +// tools/vision/strata_vision.cpp - the image half of Strata's multimodal path. +// +// Turns an image into the embeddings the text model reads in place of its <|image_pad|> tokens, with llama.cpp's +// mtmd library and the model's mmproj file (vision encoder + projector). The engine does the rest: it places the +// rows at the image's pad tokens and gives them their 2-D M-RoPE positions (see --serve GENI in generate.cpp). +// +// strata-vision --mmproj --model [--gpu] [--threads N] +// [--max-tokens N] +// +// Resident: prints "READY ", then per stdin line +// ENC -> "OK " or "ERR " +// QUIT +// The output file is int32 {0x31455653 'SVE1', n_tokens, nx, ny, n_embd} then float32 [n_tokens][n_embd], +// row i at grid position (x = i % nx, y = i / nx). The text model is opened vocab-only (no weights). +#include "gguf.h" +#include "llama.h" +#include "mtmd.h" +#include "mtmd-helper.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void quiet_log(ggml_log_level level, const char* text, void*) { + if (level >= GGML_LOG_LEVEL_WARN) std::fputs(text, stderr); +} + +// "ENC ": paths may contain spaces, so the image path ends at the last " " before the output path; +// the server always writes paths without spaces, but a user calling the tool by hand might not +bool parse_enc(const std::string& line, std::string& img, std::string& out) { + if (line.rfind("ENC ", 0) != 0) return false; + const std::string rest = line.substr(4); + const size_t sp = rest.rfind(' '); + if (sp == std::string::npos || sp == 0) return false; + img = rest.substr(0, sp); + out = rest.substr(sp + 1); + return !img.empty() && !out.empty(); +} + +} // namespace + +int main(int argc, char** argv) { + std::string mmproj, model; + bool gpu = false; + int threads = 0, max_tokens = 0; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto next = [&]() -> std::string { + if (i + 1 >= argc) { std::fprintf(stderr, "%s needs a value\n", a.c_str()); std::exit(2); } + return argv[++i]; + }; + if (a == "--mmproj") mmproj = next(); + else if (a == "--model") model = next(); + else if (a == "--gpu") gpu = true; + else if (a == "--threads") threads = std::atoi(next().c_str()); + else if (a == "--max-tokens") max_tokens = std::atoi(next().c_str()); + else { std::fprintf(stderr, "unknown argument %s\n", a.c_str()); return 2; } + } + if (mmproj.empty() || model.empty()) { + std::fprintf(stderr, "usage: strata-vision --mmproj --model [--gpu] [--threads N] " + "[--max-tokens N]\n"); + return 2; + } + llama_log_set(quiet_log, nullptr); + mtmd_helper_log_set(quiet_log, nullptr); + llama_backend_init(); + + llama_model_params mp = llama_model_default_params(); + mp.vocab_only = true; + llama_model* text = llama_model_load_from_file(model.c_str(), mp); + if (!text) { std::printf("ERR cannot open the text model %s\n", model.c_str()); std::fflush(stdout); return 1; } + + mtmd_context_params cp = mtmd_context_params_default(); + cp.use_gpu = gpu; + cp.print_timings = false; + cp.warmup = false; + if (threads > 0) cp.n_threads = threads; + if (max_tokens > 0) cp.image_max_tokens = max_tokens; + mtmd_context* ctx = mtmd_init_from_file(mmproj.c_str(), text, cp); + if (!ctx || !mtmd_support_vision(ctx)) { + std::printf("ERR cannot load the vision encoder %s\n", mmproj.c_str()); + std::fflush(stdout); + return 1; + } + // a vocab-only model reports no hparams, so the width comes from the projector: its output is the model's input + int n_embd = 0; + { + gguf_init_params gp{true, nullptr}; + gguf_context* gg = gguf_init_from_file(mmproj.c_str(), gp); + const int64_t k = gg ? gguf_find_key(gg, "clip.vision.projection_dim") : -1; + if (k >= 0) n_embd = (int) gguf_get_val_u32(gg, k); + if (gg) gguf_free(gg); + } + if (n_embd <= 0) { std::printf("ERR the vision encoder has no projection_dim\n"); std::fflush(stdout); return 1; } + std::printf("READY %d\n", n_embd); + std::fflush(stdout); + + std::string line; + while (std::getline(std::cin, line)) { + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + if (line == "QUIT") break; + std::string img, out; + if (!parse_enc(line, img, out)) { std::printf("ERR expected: ENC \n"); std::fflush(stdout); continue; } + const auto t0 = std::chrono::steady_clock::now(); + mtmd_helper_bitmap_wrapper bw = mtmd_helper_bitmap_init_from_file(ctx, img.c_str(), false, + mtmd_helper_init_opt_default()); + if (!bw.bitmap) { std::printf("ERR cannot read the image %s\n", img.c_str()); std::fflush(stdout); continue; } + mtmd_input_chunks* chunks = mtmd_input_chunks_init(); + const std::string prompt = mtmd_default_marker(); + mtmd_input_text txt{prompt.c_str(), prompt.size(), false, true}; + const mtmd_bitmap* bm[1] = {bw.bitmap}; + std::string err; + const mtmd_input_chunk* ichunk = nullptr; + if (mtmd_tokenize(ctx, chunks, &txt, bm, 1) != 0) err = "the image could not be preprocessed"; + for (size_t c = 0; err.empty() && c < mtmd_input_chunks_size(chunks); ++c) { + const mtmd_input_chunk* ch = mtmd_input_chunks_get(chunks, c); + if (mtmd_input_chunk_get_type(ch) == MTMD_INPUT_CHUNK_TYPE_IMAGE) ichunk = ch; + } + if (err.empty() && !ichunk) err = "no image chunk"; + if (err.empty() && mtmd_encode_chunk(ctx, ichunk) != 0) err = "the vision encoder failed"; + if (err.empty()) { + const mtmd_image_tokens* it = mtmd_input_chunk_get_tokens_image(ichunk); + const int n = (int) mtmd_input_chunk_get_n_tokens(ichunk); + // the grid from the decoder positions (nx/ny getters are deprecated): x and y of the last token + const mtmd_decoder_pos last = mtmd_image_tokens_get_decoder_pos(it, 0, (size_t) n - 1); + const int nx = (int) last.x + 1, ny = (int) last.y + 1; + if (nx * ny != n) err = "the image grid is not rectangular (" + std::to_string(n) + " tokens)"; + const float* embd = mtmd_get_output_embd(ctx); + FILE* f = err.empty() ? std::fopen(out.c_str(), "wb") : nullptr; + if (err.empty() && !f) err = "cannot write " + out; + if (f) { + const int32_t hdr[5] = {0x31455653, n, nx, ny, n_embd}; + const bool ok = std::fwrite(hdr, sizeof hdr, 1, f) == 1 && + std::fwrite(embd, sizeof(float) * (size_t) n_embd, (size_t) n, f) == (size_t) n; + std::fclose(f); + if (!ok) err = "short write to " + out; + const double ms = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + if (ok) std::printf("OK %d %d %d %.0f\n", n, nx, ny, ms); + } + } + if (!err.empty()) std::printf("ERR %s\n", err.c_str()); + std::fflush(stdout); + mtmd_input_chunks_free(chunks); + mtmd_bitmap_free(bw.bitmap); + if (bw.video_ctx) mtmd_helper_video_free(bw.video_ctx); + } + mtmd_free(ctx); + llama_model_free(text); + llama_backend_free(); + return 0; +}