mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
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.
This commit is contained in:
commit
01529aaa82
219 files changed
+50165
No files matched your search
@@ -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
|
||||
+48
@@ -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
|
||||
+536
@@ -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
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:MSVC>>:/W4>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:MSVC>>:/utf-8>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-Wall>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-Wextra>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CUDA>,$<CXX_COMPILER_ID:MSVC>>:-Xcompiler=/W4>)
|
||||
if(STRATA_WERROR)
|
||||
target_compile_options(strata_warnings INTERFACE
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:MSVC>>:/WX>
|
||||
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-Werror>)
|
||||
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()
|
||||
@@ -0,0 +1,313 @@
|
||||
<h1 align="center">Qwen3.8-Flash-Next (125B MoE) on a 12-24 GB NVIDIA GPU + 64 GB RAM: one-click install for Windows / Linux</h1>
|
||||
|
||||
<p align="center"><b>Strata</b>: an inference engine built for exactly one model and exactly this kind of PC</p>
|
||||
|
||||
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-<model>.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-<model>.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-<model>.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 <path to a picture>`, 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-<model>.log` in this folder. |
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
<p align="center"><img src="docs/paper/tiers.svg" width="760" alt="memory tiers"></p>
|
||||
|
||||
- **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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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 | - |
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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 | - |
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A tiny terminal chat for a running Strata server (start it with run-<model>.bat / run-<model>.sh first).
|
||||
|
||||
python chat.py [--port 8080] [--think none|low|medium|high]
|
||||
|
||||
Type a message and press Enter. /image <path> attaches a picture to your next message (when the server was set up
|
||||
with images), /think <none|low|medium|high> 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 <path> = 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 <path to a picture>)")
|
||||
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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
<svg viewBox="0 0 700 250" width="100%" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="700" height="72" rx="6" fill="#eaf1f9" stroke="#2f6fb3"/>
|
||||
<text x="10" y="18" font-size="12" font-weight="700" fill="#2f6fb3">GPU VRAM 12 GB · 672 GB/s</text>
|
||||
<rect x="10" y="26" width="150" height="38" rx="3" fill="#2f6fb3"/><text x="85" y="42" font-size="10" fill="#fff" text-anchor="middle">dense weights ~3.3 GB</text><text x="85" y="56" font-size="9" fill="#fff" text-anchor="middle">mixers · GR · routers · head</text>
|
||||
<rect x="166" y="26" width="330" height="38" rx="3" fill="#6d9fd4"/><text x="331" y="42" font-size="10" fill="#fff" text-anchor="middle">expert cache: everything left (~6 GB)</text><text x="331" y="56" font-size="9" fill="#fff" text-anchor="middle">~4,500 of 24,576 experts, adaptive</text>
|
||||
<rect x="502" y="26" width="90" height="38" rx="3" fill="#8fb6e0"/><text x="547" y="42" font-size="10" fill="#fff" text-anchor="middle">MTP layer</text><text x="547" y="56" font-size="9" fill="#fff" text-anchor="middle">0.8 GB</text>
|
||||
<rect x="598" y="26" width="94" height="38" rx="3" fill="#b3cde9"/><text x="645" y="42" font-size="10" fill="#1b1d21" text-anchor="middle">KV + state</text><text x="645" y="56" font-size="9" fill="#1b1d21" text-anchor="middle">0.3-3 GB</text>
|
||||
<text x="350" y="88" font-size="10" fill="#5a606b" text-anchor="middle">PCIe 4.0 x16 · ~26 GB/s · experts by DMA (prompt, 20% of misses, cache swaps)</text>
|
||||
<rect x="0" y="96" width="700" height="72" rx="6" fill="#fbf0e6" stroke="#c7702a"/>
|
||||
<text x="10" y="114" font-size="12" font-weight="700" fill="#c7702a">System RAM 64 GB DDR5 · ~41-52 GB/s · 6 CPU cores (AVX-512)</text>
|
||||
<rect x="10" y="122" width="440" height="38" rx="3" fill="#c7702a"/><text x="230" y="138" font-size="10" fill="#fff" text-anchor="middle">pinned expert arena: all 24,576 experts</text><text x="230" y="152" font-size="9" fill="#fff" text-anchor="middle">34 GB (Q2_0) · 35.5 GB (IQ2_XS) · 43 GB (IQ3_XXS); the CPU computes misses in place</text>
|
||||
<rect x="456" y="122" width="236" height="38" rx="3" fill="#e0a877"/><text x="574" y="138" font-size="10" fill="#fff" text-anchor="middle">OS page cache: hot n-gram rows</text><text x="574" y="152" font-size="9" fill="#fff" text-anchor="middle">+ OS, embedding table, buffers</text>
|
||||
<text x="350" y="184" font-size="10" fill="#5a606b" text-anchor="middle">NVMe · 16 row reads per token, issued a step ahead</text>
|
||||
<rect x="0" y="192" width="700" height="54" rx="6" fill="#f1f3e8" stroke="#6b7b3a"/>
|
||||
<text x="10" y="210" font-size="12" font-weight="700" fill="#6b7b3a">SSD</text>
|
||||
<rect x="60" y="200" width="300" height="38" rx="3" fill="#6b7b3a"/><text x="210" y="223" font-size="10" fill="#fff" text-anchor="middle">GGUF shard 1 (37.6-47 GB): read once at start</text>
|
||||
<rect x="366" y="200" width="326" height="38" rx="3" fill="#95a15f"/><text x="529" y="223" font-size="10" fill="#fff" text-anchor="middle">GGUF shard 2: n-gram table 28.8 GB, memory-mapped</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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 <file.gguf>` 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 <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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
|
||||
@@ -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 <file.gguf> [--check]
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <stdexcept>
|
||||
#include <algorithm>
|
||||
|
||||
#ifdef _WIN32
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#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<uint64_t> 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 <class T> 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<uint64_t>();
|
||||
need((size_t)n);
|
||||
std::string s(reinterpret_cast<const char*>(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<MetaValue> 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<uint8_t>();
|
||||
break;
|
||||
case MetaType::I8:
|
||||
v.u = (uint64_t)(int64_t)c.read<int8_t>();
|
||||
break;
|
||||
case MetaType::U16:
|
||||
v.u = c.read<uint16_t>();
|
||||
break;
|
||||
case MetaType::I16:
|
||||
v.u = (uint64_t)(int64_t)c.read<int16_t>();
|
||||
break;
|
||||
case MetaType::U32:
|
||||
v.u = c.read<uint32_t>();
|
||||
break;
|
||||
case MetaType::I32:
|
||||
v.u = (uint64_t)(int64_t)c.read<int32_t>();
|
||||
break;
|
||||
case MetaType::F32: {
|
||||
float x = c.read<float>();
|
||||
v.f = x;
|
||||
v.u = 0;
|
||||
break;
|
||||
}
|
||||
case MetaType::BOOL:
|
||||
v.u = c.read<uint8_t>() ? 1 : 0;
|
||||
break;
|
||||
case MetaType::STRING:
|
||||
v.s = c.str();
|
||||
break;
|
||||
case MetaType::U64:
|
||||
v.u = c.read<uint64_t>();
|
||||
break;
|
||||
case MetaType::I64:
|
||||
v.u = (uint64_t)c.read<int64_t>();
|
||||
break;
|
||||
case MetaType::F64:
|
||||
v.f = c.read<double>();
|
||||
break;
|
||||
case MetaType::ARRAY: {
|
||||
v.elem = (MetaType)c.read<uint32_t>();
|
||||
v.count = c.read<uint64_t>();
|
||||
if (v.count > (1u << 24)) throw std::runtime_error("GGUF: implausible array length");
|
||||
v.items.reserve((size_t)std::min<uint64_t>(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<TensorInfo>& tensors() const { return tensors_; }
|
||||
const std::map<std::string, MetaValue>& 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<uint32_t>();
|
||||
if (magic != 0x46554747u) throw std::runtime_error("not a GGUF file (bad magic)");
|
||||
version_ = c.read<uint32_t>();
|
||||
if (version_ != 3)
|
||||
throw std::runtime_error("GGUF v" + std::to_string(version_) + ", this reader handles v3");
|
||||
const uint64_t n_tensors = c.read<uint64_t>();
|
||||
const uint64_t n_kv = c.read<uint64_t>();
|
||||
|
||||
for (uint64_t i = 0; i < n_kv; ++i) {
|
||||
std::string key = c.str();
|
||||
MetaType t = (MetaType)c.read<uint32_t>();
|
||||
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<uint32_t>();
|
||||
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<uint64_t>();
|
||||
t.type = c.read<uint32_t>();
|
||||
t.offset = c.read<uint64_t>();
|
||||
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<TensorInfo> tensors_;
|
||||
std::map<std::string, MetaValue> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::pair<int32_t, int32_t>>& 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<int64_t>& 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<int32_t> 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<int32_t> layer_next_; ///< [n_layers] -> that layer's next free slot
|
||||
std::vector<uint64_t> off_; ///< plan v0.3 P6: slot offsets (slots + 1 entries) when sized
|
||||
int64_t admitted_ = 0;
|
||||
};
|
||||
|
||||
} // namespace strata::core
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<int32_t> 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<uint8_t> 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<strata::kernels::cpu::ExpertJob> 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<strata::kernels::cpu::ActQ> act_multi;
|
||||
/// Plan v0.3 P6: a native pack's per-token activations (MAXT x kNativeActBytes).
|
||||
std::vector<uint8_t> nact_multi;
|
||||
std::vector<strata::kernels::cpu::ExpertJobMulti> jobs_multi;
|
||||
std::vector<int16_t> 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<float> 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 `<pack_dir>/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 `<pack_dir>/experts.bin`. Prints nothing; the caller reports `note()` and the load
|
||||
/// rate, because those are the two numbers that say whether the arena is the one that was asked for.
|
||||
bool open(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, int threads, std::string& err);
|
||||
/// 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<const uint8_t*> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <cuda_runtime.h>
|
||||
|
||||
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<void()>& 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<int, int>;
|
||||
void* stream_ = nullptr;
|
||||
std::map<Key, CapturedGraph> graphs_;
|
||||
size_t captures_ = 0;
|
||||
};
|
||||
|
||||
} // namespace strata::core
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
|
||||
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-<l>` from its `ffn_out-<l>`
|
||||
/// 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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
|
||||
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.<layer>.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
|
||||
@@ -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 <cuda_runtime.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<Tensor> 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
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string>& 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<std::string>& shards, bool include_ple_key,
|
||||
std::set<std::string>& out, std::string& err);
|
||||
uint64_t weight_bytes() const { return bytes_; }
|
||||
size_t tensor_count() const { return weights_.size(); }
|
||||
|
||||
private:
|
||||
std::vector<void*> weights_;
|
||||
void* scratch_ = nullptr;
|
||||
uint64_t bytes_ = 0;
|
||||
};
|
||||
} // namespace strata::core
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<uint64_t>& bounds);
|
||||
std::vector<uint64_t> 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<uint64_t> layer_checksums; // one FNV-1a per layer
|
||||
double gib_per_second() const { return seconds > 0 ? (double) bytes / (1024.0 * 1024 * 1024) / seconds : 0.0; }
|
||||
};
|
||||
|
||||
// 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<uint64_t>& layer_off,
|
||||
const std::vector<uint64_t>& 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
|
||||
@@ -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 <cuda_runtime.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<double>& stage_ms,
|
||||
std::vector<double>& 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<double>& 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-<l>`. 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
|
||||
@@ -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 <cuda_runtime.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
// (`<pack>/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 <cstdint>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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
|
||||
/// `<file>.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<std::string>* skip = nullptr);
|
||||
|
||||
/// Load every tensor in `<pack_dir>/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<std::string>* skip = nullptr);
|
||||
|
||||
const WeightRef* find(const std::string& name) const;
|
||||
const std::map<std::string, WeightRef>& all() const { return table_; }
|
||||
const LoadReport& report() const { return report_; }
|
||||
|
||||
private:
|
||||
friend class NativeDense;
|
||||
std::map<std::string, WeightRef> table_;
|
||||
LoadReport report_;
|
||||
};
|
||||
|
||||
} // namespace strata::core
|
||||
@@ -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 <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <cstddef>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace strata::kernels::cpu {
|
||||
|
||||
struct ExpertLayout {
|
||||
bool native = false;
|
||||
int64_t n_layers = 0, n_expert = NE;
|
||||
std::vector<NativeFmt> fmt; ///< per layer (native packs)
|
||||
std::vector<uint64_t> 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<uint64_t> 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 `<pack_dir>/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
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -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 <atomic>
|
||||
#include <cstdint>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
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<int> 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<uint32_t> head_{0};
|
||||
alignas(64) std::atomic<uint32_t> done_{0};
|
||||
alignas(64) std::atomic<uint32_t> parked_{0};
|
||||
alignas(64) std::atomic<uint32_t> epoch_{0};
|
||||
alignas(64) std::atomic<bool> stop_{false};
|
||||
std::vector<std::thread> threads_;
|
||||
std::vector<ExpertScratch> 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<SplitBuf> 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<SplitBufMulti> split_multi_;
|
||||
};
|
||||
|
||||
} // namespace strata::kernels::cpu
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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<d_conv} inp[i][c] * kW[c*d_conv + i], inp = [conv_state | x]
|
||||
///
|
||||
/// `kernel[0]` reads the OLDEST state row and the new input lands in the LAST state row.
|
||||
void gdn_conv_step(float* conv_state, const float* x, const float* kW, float* out, int64_t channels,
|
||||
int64_t d_conv, void* stream);
|
||||
|
||||
/// `build_gdn_l2_norm`: `x / sqrt(sum(x^2) + eps)`, over the LAST axis.
|
||||
///
|
||||
/// The `+ eps` is an absolute floor on the SQUARED NORM, not on the mean - so the sum is formed first and
|
||||
/// there is NO division by the width. The plausible wrong reading (`x / sqrt(mean(x^2) + eps)`) differs by
|
||||
/// sqrt(S) = 11.3x here, which is why the test asserts the two are distinguishable.
|
||||
void gdn_l2_norm(float* x, int64_t rows, int64_t cols, float eps, void* stream);
|
||||
|
||||
/// `beta = sigmoid(beta)`, in place over `h_v` values.
|
||||
///
|
||||
/// **`gdn_step` DOES NOT DO THIS AND THE LAYER MUST.** `build_layer_attn_linear` L889 is
|
||||
/// `beta = ggml_sigmoid(beta)` and the step kernel's contract is `d = (v - sk) * beta`, so what it receives has
|
||||
/// to be a fraction. Passing the raw `ssm_beta @ cur` leaves the delta-net write strength unbounded and signed.
|
||||
/// `ref/gdn.py` and `ref/model.py` both sigmoid it; this call is what makes the C++ agree with them.
|
||||
void gdn_beta_gate(float* beta, int64_t h_v, void* stream);
|
||||
|
||||
/// The layer's closing norm: `y = rms_norm(o, eps) * ssm_norm * sigmoid(z)`.
|
||||
///
|
||||
/// Note SIGMOID, not SiLU - qwen3.5 used SiLU and this artifact does not, and `ref/gdn.py` says so outright.
|
||||
/// The norm is over the LAST axis: one RMS per head, so it is per-ROW here and not over the whole (h_v, S).
|
||||
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);
|
||||
|
||||
} // namespace strata::kernels
|
||||
@@ -0,0 +1,122 @@
|
||||
// include/strata/kernels/gr.hpp - the gated residual (hyper-connection), P2.S2.
|
||||
//
|
||||
// `ref/gr.py::gr_read` / `gr_write`, which are transcribed from
|
||||
// `llama_model_qwen4exp::graph::build_hc_mix` / `build_hc_combine` (qwen4exp.cpp L267-350 at 3cf03257).
|
||||
//
|
||||
// The residual is a STACK of `hc` streams, each `n_embd` wide - not one vector with a gate. `gr_read`
|
||||
// collapses the stack to the one vector the mixer consumes and produces a per-stream injection; `gr_write`
|
||||
// puts the mixer's output back into every stream, weighted per stream.
|
||||
//
|
||||
// WEIGHTS ARE BF16, stored as the high 16 bits of an f32 in a `uint16_t`. That is not a shortcut: every one
|
||||
// of these tensors has source type BF16 in the pack (288 of the shard's 483 BF16 tensors are `hc_*`), so the
|
||||
// pack's 32-bit promotion is lossless padding and re-rounding to 16 bits costs nothing. It is also worth
|
||||
// 1.374 GiB of the 5.305 GiB dense file, which is the difference between the dense weights fitting in VRAM
|
||||
// beside the expert cache and not fitting at all.
|
||||
//
|
||||
// The default activation contract is the historical BF16-rounded reference. The opt-in FP32 contract
|
||||
// matches the pinned llama.cpp CUDA single-token BF16 MMVF input precision: BF16 weights multiply FP32
|
||||
// activations. The CPU oracle and some batched CUDA paths instead round activations to BF16. Therefore
|
||||
// activation precision must be pinned with the oracle backend and batch geometry, not inferred from weights.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
|
||||
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
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,112 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
|
||||
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 `<windows.h>` 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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
#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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
#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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<float> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
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<bool(const float* R_rows, int64_t T, int64_t pos0, std::string& err)> 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> impl_;
|
||||
PrefillStats stats_;
|
||||
};
|
||||
|
||||
} // namespace strata::prefill
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
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
|
||||
@@ -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 <array>
|
||||
#include <cstdint>
|
||||
|
||||
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<double, K_MAX + 1> 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<double, K_MAX + 1> 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<double, K_MAX> mtp_p_{};
|
||||
std::array<double, 4> lookup_q_{};
|
||||
};
|
||||
|
||||
} // namespace strata::spec
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
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<int32_t> hist_;
|
||||
std::vector<Slot> table_;
|
||||
size_t mask_ = 0;
|
||||
int last_match_ = 0;
|
||||
};
|
||||
|
||||
} // namespace strata::spec
|
||||
+212
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""serve - plan v0.3 P8: the API layer (text in, token ids to the engine, text deltas out)."""
|
||||
@@ -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<think>\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<think>\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<think>\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<think>\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<think>\n\n</think>\n\nHello! How can I help?<|im_end|>\n<|im_start|>user\nExplain BPE in one sentence.<|im_end|>\n<|im_start|>assistant\n<think>\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<think>\n\n</think>\n\nHello! How can I help?<|im_end|>\n<|im_start|>user\nExplain BPE in one sentence.<|im_end|>\n<|im_start|>assistant\n<think>\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<think>\n17*23 = 17*20 + 17*3 = 340+51 = 391\n</think>\n\nLet me compute.<|im_end|>\n<|im_start|>user\nThanks<|im_end|>\n<|im_start|>assistant\n<think>\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<think>\n17*23 = 17*20 + 17*3 = 340+51 = 391\n</think>\n\nLet me compute.<|im_end|>\n<|im_start|>user\nThanks<|im_end|>\n<|im_start|>assistant\n<think>\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<think>\n\n</think>\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<think>\n\n</think>\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<think>\n\n</think>\n\n",
|
||||
"error": null,
|
||||
"hf": "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\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<think>\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<think>\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<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get the weather\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"]}}}\n</tools>\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> 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</IMPORTANT><|im_end|>\n<|im_start|>user\nWeather in Oslo?<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n<tool_call>\n<function=get_weather>\n<parameter=city>\nOslo\n</parameter>\n</function>\n</tool_call><|im_end|>\n<|im_start|>user\n<tool_response>\n{\"temp\": 3}\n</tool_response><|im_end|>\n<|im_start|>user\nAnd in Bergen?<|im_end|>\n<|im_start|>assistant\n<think>\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<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get the weather\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"]}}}\n</tools>\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> 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</IMPORTANT><|im_end|>\n<|im_start|>user\nWeather in Oslo?<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n<tool_call>\n<function=get_weather>\n<parameter=city>\nOslo\n</parameter>\n</function>\n</tool_call><|im_end|>\n<|im_start|>user\n<tool_response>\n{\"temp\": 3}\n</tool_response><|im_end|>\n<|im_start|>user\nAnd in Bergen?<|im_end|>\n<|im_start|>assistant\n<think>\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<think>\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<think>\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<think>\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<think>\n"
|
||||
}
|
||||
]
|
||||
@@ -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<tools>" }}
|
||||
{%- for tool in tools %}
|
||||
{{- "\n" }}
|
||||
{{- tool | tojson }}
|
||||
{%- endfor %}
|
||||
{{- "\n</tools>" }}
|
||||
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> 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</IMPORTANT>' }}
|
||||
{%- 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('<tool_response>') and content.endswith('</tool_response>')) %}
|
||||
{%- 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<think>\n' + reasoning_content + '\n</think>\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<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
||||
{%- else %}
|
||||
{{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
||||
{%- endif %}
|
||||
{%- if tool_call.arguments is mapping %}
|
||||
{%- for args_name, args_value in tool_call.arguments|items %}
|
||||
{{- '<parameter=' + args_name + '>\n' }}
|
||||
{%- set args_value = args_value | string if args_value is string else args_value | tojson | safe %}
|
||||
{{- args_value }}
|
||||
{{- '\n</parameter>\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 %}
|
||||
{{- '</function>\n</tool_call>' }}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- elif message.role == "tool" %}
|
||||
{%- if loop.previtem and loop.previtem.role != "tool" %}
|
||||
{{- '<|im_start|>user' }}
|
||||
{%- endif %}
|
||||
{{- '\n<tool_response>\n' }}
|
||||
{{- content }}
|
||||
{{- '\n</tool_response>' }}
|
||||
{%- 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 %}
|
||||
{{- '<think>\n\n</think>\n\n' }}
|
||||
{%- else %}
|
||||
{{- '<think>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{#- Unsloth fixes - developer role, merged system messages, tool calling #}
|
||||
@@ -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 (`<think>...</think>`), content and tool calls in
|
||||
the template's XML form (`<tool_call><function=NAME><parameter=P>VALUE</parameter>...</function></tool_call>`),
|
||||
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 = "</think>"
|
||||
CALL_START = "<tool_call>"
|
||||
CALL_END = "</tool_call>"
|
||||
|
||||
|
||||
def parse_tool_call(body: str, schema: dict | None = None) -> ToolCall:
|
||||
"""`<function=NAME>\\n<parameter=P>\\nVALUE\\n</parameter>...</function>` -> ToolCall. Values are JSON-decoded
|
||||
when the tool's schema says the parameter is not a string (or, without a schema, when they parse as JSON
|
||||
objects/arrays/numbers/booleans)."""
|
||||
body = body.strip()
|
||||
if not body.startswith("<function=") or ">" not in body:
|
||||
raise ValueError("malformed tool call: " + body[:80])
|
||||
name = body[len("<function="):body.index(">")]
|
||||
rest = body[body.index(">") + 1:]
|
||||
props = ((schema or {}).get("parameters") or {}).get("properties") or {}
|
||||
args = {}
|
||||
while "<parameter=" in rest:
|
||||
rest = rest[rest.index("<parameter=") + len("<parameter="):]
|
||||
pname = rest[:rest.index(">")]
|
||||
rest = rest[rest.index(">") + 1:]
|
||||
end = rest.find("</parameter>")
|
||||
value = rest[:end] if end >= 0 else rest
|
||||
rest = rest[end + len("</parameter>"):] 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 `<tool_` or `</thi`."""
|
||||
|
||||
def __init__(self, thinking: bool = True, tools: list[dict] | None = None):
|
||||
self.state = "reasoning" if thinking else "content"
|
||||
self.buf = ""
|
||||
self.lead = False
|
||||
self.schemas = {t.get("name"): t for t in tools or []}
|
||||
|
||||
def _hold(self, text: str, tags: tuple[str, ...]) -> 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 </think> 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("<function="):].split(">", 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
|
||||
@@ -0,0 +1,152 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Strata</title>
|
||||
<style>
|
||||
:root { --bg:#f6f7f9; --card:#fff; --fg:#1b1f24; --mute:#667085; --line:#e3e6ea; --acc:#2f6fed; --me:#e9f0ff; --ok:#1a7f37; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg:#111418; --card:#1a1e24; --fg:#e7e9ec; --mute:#9aa3ae; --line:#2b313a; --acc:#6b9bff; --me:#1f2b44; --ok:#3fb950; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
main { max-width:860px; margin:0 auto; padding:16px; display:flex; flex-direction:column; gap:12px; min-height:100vh; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:14px 16px; }
|
||||
h1 { font-size:20px; margin:0; display:flex; align-items:center; gap:8px; }
|
||||
.dot { width:10px; height:10px; border-radius:50%; background:var(--mute); display:inline-block; }
|
||||
.dot.on { background:var(--ok); }
|
||||
.mute { color:var(--mute); font-size:13px; }
|
||||
code { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:1px 6px; font-size:13px; }
|
||||
.grid { display:grid; grid-template-columns: max-content 1fr; gap:4px 12px; margin-top:8px; font-size:14px; }
|
||||
#log { flex:1; display:flex; flex-direction:column; gap:10px; overflow-y:auto; min-height:240px; }
|
||||
.msg { padding:10px 12px; border-radius:10px; white-space:pre-wrap; word-wrap:break-word; }
|
||||
.msg.user { background:var(--me); align-self:flex-end; max-width:85%; }
|
||||
.msg.bot { background:var(--card); border:1px solid var(--line); }
|
||||
.msg img { max-width:220px; max-height:160px; display:block; border-radius:6px; margin-bottom:6px; }
|
||||
details { color:var(--mute); font-size:13px; margin-bottom:6px; }
|
||||
form { display:flex; gap:8px; align-items:flex-end; flex-wrap:wrap; }
|
||||
textarea { flex:1; min-width:200px; min-height:52px; max-height:200px; resize:vertical; padding:10px; border-radius:10px;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--fg); font:inherit; }
|
||||
button, label.btn { border:1px solid var(--line); background:var(--card); color:var(--fg); border-radius:10px; padding:10px 14px;
|
||||
font:inherit; cursor:pointer; }
|
||||
button.primary { background:var(--acc); border-color:var(--acc); color:#fff; }
|
||||
button:disabled { opacity:.5; cursor:default; }
|
||||
.row { display:flex; gap:14px; align-items:center; flex-wrap:wrap; font-size:13px; color:var(--mute); }
|
||||
#att { font-size:13px; color:var(--mute); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1><span id="dot" class="dot"></span> Strata <span id="state" class="mute">connecting ...</span></h1>
|
||||
<div class="grid">
|
||||
<span class="mute">Model</span><span id="model">-</span>
|
||||
<span class="mute">Context</span><span id="ctx">-</span>
|
||||
<span class="mute">Images</span><span id="img">-</span>
|
||||
<span class="mute">OpenAI API</span><span><code id="oai"></code> (any API key<span id="keynote"></span>)</span>
|
||||
<span class="mute">Anthropic API</span><span><code id="ant"></code></span>
|
||||
</div>
|
||||
<p class="mute" style="margin:10px 0 0">Point any app with an "OpenAI-compatible" or "Anthropic" provider at these
|
||||
addresses. Close the Strata window to stop the model.</p>
|
||||
</div>
|
||||
|
||||
<div id="log" class="card"></div>
|
||||
|
||||
<div class="card">
|
||||
<form id="f">
|
||||
<textarea id="q" placeholder="Ask something ... (Enter sends, Shift+Enter = new line)"></textarea>
|
||||
<label class="btn" id="attbtn" hidden>Picture<input id="file" type="file" accept="image/png,image/jpeg,image/gif,image/bmp" hidden></label>
|
||||
<button class="primary" id="send">Send</button>
|
||||
<button type="button" id="stop" disabled>Stop</button>
|
||||
</form>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<label>Thinking <select id="think" style="padding:4px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--fg)">
|
||||
<option value="none">off - answer at once</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium" selected>medium</option>
|
||||
<option value="high">high - best on hard questions, slowest</option>
|
||||
</select></label>
|
||||
<span id="att"></span>
|
||||
<button type="button" id="clear" style="padding:4px 10px">New chat</button>
|
||||
<input id="key" type="password" placeholder="API key" hidden style="padding:6px;border-radius:8px;border:1px solid var(--line)">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const base = location.origin;
|
||||
$("oai").textContent = base + "/v1";
|
||||
$("ant").textContent = base + "/v1/messages";
|
||||
let history = [], image = null, ctrl = null;
|
||||
|
||||
async function status() {
|
||||
try {
|
||||
const h = await (await fetch("/health")).json();
|
||||
$("dot").classList.add("on"); $("state").textContent = "running";
|
||||
$("model").textContent = h.model; $("ctx").textContent = h.max_context.toLocaleString() + " tokens";
|
||||
$("img").textContent = h.images ? "on" : "off (run setup again and answer yes to images)";
|
||||
$("attbtn").hidden = !h.images;
|
||||
if (h.api_key) { $("key").hidden = false; $("keynote").textContent = " - this server needs its key"; }
|
||||
} catch (e) { $("state").textContent = "not reachable - is the Strata window still open?"; }
|
||||
}
|
||||
status();
|
||||
|
||||
function bubble(cls, text) {
|
||||
const d = document.createElement("div"); d.className = "msg " + cls; d.textContent = text || "";
|
||||
$("log").appendChild(d); $("log").scrollTop = $("log").scrollHeight; return d;
|
||||
}
|
||||
|
||||
$("file").onchange = () => {
|
||||
const f = $("file").files[0]; if (!f) return;
|
||||
const r = new FileReader();
|
||||
r.onload = () => { image = r.result; $("att").textContent = "picture: " + f.name; };
|
||||
r.readAsDataURL(f);
|
||||
};
|
||||
$("clear").onclick = () => { history = []; $("log").innerHTML = ""; };
|
||||
$("stop").onclick = () => ctrl && ctrl.abort();
|
||||
$("q").addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("f").requestSubmit(); } });
|
||||
|
||||
$("f").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const text = $("q").value.trim(); if (!text) return;
|
||||
$("q").value = "";
|
||||
const content = image ? [{type: "image_url", image_url: {url: image}}, {type: "text", text}] : text;
|
||||
const u = bubble("user", "");
|
||||
if (image) { const im = document.createElement("img"); im.src = image; u.appendChild(im); }
|
||||
u.appendChild(document.createTextNode(text));
|
||||
history.push({role: "user", content});
|
||||
image = null; $("att").textContent = ""; $("file").value = "";
|
||||
const b = bubble("bot", ""); const det = document.createElement("details"); const sum = document.createElement("summary");
|
||||
sum.textContent = "thinking"; det.appendChild(sum); const th = document.createElement("div"); det.appendChild(th);
|
||||
const ans = document.createElement("div"); b.appendChild(ans);
|
||||
$("send").disabled = true; $("stop").disabled = false; ctrl = new AbortController();
|
||||
let answer = "";
|
||||
try {
|
||||
const headers = {"Content-Type": "application/json"};
|
||||
if ($("key").value) headers["Authorization"] = "Bearer " + $("key").value;
|
||||
const body = {model: "strata", messages: history, stream: true, max_tokens: 8192};
|
||||
body.reasoning_effort = $("think").value;
|
||||
const r = await fetch("/v1/chat/completions", {method: "POST", headers, body: JSON.stringify(body), signal: ctrl.signal});
|
||||
if (!r.ok) { ans.textContent = "error: " + ((await r.json()).error || {}).message; throw 0; }
|
||||
const rd = r.body.getReader(), dec = new TextDecoder(); let buf = "";
|
||||
for (;;) {
|
||||
const {value, done} = await rd.read(); if (done) break;
|
||||
buf += dec.decode(value, {stream: true});
|
||||
let i;
|
||||
while ((i = buf.indexOf("\n\n")) >= 0) {
|
||||
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 2);
|
||||
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
||||
const d = JSON.parse(line.slice(6)).choices[0].delta;
|
||||
if (d.reasoning_content) { if (!det.parentNode) b.insertBefore(det, ans); th.textContent += d.reasoning_content; }
|
||||
if (d.content) { answer += d.content; ans.textContent = answer; }
|
||||
$("log").scrollTop = $("log").scrollHeight;
|
||||
}
|
||||
}
|
||||
} catch (err) { if (err && err.name === "AbortError") ans.textContent = answer + " [stopped]"; }
|
||||
history.push({role: "assistant", content: answer});
|
||||
$("send").disabled = false; $("stop").disabled = true; ctrl = null;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+606
@@ -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-<model>.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 <max_new> <ids>` lines and streams `T <id>` 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 <image> <out>`
|
||||
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-<model>.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.</think>\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())
|
||||
@@ -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-<model>.bat / run-<model>.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: <PREBUILT_URL><asset>, 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/<you>/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: <path>.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 <name>.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 <pack>/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 <path>) 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)
|
||||
@@ -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 "$@"
|
||||
@@ -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 <file.gguf>\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
|
||||
@@ -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 <file.gguf> [--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<const strata::TensorInfo*> 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
|
||||
@@ -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 <cuda_runtime.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
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<uint64_t> 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<uint16_t> hx((size_t) n_in, 0x3C00);
|
||||
std::vector<float> 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<bool>& stop, std::atomic<uint64_t>& 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<bool> stop{false};
|
||||
std::atomic<uint64_t> bytes{0};
|
||||
std::vector<std::thread> 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<double>(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;
|
||||
}
|
||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user