diff --git a/.gitignore b/.gitignore
index cb6477db40..8bce5378ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,17 +37,22 @@ td.log
/strata-*.json
/strata-*.log
/build-strata.bat
+/build-strata-cuda12.bat
+/build-cuda12/
/.strata-mcp/
# image encoder (setup.py --vision): build output and the generated build script
build-vision*/
build-vision.bat
+build-vision-cuda12.bat
# setup.py outputs (engine, downloads) and release builds
/engine/
+/engine-cuda12/
/build-release*/
/dist/
*.done
/third_party/*.zip
+/.ccache/
/.rocm-win/
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ff8766b20d..0121bb0097 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -8,7 +8,18 @@
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
# and is wrong about its own performance, which is the worst of both outcomes.
cmake_minimum_required(VERSION 3.24)
-project(strata VERSION 0.1.38 LANGUAGES CXX)
+project(strata VERSION 0.1.39 LANGUAGES CXX)
+# EXPERIMENTAL: the Intel Arc engine, the SYCL port in sycl/ (PR #423; icpx + oneMKL, docs/INTEL_ARC.md). It is its
+# own project that takes the unported sources from src/; with this on, nothing below is configured.
+option(STRATA_ENABLE_SYCL "EXPERIMENTAL: build the Intel Arc (SYCL) engine from sycl/ - docs/INTEL_ARC.md" OFF)
+if(STRATA_ENABLE_SYCL)
+ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
+ message(FATAL_ERROR "STRATA_ENABLE_SYCL is a separate backend; leave STRATA_ENABLE_CUDA and STRATA_ENABLE_HIP off")
+ endif()
+ message(WARNING "STRATA_ENABLE_SYCL: the Intel Arc engine is experimental (community port, docs/INTEL_ARC.md)")
+ add_subdirectory(sycl)
+ return()
+endif()
add_compile_definitions(STRATA_VERSION="${PROJECT_VERSION}")
# issue #31: release builds keep a PDB beside the exe (not shipped in it), so a minidump from a stalled engine can
# be read back to the exact source line
@@ -21,6 +32,18 @@ endif()
# 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)
+# EXPERIMENTAL, opt-in: an engine for CPUs without AVX2 (#394, #595, #623), compiled on that PC. ggml-cpu, which then
+# computes every CPU expert row, is built for this floor instead of AVX2 or the build host: "avx" (Sandy/Ivy Bridge:
+# AVX, no FMA/F16C/AVX2) or "none" (SSE4.2 + POPCNT: Nehalem/Westmere). Strata's own AVX2/AVX-512 kernels stay in the
+# binary behind their CPU checks; the router lookahead gets an AVX1 kernel. Empty (the default) changes nothing.
+set(STRATA_ISA_FLOOR "" CACHE STRING "experimental older-CPU build: empty (default, AVX2 floor), avx, or none (SSE4.2)")
+set_property(CACHE STRATA_ISA_FLOOR PROPERTY STRINGS "" avx none)
+if(NOT STRATA_ISA_FLOOR STREQUAL "" AND NOT STRATA_ISA_FLOOR STREQUAL "avx" AND NOT STRATA_ISA_FLOOR STREQUAL "none")
+ message(FATAL_ERROR "STRATA_ISA_FLOOR must be empty, avx or none (got '${STRATA_ISA_FLOOR}')")
+endif()
+if(STRATA_ISA_FLOOR AND STRATA_PORTABLE)
+ message(FATAL_ERROR "STRATA_ISA_FLOOR (an older-CPU build) and STRATA_PORTABLE (the AVX2 release build) exclude each other")
+endif()
if(STRATA_PORTABLE AND MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")
endif()
@@ -33,7 +56,19 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
endif()
option(STRATA_ENABLE_CUDA "Build the CUDA targets (Phase 2+)" OFF)
-option(STRATA_ENABLE_HIP "Build the HIP targets for wave32 AMD GPUs (gfx1100, gfx1201; gfx1030 unvalidated)" OFF)
+# AMD gfx906 (MI50, wave64): the same .cu sources compiled as HIP through the gfx906 compat layer. The CUDA runtime and
+# cuBLAS names are mapped by the headers in include/strata/platform/hip_compat/, which go first on the include path,
+# and CUDA::cudart / CUDA::cublas become aliases of HIP's targets, so every CUDA target below stays as it is. This is
+# separate from STRATA_ENABLE_HIP (the wave32 RDNA backend): with it on, STRATA_ENABLE_CUDA is on and
+# STRATA_ENABLE_HIP stays off, so every "CUDA" branch of the tree is the one taken.
+option(STRATA_HIP_GFX906 "Build the CUDA targets for AMD gfx906 through HIP (wave64 compat layer)" OFF)
+if(STRATA_HIP_GFX906)
+ if(STRATA_ENABLE_HIP)
+ message(FATAL_ERROR "STRATA_HIP_GFX906 and STRATA_ENABLE_HIP are separate backends; enable only one")
+ endif()
+ set(STRATA_ENABLE_CUDA ON CACHE BOOL "" FORCE)
+endif()
+option(STRATA_ENABLE_HIP "Build the HIP targets for wave32 AMD GPUs (gfx1100, gfx1201; gfx1012/gfx1030 unvalidated)" OFF)
option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF)
# CUDA: MMQ prompt kernels for Q4_K / Q5_K / Q5_1 (Unsloth's UD-Q4_K_XL experts). Off by default: the engine loads
# every kernel when it starts (CUDA_MODULE_LOADING=EAGER), so these took ~5 MB of VRAM - 1-3 expert slots - from every
@@ -43,7 +78,7 @@ option(STRATA_ORCA_Q4KS_MMQ "Build the GGML Q5_0 MMQ instance for OrcaRouter Q4_
if(STRATA_PREFILL_MMQ AND NOT STRATA_ENABLE_HIP)
message(FATAL_ERROR "STRATA_PREFILL_MMQ requires STRATA_ENABLE_HIP=ON")
endif()
-if(STRATA_ENABLE_CUDA AND STRATA_ENABLE_HIP)
+if(STRATA_ENABLE_CUDA AND STRATA_ENABLE_HIP AND NOT STRATA_HIP_GFX906)
message(FATAL_ERROR "STRATA_ENABLE_CUDA and STRATA_ENABLE_HIP are separate backends; enable only one")
endif()
if(STRATA_ENABLE_HIP)
@@ -68,7 +103,34 @@ if(STRATA_BUILD_CONVERSATION_TESTS)
add_test(NAME conversation_memory_test COMMAND conversation_memory_test)
endif()
-if(STRATA_ENABLE_CUDA)
+if(STRATA_HIP_GFX906)
+ if(NOT DEFINED CMAKE_HIP_ARCHITECTURES)
+ set(CMAKE_HIP_ARCHITECTURES gfx906 CACHE STRING "HIP architectures" FORCE)
+ endif()
+ enable_language(HIP)
+ find_package(hip REQUIRED)
+ find_package(hipblas REQUIRED)
+ add_library(CUDA::cudart INTERFACE IMPORTED)
+ target_link_libraries(CUDA::cudart INTERFACE hip::host)
+ target_compile_definitions(CUDA::cudart INTERFACE STRATA_HIP_GFX906=1 __HIP_PLATFORM_AMD__=1)
+ target_include_directories(CUDA::cudart BEFORE INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/platform/hip_compat)
+ add_library(CUDA::cublas INTERFACE IMPORTED)
+ target_link_libraries(CUDA::cublas INTERFACE roc::hipblas CUDA::cudart)
+ set(CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/platform/hip_compat)
+ include_directories(BEFORE ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/platform/hip_compat)
+ add_compile_definitions(STRATA_HIP_GFX906=1)
+ message(STATUS "Strata: HIP (gfx906 compat) enabled, arch ${CMAKE_HIP_ARCHITECTURES}")
+ set(_strata_gpu_runtime_target CUDA::cudart)
+ set(_strata_gpu_blas_target CUDA::cublas)
+ set(_strata_gpu_include_directories ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
+elseif(STRATA_ENABLE_CUDA)
+ # #585: CMake links CUDA code with the STATIC runtime by default, and the targets below also link CUDA::cudart (the
+ # DLL's import library). MSVC with a CUDA 12.x toolkit - the experimental Pascal/Volta build - then fails with
+ # LNK1169 (every cudart symbol defined twice); GNU ld lets it pass. That build links the shared runtime alone. The
+ # ready-made engine (CUDA 13, no STRATA_EXPERIMENTAL_SM60) is unchanged; -DCMAKE_CUDA_RUNTIME_LIBRARY overrides.
+ if(MSVC AND STRATA_EXPERIMENTAL_SM60 AND NOT DEFINED CMAKE_CUDA_RUNTIME_LIBRARY)
+ set(CMAKE_CUDA_RUNTIME_LIBRARY Shared)
+ endif()
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.
@@ -107,13 +169,18 @@ if(STRATA_ENABLE_CUDA)
"CUDA 12.8 crashed on long prompts on these cards (issues #220, #224). Use CUDA 13.0 or newer.")
endif()
endforeach()
+ if(STRATA_EXPERIMENTAL_SM60)
+ # every target sees it: the Pascal / Volta code paths (the Volta prompt attention, PR #600) are compiled into this
+ # build only, so the ready-made engine (sm_75+) keeps exactly the code it had
+ add_compile_definitions(STRATA_EXPERIMENTAL_SM60=1)
+ endif()
message(STATUS "Strata: CUDA enabled, arch ${CMAKE_CUDA_ARCHITECTURES}")
set(_strata_gpu_runtime_target CUDA::cudart)
set(_strata_gpu_blas_target CUDA::cublas)
set(_strata_gpu_include_directories ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
elseif(STRATA_ENABLE_HIP)
set(_strata_gpu_runtime_target strata_hip_runtime)
- set(_strata_gpu_blas_target roc::hipblas)
+ set(_strata_gpu_blas_target ${STRATA_HIP_BLAS_TARGETS})
set(_strata_gpu_include_directories ${STRATA_HIP_COMPAT_INCLUDE_DIR})
endif()
@@ -309,7 +376,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
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")
+ PROPERTIES COMPILE_OPTIONS "$,,--use_fast_math>")
elseif(STRATA_ENABLE_HIP)
# Keep Q8_K's multiply and magic-bias rounding as separate FP32 operations on HIP; contraction to an
# FMA changes tie results. The embedding gather also relies on separately rounded operations.
@@ -348,6 +415,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
# 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/remote_experts.cpp
+ src/core/remote_expert_opt.cu
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp
src/core/conversation_snapshot.cpp src/core/conversation_state.cpp src/core/conversation_memory.cpp src/core/peer_experts.cpp)
target_include_directories(strata_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
@@ -384,6 +452,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(expert_cache_per_layer_test PRIVATE strata_engine)
add_test(NAME expert_cache_per_layer_test COMMAND expert_cache_per_layer_test)
set_tests_properties(expert_cache_per_layer_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60)
+ add_executable(expert_cache_segmented_test tests/core/expert_cache_segmented_test.cpp)
+ target_link_libraries(expert_cache_segmented_test PRIVATE strata_engine)
+ add_test(NAME expert_cache_segmented_test COMMAND expert_cache_segmented_test)
+ set_tests_properties(expert_cache_segmented_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60)
endif()
if(STRATA_BUILD_CONVERSATION_TESTS)
add_executable(conversation_snapshot_test src/core/conversation_snapshot_test.cpp)
@@ -427,6 +499,14 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
if(STRATA_ENABLE_HIP)
enable_testing()
set(_strata_hip_tests)
+ add_executable(hip_prefill_gemm tests/hip/prefill_gemm.cpp)
+ target_link_libraries(hip_prefill_gemm PRIVATE strata_prefill strata_hip_runtime)
+ add_test(NAME hip_prefill_gemm COMMAND hip_prefill_gemm)
+ list(APPEND _strata_hip_tests hip_prefill_gemm)
+ add_executable(hip_q2_zero tests/hip/q2_zero.cpp)
+ target_link_libraries(hip_q2_zero PRIVATE strata_kernels strata_hip_runtime)
+ add_test(NAME hip_q2_zero COMMAND hip_q2_zero)
+ list(APPEND _strata_hip_tests hip_q2_zero)
add_executable(hip_expert_cache_staging tests/hip/expert_cache_staging.cpp)
target_link_libraries(hip_expert_cache_staging PRIVATE strata_engine)
add_test(NAME hip_expert_cache_staging COMMAND hip_expert_cache_staging)
@@ -560,6 +640,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_include_directories(mmvq_multi_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(mmvq_multi_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME mmvq_multi_parity COMMAND mmvq_multi_parity)
+ if(STRATA_HIP_GFX906) # the CUDA layout, where the negative control has power (the wave64 one is exact at every T)
+ add_test(NAME mmvq_multi_parity_cuda_layout COMMAND mmvq_multi_parity)
+ set_tests_properties(mmvq_multi_parity_cuda_layout PROPERTIES ENVIRONMENT "STRATA_MMVQ_WAVE=0")
+ endif()
endif()
# the IQ kernels that decode a weight part once for every column/entry, bitwise vs the per-column ones
# (GPU, synthetic, no model; --bench adds old/new timings)
@@ -568,11 +652,16 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(iq_multi_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME iq_multi_parity COMMAND iq_multi_parity)
# the prompt path's DeltaNet recurrence: gdn_rec_kh_kernel against gdn_rec_cols_pipe_kernel, bitwise (GPU,
- # synthetic, no model; --bench adds timings)
- if(STRATA_ENABLE_CUDA)
+ # synthetic, no model; --bench adds timings). NVIDIA only: cp.async and the CUDA occupancy API (not on gfx906)
+ if(STRATA_ENABLE_CUDA AND NOT STRATA_HIP_GFX906)
add_executable(gdn_rec_parity src/prefill/gdn_rec_parity.cu)
target_link_libraries(gdn_rec_parity PRIVATE ${_strata_gpu_runtime_target})
add_test(NAME gdn_rec_parity COMMAND gdn_rec_parity)
+ # the prompt path's BF16 products against cuBLAS's own BF16 GEMM, within fp32 rounding (sm_7x runs them on the
+ # FP16 tensor cores; GPU, synthetic, no model; --bench adds timings)
+ add_executable(gemm_bf16_parity src/prefill/gemm_bf16_parity.cu)
+ target_link_libraries(gemm_bf16_parity PRIVATE strata_prefill )
+ add_test(NAME gemm_bf16_parity COMMAND gemm_bf16_parity)
endif()
# native_expert_grouped's group stride and fused SwiGLU + q8_1, bitwise vs the launches before them (GPU, synthetic,
# no model; --bench adds v1/new timings of a window's 48 calls in a graph)
@@ -585,6 +674,9 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(shared_expert_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME shared_expert_parity COMMAND shared_expert_parity --selftest)
+ add_executable(fused_gr_bench src/kernels/fused_gr_bench.cpp)
+ target_include_directories(fused_gr_bench PRIVATE ${_strata_gpu_include_directories})
+ target_link_libraries(fused_gr_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(gr_parity src/kernels/gr_parity.cpp)
target_include_directories(gr_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(gr_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
@@ -629,10 +721,22 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(quantize_act_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME quantize_act_parity COMMAND quantize_act_parity --selftest)
+ # the verify window's routing in one multi-column BF16 projection + one top-k, bitwise the per-token calls
+ add_executable(route_window_parity src/kernels/route_window_parity.cpp)
+ target_include_directories(route_window_parity PRIVATE ${_strata_gpu_include_directories})
+ target_link_libraries(route_window_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
+ add_test(NAME route_window_parity COMMAND route_window_parity)
+
add_executable(router_top10_parity src/kernels/router_top10_parity.cpp)
target_include_directories(router_top10_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(router_top10_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME router_top10_parity COMMAND router_top10_parity --selftest)
+ if(STRATA_HIP_GFX906)
+ # the S6 router (STRATA_HIP_ROUTER_FAST=1 on gfx906) against the portable kernel, bitwise, on wave64
+ add_executable(hip_router_fast tests/hip/router_fast.cpp)
+ target_include_directories(hip_router_fast PRIVATE ${_strata_gpu_include_directories})
+ target_link_libraries(hip_router_fast PRIVATE strata_kernels ${_strata_gpu_runtime_target})
+ endif()
add_executable(s_gemv_parity src/kernels/s_gemv_parity.cpp)
target_include_directories(s_gemv_parity PRIVATE ${_strata_gpu_include_directories})
@@ -659,6 +763,13 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(qsa_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME qsa_parity COMMAND qsa_parity --selftest)
+ # the QSA top-k the engine dispatches against the original kernel: identical ids on both sides of the register
+ # kernel's reach, with tied and with equal scores (synthetic, no model)
+ add_executable(qsa_topk_parity src/kernels/qsa_topk_parity.cpp)
+ target_include_directories(qsa_topk_parity PRIVATE ${_strata_gpu_include_directories})
+ target_link_libraries(qsa_topk_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
+ add_test(NAME qsa_topk_parity COMMAND qsa_topk_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.
@@ -857,6 +968,22 @@ if(STRATA_NATIVE_EXPERTS)
foreach(_f GGML_AVX GGML_AVX2 GGML_FMA GGML_F16C GGML_BMI2)
set(${_f} ON CACHE BOOL "" FORCE)
endforeach()
+ elseif(STRATA_ISA_FLOOR)
+ # the experimental older-CPU build (STRATA_ISA_FLOOR above): one ggml-cpu for the floor, ggml's "sandybridge"
+ # feature set (SSE4.2 + AVX) or SSE4.2 alone - not the build host's, which may be newer than the PC it runs on.
+ # From the Strata_Dirigo fork (rwkeyes). ggml's defaults turn the rest on when GGML_NATIVE is off: all off here.
+ set(GGML_NATIVE OFF CACHE BOOL "" FORCE)
+ set(GGML_CPU_ALL_VARIANTS OFF CACHE BOOL "" FORCE)
+ set(GGML_SSE42 ON CACHE BOOL "" FORCE)
+ if(STRATA_ISA_FLOOR STREQUAL "avx")
+ set(GGML_AVX ON CACHE BOOL "" FORCE)
+ else()
+ set(GGML_AVX OFF CACHE BOOL "" FORCE)
+ endif()
+ foreach(_f GGML_AVX2 GGML_FMA GGML_F16C GGML_BMI2 GGML_AVX_VNNI GGML_AVX512 GGML_AVX512_VBMI GGML_AVX512_VNNI
+ GGML_AVX512_BF16 GGML_AMX_TILE GGML_AMX_INT8 GGML_AMX_BF16)
+ set(${_f} OFF CACHE BOOL "" FORCE)
+ endforeach()
else()
set(GGML_NATIVE ON CACHE BOOL "" FORCE)
endif()
@@ -877,20 +1004,36 @@ if(STRATA_NATIVE_EXPERTS)
# 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
- src/kernels/cpu/iq_avx2.cpp src/kernels/cpu/kq_avx2.cpp)
+ src/kernels/cpu/iq_avx2.cpp src/kernels/cpu/kq_avx2.cpp
+ src/kernels/cpu/kq_avx1.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")
set_source_files_properties(src/kernels/cpu/iq_avx2.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX2")
set_source_files_properties(src/kernels/cpu/kq_avx2.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX2")
+ # the router lookahead's AVX1 dot (older CPUs; called behind cpu_avx1_ok): no FMA, no 256-bit integer ops
+ set_source_files_properties(src/kernels/cpu/kq_avx1.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX")
else()
set_source_files_properties(src/kernels/cpu/iq_avx512.cpp PROPERTIES COMPILE_OPTIONS
"-mavx512f;-mavx512bw;-mavx512vl;-mavx512dq;-mavx512vnni;-mavx512vbmi;-mf16c;-mfma")
set_source_files_properties(src/kernels/cpu/iq_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
set_source_files_properties(src/kernels/cpu/kq_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
+ set_source_files_properties(src/kernels/cpu/kq_avx1.cpp PROPERTIES COMPILE_OPTIONS "-mavx")
+ endif()
+ if(STRATA_ISA_FLOOR STREQUAL "avx")
+ target_compile_definitions(strata_kernels_cpu PRIVATE STRATA_ISA_FLOOR_AVX=1)
+ elseif(STRATA_ISA_FLOOR STREQUAL "none")
+ target_compile_definitions(strata_kernels_cpu PRIVATE STRATA_ISA_FLOOR_NONE=1)
endif()
target_link_libraries(strata_kernels_cpu PUBLIC ggml-cpu ggml-base)
target_compile_definitions(strata_kernels_cpu PUBLIC STRATA_NATIVE_EXPERTS=1)
+ # the router lookahead's dot on each rung this CPU has (AVX2, the older CPUs' AVX1) against a double reference
+ add_executable(router_dot_parity src/kernels/cpu/router_dot_parity.cpp)
+ target_link_libraries(router_dot_parity PRIVATE strata_kernels_cpu)
+ if(STRATA_BUILD_TESTS)
+ add_test(NAME router_dot_parity COMMAND router_dot_parity)
+ add_test(NAME router_dot_parity_avx COMMAND ${CMAKE_COMMAND} -E env STRATA_FORCE_ISA=avx $)
+ endif()
set(_strata_mmq_types q2_0 iq2_xxs iq2_xs iq2_s iq3_xxs iq3_s iq4_nl iq4_xs q8_0)
# #296: OrcaRouter Q4_K_S's Q5_0 experts (Q4_K and Q5_1 come with STRATA_MMQ_KQUANTS on CUDA, S5's branch)
if(STRATA_ORCA_Q4KS_MMQ)
@@ -1003,6 +1146,9 @@ if(STRATA_NATIVE_EXPERTS)
add_executable(native_expert_parity src/kernels/native_expert_parity.cpp)
target_include_directories(native_expert_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(native_expert_parity PRIVATE strata_kernels strata_kernels_cpu strata_artifact ${_strata_gpu_runtime_target})
+ add_executable(native_expert_bench src/kernels/native_expert_bench.cpp)
+ target_include_directories(native_expert_bench PRIVATE ${_strata_gpu_include_directories})
+ target_link_libraries(native_expert_bench PRIVATE strata_kernels strata_kernels_cpu strata_artifact ${_strata_gpu_runtime_target})
if(STRATA_BUILD_TESTS AND STRATA_ENABLE_CUDA)
# Unsloth UD-Q4_K_XL's three expert pairs (and Q8_0 alone, its embedding) on synthetic ggml-quantized weights
# at 2560/640: GPU and CPU experts against the float reference, GPU dequantizers against to_float.
@@ -1043,6 +1189,15 @@ if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_
endif()
if(STRATA_BUILD_TESTS)
+ if(WIN32)
+ add_executable(pool_affinity_test src/kernels/cpu/pool_affinity_test.cpp)
+ target_link_libraries(pool_affinity_test PRIVATE strata_kernels_cpu)
+ add_test(NAME pool_affinity_test COMMAND pool_affinity_test)
+ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
+ add_executable(pool_affinity_test src/kernels/cpu/pool_affinity_linux_test.cpp)
+ target_link_libraries(pool_affinity_test PRIVATE strata_kernels_cpu)
+ add_test(NAME pool_affinity_test COMMAND pool_affinity_test)
+ endif()
# 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)
@@ -1085,3 +1240,15 @@ if(STRATA_BUILD_TESTS)
add_subdirectory(tests)
endif()
endif()
+
+# ---- HIP gfx906: every CUDA source of the tree (and the ggml-cuda MMQ sources) is compiled as HIP ----
+if(STRATA_HIP_GFX906)
+ file(GLOB_RECURSE _strata_cu CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cu ${CMAKE_CURRENT_SOURCE_DIR}/bench/micro/*.cu
+ ${CMAKE_CURRENT_SOURCE_DIR}/tools/vision/*.cu)
+ if(_strata_mmq)
+ file(GLOB _strata_mmq_cu ${_strata_mmq}/*.cu ${_strata_mmq}/template-instances/*.cu)
+ list(APPEND _strata_cu ${_strata_mmq_cu})
+ target_compile_definitions(strata_mmq PRIVATE GGML_USE_HIP)
+ endif()
+ set_source_files_properties(${_strata_cu} PROPERTIES LANGUAGE HIP)
+endif()
diff --git a/README.de.md b/README.de.md
new file mode 100644
index 0000000000..e400395e2a
--- /dev/null
+++ b/README.de.md
@@ -0,0 +1,221 @@
+
Ein KI-Modell mit 125 Milliarden Parametern auf deinem eigenen Gaming-PC
+NVIDIA- oder AMD-Grafikkarte (ab 12 GB) · Windows oder Linux · kostenlos und Open Source
+
+
+Ein Voxel-Pagodengarten aus einem einzigen Prompt, auf einer RTX 5070 mit Strata (IQ3_S, 128K Kontext) ·
+ganzes Video (49 s)
+
+Strata lässt **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next)** auf einem normalen PC laufen.
+Das ist ein großes, kluges KI-Modell, das sonst meist einen Server braucht. Es chattet, schreibt Code, versteht
+Bilder und arbeitet mit deinen Apps und Coding-Agents zusammen. Nichts verlässt deinen PC.
+
+## Wie schnell ist es?
+
+Wir haben es auf zwei ganz normalen Gaming-PCs gemessen. Ein Token ist etwa ¾ eines Wortes.
+
+- **Schreibt Antworten:** wie schnell die Antwort in einem kurzen Chat erscheint. 60 Tokens pro Sekunde sind
+ schneller, als du lesen kannst.
+- **Liest deinen Prompt:** wie schnell es aufnimmt, was du schickst (hier ein Dokument, Code oder Chatverlauf mit
+ 32K Tokens).
+
+
+
+NVIDIA: Q2_0 mit Engine 0.1.36, die anderen Zeilen mit 0.1.26 (4K Antworten, 32K Prompts). Die vollständigen
+Tabellen stehen in [DETAILS.md](docs/DETAILS.md#speed-measured). Eine Karte mit mehr VRAM ist schneller: Eine
+RTX 3090 (24 GB) sollte etwa 100-140 Tokens pro Sekunde schreiben. Lange Chats und andere Karten:
+[Tempo jedes Modells](docs/MODELS.md#how-fast-is-each-size), [Ergebnisse aus der Community](docs/COMMUNITY_BENCHMARKS.md).
+
+
+Strata ist kostenlos. Wenn es auf deinem PC gut läuft, hält ein Kaffee die Arbeit daran am Laufen.
+
+## Was du brauchst
+
+| | |
+| --- | --- |
+| **Grafikkarte** | **NVIDIA** GeForce RTX 20, 30, 40 oder 50 Serie, oder **AMD** Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700 oder RX 6800 / 6900 Serie. Sie braucht **12 GB VRAM oder mehr**. |
+| **RAM** | 32 GB oder mehr. Dein RAM entscheidet, [welches Modell](#welches-modell-soll-ich-nehmen) passt. Mit 64 GB laufen alle Größen. |
+| **Festplatte** | Etwa 80 GB frei. Nimm wenn möglich eine SSD: Der erste Start geht dann viel schneller. |
+| **System** | Windows 10 / 11 oder Linux und ein aktueller Grafiktreiber von NVIDIA oder AMD. |
+
+Alles andere richtet der Installer ein. Zwei oder drei Karten können sich das Modell teilen ([Multi-GPU](docs/MULTI_GPU.md)).
+
+Experimentell, von Community-Mitgliedern auf ihren eigenen Rechnern geschrieben und getestet:
+
+- **Ältere Grafikkarten** (Tesla P40 / V100, GTX 10, Radeon VII / MI50, RX 6700 XT, RX 5500 XT): [Ältere GPUs](docs/OLDER_GPUS.md).
+- **Intel Arc**, unter Linux aus dem Quellcode gebaut: [Intel Arc](docs/INTEL_ARC.md).
+- **Ältere Prozessoren ohne AVX2**: Sie funktionieren, aber langsam. [Ältere CPUs](docs/INSTALL.md#older-cpus-experimental).
+
+Die vollständige Liste: [docs/INSTALL.md](docs/INSTALL.md#what-you-need).
+
+## Installieren
+
+### Lass deine KI es einrichten
+
+Nutzt du einen KI-Coding-Assistenten (Claude Code, Cursor, Codex, GitHub Copilot, ...)? Füge dort diesen Text ein:
+
+```text
+Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow docs/AI_SETUP.md in that repository.
+```
+
+Er prüft deine Grafikkarte, deinen RAM und deine Festplatte und wählt das passende Modell. Dann installiert und
+startet er es und sagt dir, wie du deine Apps verbindest. KI-Tools können Strata auch über seinen
+[MCP-Server](docs/MCP_SERVER.md) installieren, starten und stoppen.
+
+### Oder mach es selbst
+
+[Lade Strata herunter](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) und entpacke es (oder nutze
+`git clone`). **Windows:** Doppelklick auf **`START-HERE.bat`**. **Linux:** Führe im Strata-Ordner **`./setup.sh`** aus.
+
+Die Schritte sind für NVIDIA und AMD gleich. Der Installer erkennt deine Karte und richtet die passende Engine
+dafür ein. Er stellt dir ein paar Fragen:
+
+- welches Modell und welche Größe,
+- wie viel Kontext (wie viel Text sich das Modell merkt),
+- ob es Bilder lesen soll.
+
+Drück jedes Mal Enter, um die empfohlene Antwort zu nehmen. Dann lädt er das Modell herunter (etwa 70 GB) und
+startet es. Bricht der Download ab, starte ihn einfach neu: Er macht dort weiter, wo er aufgehört hat. Dein Browser
+öffnet die Strata-App unter `http://127.0.0.1:8080`.
+
+> **Während das Modell startet, kann dein PC 1-3 Minuten langsam sein oder nicht reagieren** (beim ersten Mal am
+> längsten). Strata lädt 35-55 GB in deinen RAM und reserviert einen Teil davon für die Grafikkarte. Das ist normal.
+> Warte und schließ das Fenster nicht. Im Fenster siehst du, was Strata gerade tut.
+
+**Beim nächsten Mal** startest du wieder `START-HERE.bat` (oder `./setup.sh`). Es startet sofort und lädt nichts
+doppelt herunter. Schließ das Fenster, um das Modell zu stoppen. `UPDATE.bat` (`./update.sh`) aktualisiert Strata,
+ohne es zu starten. Updates, Docker, mehrere Karten, wo die Dateien landen und alle Optionen:
+[docs/INSTALL.md](docs/INSTALL.md).
+
+## Welches Modell soll ich nehmen?
+
+Der Installer empfiehlt eins passend zu deinem RAM. Dasselbe Modell gibt es in mehreren Größen, mehr oder weniger
+stark komprimiert. Kleinere Größen sind schneller. Größere sind etwas klüger.
+
+| Dein RAM | Nimm | Warum |
+| --- | --- | --- |
+| **32 GB** | **Coder** | passt in 32 GB und ist für Code gemacht (mit einer 24-GB-Karte laufen auch Q2_0 und IQ2_XS) |
+| **48 GB** | **IQ2_XS** (oder Q2_0, das schnellste) | die größeren Größen passen nicht |
+| **64 GB** | **IQ2_XS** (empfohlen) oder IQ3_XXS / IQ3_S | alle Größen passen; IQ3_S ist das beste und das langsamste |
+| **96 GB oder mehr** | **IQ3_S** oder Unsloths UD-IQ4_XS (~4 Bit) | Platz für die größten Größen, auch wenn alles andere offen ist |
+
+- **[Coder](docs/MODELS.md#coder):** eine Version für Code, bei der die Hälfte der Experten entfernt wurde. Sie
+ erreicht 91 % des SWE-bench-Verified-Werts des vollen Modells (von ihren Autoren gemessen) und passt in 32 GB RAM.
+ Außerhalb von Code ist sie schwächer, auch bei chinesischem und anderem CJK-Text (#438). Nimm dafür Q2_0, IQ2_XS
+ oder IQ3_S, die alle Experten behalten.
+- **[Swift 1.5](docs/MODELS.md#swift-15):** ein Fine-Tune, das viel kürzer nachdenkt, bevor es antwortet. Du
+ bekommst die Antwort früher, bei etwa gleicher Qualität.
+- **[Unsloth UD-IQ4_XS](docs/MODELS.md#unsloth-ud-iq4_xs):** die ~4-Bit-Version von Unsloth, in der Qualität
+ zwischen IQ3_S und UD-Q4_K_XL. 94 GB Download. Mit weniger als ~80 GB RAM liest Strata einen Teil davon während
+ der Antwort von der SSD, dort ist es also langsamer (eine NVMe-SSD hilft).
+- **[Unsloth UD-Q4_K_XL](docs/MODELS.md#unsloth-ud-q4_k_xl-experimental)** (experimentell): am nächsten am vollen
+ Modell. Aber Strata liest das meiste davon während der Antwort von der SSD, deshalb schreibt es auf einem PC mit
+ 64 GB nur 7-8.5 Tokens/s.
+- **[OrcaRouter's Uncensored IQ3_XXS](docs/MODELS.md#orcarouter-uncensored-iq3_xxs):** Das richtest du von Hand
+ ein. Es steht nicht im Menü des Installers.
+
+Größen, Downloads und was wohin passt: [docs/MODELS.md](docs/MODELS.md). Um später ein weiteres Modell
+hinzuzufügen, starte `SETUP.bat` (Linux: `./setup.sh --setup`).
+
+## So benutzt du es
+
+
+Der Monitor der Strata-App (links), während ein Coding-Agent den Pagodengarten aus dem Video schreibt (rechts)
+
+- **Im Browser:** Öffne `http://127.0.0.1:8080`. Dort gibt es **Chat**, einen Live-**Monitor** für das Modell und
+ deine GPU/CPU/RAM und **About** mit den Einstellungen und Adressen.
+- **Deine Apps und Coding-Agents:** Füge einen „OpenAI-kompatiblen“ Anbieter mit der Basis-URL
+ **`http://127.0.0.1:8080/v1`** hinzu. Jeder API-Key und jeder Modellname funktioniert.
+ - Apps, die die API von Anthropic nutzen: `http://127.0.0.1:8080/v1/messages` (Claude Code:
+ `ANTHROPIC_BASE_URL=http://127.0.0.1:8080`).
+ - Codex CLI und andere Apps, die die OpenAI Responses API nutzen: `/v1/responses`
+ ([Einrichtung](docs/DETAILS.md#the-responses-api-and-codex-cli)).
+- **Nachdenken:** Wähle **aus, niedrig, mittel oder hoch** im Chat-Menü oder unter „Reasoning Effort“ in deiner
+ App. Aus ist am schnellsten. Hoch ist am besten für schwierige Fragen.
+- **Bilder:** Antworte im Setup mit Ja auf „Images?“. Klick dann im Chat auf **Picture** oder hänge Bilder in deiner
+ App an. AMD-Karten lesen Bilder unter Linux über den Prozessor; unter Windows geht das noch nicht.
+- **Vom Handy oder einem anderen PC:** `START-HERE.bat --setup --host 0.0.0.0 --api-key `. Setz immer einen
+ Key.
+- **Eine Anfrage nach der anderen:** Standardmäßig beantwortet Strata eine Anfrage, die anderen warten. Um mehrere
+ gleichzeitig zu beantworten, setz `"parallel": 2` ([BATCHING.md](docs/BATCHING.md)). Auf einer 12-GB-Karte wird
+ dadurch jede Antwort langsamer.
+- **Lange Prompts:** Strata liest die erste Nachricht eines Chats komplett, etwa 1 Minute pro 30,000 Tokens.
+ Folgenachrichten starten in Sekunden.
+
+Mehr: [wo deine Chats gespeichert werden](docs/INSTALL.md#where-things-are-stored), [die API](docs/DETAILS.md#using-it).
+
+## Etwas ist schiefgelaufen?
+
+- **Mein PC ist beim ersten Start von Strata eingefroren.** Das ist normal, während das Modell lädt. Warte und
+ schließ das Fenster nicht. Nach 10 Minuten immer noch eingefroren? Starte den PC neu, schließ andere Programme und
+ versuch es noch einmal, oder nimm eine kleinere Größe.
+- **Es hat beim Herunterladen oder Installieren aufgehört.** Starte `START-HERE.bat` (oder `./setup.sh`) noch
+ einmal. Es macht dort weiter, wo es aufgehört hat.
+- **Es ist sehr langsam und die Festplattenlampe blinkt ständig, oder es meldet „the engine stopped
+ unexpectedly“.** Dein PC hat nicht genug freien RAM. Schließ andere Programme (Browser brauchen viel) oder nimm
+ eine kleinere Größe (Q2_0 oder IQ2_XS).
+- **Es meldet, dass Port 8080 schon belegt ist.** Strata läuft bereits. Such nach seinem Fenster.
+
+Weitere Probleme und ihre Lösungen: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). Kommst du nicht weiter?
+Öffne ein [Issue](https://github.com/Niko1221/Strata/issues) und hänge `strata-.log` aus dem Strata-Ordner
+an. Du hast ein Sicherheitsproblem gefunden? Melde es vertraulich: [SECURITY.md](SECURITY.md).
+
+## Wie funktioniert es?
+
+Modelle wie dieses laufen normalerweise auf Servern mit Hunderten Gigabyte Grafikspeicher. Deine Grafikkarte hat
+12-24 GB. Strata bringt das Modell trotzdem unter, indem es **die Arbeit auf deinen ganzen PC verteilt**. Stell dir
+eine Küche vor: Was du ständig brauchst, bleibt auf der Arbeitsfläche, der Rest wartet in der Vorratskammer.
+
+
+
+- **Das Modell ist ein Team aus 24,576 kleinen Spezialisten („Experten“).** Jedes Wort braucht nur 10 davon.
+- **Deine Grafikkarte** hält die paar tausend Experten, die am häufigsten gebraucht werden. **Dein RAM** hält alle,
+ und **dein Prozessor** rechnet gleichzeitig am Rest. **Deine SSD** hält eine große Nachschlagetabelle.
+
+
+
+- **Raten, dann prüfen:** Ein kleiner Helfer rät die nächsten paar Wörter. Das große Modell prüft sie alle auf
+ einmal. Du bekommst dieselbe Antwort, nur 1.6-1.8x schneller.
+- **Lange Texte werden in großen Stücken gelesen** (bis zu 8,192 Tokens auf einmal), mit über 1,000 Tokens pro
+ Sekunde.
+
+Die längere Erklärung: [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md). Jeder Teil und seine Zahlen:
+[die Details](docs/DETAILS.md#how-it-works) und das [Paper](docs/paper/Strata-Paper.pdf).
+
+## Danksagung und Lizenz
+
+Das Modell ist [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) vom Qwen-Team. Komprimiert
+wurde es von [ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF), UkisAI (Swift 1.5)
+und Unsloth. Strata nutzt Teile von [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp). Alle Danksagungen:
+[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#credits). Strata ist Open Source unter der [MIT-Lizenz](LICENSE). Ein
+paar Teile und alle Modelle haben eigene Lizenzen ([welche](docs/HOW_IT_WORKS.md#license)).
+
+## Strata unterstützen
+
+Strata ist kostenlos und Open Source. Wenn es dir nützt, kannst du die Entwicklung unterstützen:
+
+
diff --git a/README.es.md b/README.es.md
new file mode 100644
index 0000000000..936c13de10
--- /dev/null
+++ b/README.es.md
@@ -0,0 +1,215 @@
+
Ejecuta un modelo de IA de 125 mil millones de parámetros en tu propio PC gaming
+Tarjeta gráfica NVIDIA o AMD (12 GB o más) · Windows o Linux · libre y de código abierto
+
+
+Un jardín con una pagoda de vóxeles, hecho con un solo prompt en una RTX 5070 con Strata (IQ3_S, contexto de 128K) ·
+vídeo completo (49 s)
+
+Strata ejecuta **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next)** en un PC normal. Es un
+modelo de IA grande e inteligente que normalmente necesita un servidor. Conversa, escribe código, entiende imágenes
+y funciona con tus aplicaciones y agentes de programación. Nada sale de tu PC.
+
+## ¿Qué velocidad tiene?
+
+Lo medimos en dos PC gaming normales. Un token equivale más o menos a ¾ de una palabra.
+
+- **Escribe respuestas:** lo rápido que aparece la respuesta en un chat corto. 60 tokens por segundo es más rápido de lo que puedes leer.
+- **Lee tu prompt:** lo rápido que procesa lo que le envías (aquí, un documento, código o historial de chat de 32K tokens).
+
+
+
+NVIDIA: Q2_0 con el motor 0.1.36, las demás filas con 0.1.26 (respuestas de 4K, prompts de 32K). Las tablas
+completas están en [DETAILS.md](docs/DETAILS.md#speed-measured). Una tarjeta con más VRAM es más rápida: una
+RTX 3090 (24 GB) debería escribir unos 100-140 tokens por segundo. Chats largos y otras tarjetas:
+[velocidad de cada modelo](docs/MODELS.md#how-fast-is-each-size), [resultados de la comunidad](docs/COMMUNITY_BENCHMARKS.md).
+
+
+Strata es gratis. Si funciona bien en tu PC, un café ayuda a seguir trabajando en él.
+
+## Qué necesitas
+
+| | |
+| --- | --- |
+| **Tarjeta gráfica** | **NVIDIA** GeForce serie RTX 20, 30, 40 o 50, o **AMD** Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700 o serie RX 6800 / 6900. Necesita **12 GB de VRAM o más**. |
+| **RAM** | 32 GB o más. Tu RAM decide [qué modelo](#qué-modelo-elijo) cabe. Con 64 GB funcionan todos los tamaños. |
+| **Disco** | Unos 80 GB libres. Usa un SSD si puedes: el primer arranque es mucho más rápido. |
+| **Sistema** | Windows 10 / 11 o Linux, y un controlador gráfico actual de NVIDIA o AMD. |
+
+El instalador prepara todo lo demás. Dos o tres tarjetas pueden repartirse el modelo ([multi-GPU](docs/MULTI_GPU.md)).
+
+Experimental, escrito y probado por miembros de la comunidad en sus propios equipos:
+
+- **Tarjetas gráficas antiguas** (Tesla P40 / V100, GTX 10, Radeon VII / MI50, RX 6700 XT, RX 5500 XT): [Older GPUs](docs/OLDER_GPUS.md).
+- **Intel Arc**, compilado desde el código fuente en Linux: [Intel Arc](docs/INTEL_ARC.md).
+- **Procesadores antiguos sin AVX2**: funcionan, pero despacio. [Older CPUs](docs/INSTALL.md#older-cpus-experimental).
+
+La lista completa: [docs/INSTALL.md](docs/INSTALL.md#what-you-need).
+
+## Instalación
+
+### Deja que tu IA lo instale
+
+¿Usas un asistente de programación con IA (Claude Code, Cursor, Codex, GitHub Copilot, ...)? Pega esto en él:
+
+```text
+Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow docs/AI_SETUP.md in that repository.
+```
+
+Revisa tu tarjeta gráfica, tu RAM y tu disco, y elige el modelo que cabe. Luego lo instala, lo arranca y te explica
+cómo conectar tus aplicaciones. Las herramientas de IA también pueden instalar, arrancar y detener Strata con su
+[servidor MCP](docs/MCP_SERVER.md).
+
+### O hazlo tú mismo
+
+[Descarga Strata](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) y descomprímelo (o usa `git clone`).
+**Windows:** haz doble clic en **`START-HERE.bat`**. **Linux:** ejecuta **`./setup.sh`** en la carpeta de Strata.
+
+Los pasos son los mismos para NVIDIA y AMD. El instalador detecta tu tarjeta y prepara el motor adecuado para ella.
+Te hace unas pocas preguntas:
+
+- qué modelo y qué tamaño,
+- cuánto contexto (cuánto texto tiene presente el modelo),
+- si debe entender imágenes.
+
+Pulsa Enter cada vez para usar la respuesta recomendada. Después descarga el modelo (unos 70 GB) y lo arranca. Si la
+descarga se detiene, vuelve a ejecutarlo: sigue donde lo dejó. Tu navegador abre la aplicación de Strata en
+`http://127.0.0.1:8080`.
+
+> **Mientras el modelo arranca, tu PC puede ir lento o dejar de responder durante 1-3 minutos** (más la primera vez).
+> Strata carga 35-55 GB en tu RAM y reserva una parte para la tarjeta gráfica. Es normal. Espera y no cierres la
+> ventana. La ventana muestra lo que está haciendo Strata.
+
+**La próxima vez**, vuelve a ejecutar `START-HERE.bat` (o `./setup.sh`). Arranca enseguida y no descarga nada dos
+veces. Cierra su ventana para detener el modelo. `UPDATE.bat` (`./update.sh`) actualiza Strata sin arrancarlo.
+Actualizaciones, Docker, varias tarjetas, dónde se guardan los archivos y todas las opciones: [docs/INSTALL.md](docs/INSTALL.md).
+
+## ¿Qué modelo elijo?
+
+El instalador te recomienda uno según tu RAM. El mismo modelo viene en varios tamaños, más o menos comprimidos. Los
+tamaños pequeños son más rápidos. Los grandes son algo más inteligentes.
+
+| Tu RAM | Elige | Por qué |
+| --- | --- | --- |
+| **32 GB** | **Coder** | cabe en 32 GB y está hecho para programar (con una tarjeta de 24 GB también funcionan Q2_0 e IQ2_XS) |
+| **48 GB** | **IQ2_XS** (o Q2_0, el más rápido) | los tamaños más grandes no caben |
+| **64 GB** | **IQ2_XS** (recomendado), o IQ3_XXS / IQ3_S | caben todos los tamaños; IQ3_S es el mejor y el más lento |
+| **96 GB o más** | **IQ3_S**, o el UD-IQ4_XS de Unsloth (~4 bits) | espacio para los tamaños más grandes con todo lo demás abierto |
+
+- **[Coder](docs/MODELS.md#coder):** una versión para programar a la que se le quitó la mitad de los expertos. Alcanza
+ el 91% de la puntuación SWE-bench Verified del modelo completo (medido por sus autores) y cabe en 32 GB de RAM. Es
+ más flojo fuera del código, también con chino y otros textos CJK (#438). Para eso, elige Q2_0, IQ2_XS o IQ3_S, que
+ conservan todos los expertos.
+- **[Swift 1.5](docs/MODELS.md#swift-15):** un ajuste fino que piensa mucho menos tiempo antes de responder. Recibes
+ la respuesta antes, con más o menos la misma calidad.
+- **[Unsloth UD-IQ4_XS](docs/MODELS.md#unsloth-ud-iq4_xs):** la versión de ~4 bits de Unsloth, entre IQ3_S y
+ UD-Q4_K_XL en calidad. Una descarga de 94 GB. Con menos de ~80 GB de RAM, Strata lee una parte desde el SSD
+ mientras responde, así que ahí es más lento (un SSD NVMe ayuda).
+- **[Unsloth UD-Q4_K_XL](docs/MODELS.md#unsloth-ud-q4_k_xl-experimental)** (experimental): el más parecido al modelo
+ completo. Pero Strata lee la mayor parte desde el SSD mientras responde, así que solo escribe 7-8.5 tokens/s en un
+ PC con 64 GB.
+- **[OrcaRouter's Uncensored IQ3_XXS](docs/MODELS.md#orcarouter-uncensored-iq3_xxs):** se instala a mano. No está en
+ el menú del instalador.
+
+Tamaños, descargas y qué cabe dónde: [docs/MODELS.md](docs/MODELS.md). Para añadir otro modelo más adelante, ejecuta
+`SETUP.bat` (Linux: `./setup.sh --setup`).
+
+## Cómo usarlo
+
+
+El Monitor de la aplicación de Strata (izquierda) mientras un agente de programación escribe el jardín de la pagoda del vídeo (derecha)
+
+- **En el navegador:** abre `http://127.0.0.1:8080`. Tiene **Chat**, un **Monitor** en vivo del modelo y de tu
+ GPU/CPU/RAM, y **About** con los ajustes y las direcciones.
+- **Tus aplicaciones y agentes de programación:** añade un proveedor "compatible con OpenAI" con la URL base
+ **`http://127.0.0.1:8080/v1`**. Sirve cualquier clave de API y cualquier nombre de modelo.
+ - Aplicaciones que usan la API de Anthropic: `http://127.0.0.1:8080/v1/messages` (Claude Code:
+ `ANTHROPIC_BASE_URL=http://127.0.0.1:8080`).
+ - Codex CLI y otras aplicaciones que usan la API Responses de OpenAI: `/v1/responses`
+ ([configuración](docs/DETAILS.md#the-responses-api-and-codex-cli)).
+- **Razonamiento:** elige **off, low, medium o high** en el menú del chat o en el "reasoning effort" de tu aplicación.
+ Off es lo más rápido. High es lo mejor para preguntas difíciles.
+- **Imágenes:** responde que sí a "Images?" en la instalación. Luego haz clic en **Picture** en el chat, o adjunta
+ imágenes en tu aplicación. Las tarjetas AMD leen imágenes en Linux a través del procesador; en Windows todavía no.
+- **Desde tu móvil u otro PC:** `START-HERE.bat --setup --host 0.0.0.0 --api-key `. Pon siempre una clave.
+- **Una petición cada vez:** por defecto Strata responde una petición y las demás esperan. Para responder varias a la
+ vez, pon `"parallel": 2` ([BATCHING.md](docs/BATCHING.md)). En una tarjeta de 12 GB, cada respuesta va más lenta.
+- **Prompts largos:** Strata lee entero el primer mensaje de un chat, más o menos 1 minuto por cada 30,000 tokens. Los
+ mensajes siguientes empiezan en segundos.
+
+Más: [dónde se guardan tus chats](docs/INSTALL.md#where-things-are-stored), [la API](docs/DETAILS.md#using-it).
+
+## ¿Algo salió mal?
+
+- **Mi PC se congeló la primera vez que arrancó Strata.** Es normal mientras carga el modelo. Espera y no cierres la
+ ventana. ¿Sigue congelado después de 10 minutos? Reinicia el PC, cierra otros programas y vuelve a intentarlo, o
+ elige un tamaño más pequeño.
+- **Se detuvo durante la descarga o la instalación.** Vuelve a ejecutar `START-HERE.bat` (o `./setup.sh`). Sigue
+ donde se detuvo.
+- **Va muy lento y la luz del disco no deja de parpadear, o dice "the engine stopped unexpectedly".** Tu PC no tiene
+ suficiente RAM libre. Cierra otros programas (los navegadores usan mucha), o elige un tamaño más pequeño (Q2_0 o
+ IQ2_XS).
+- **Dice que el puerto 8080 ya está en uso.** Strata ya está en marcha. Busca su ventana.
+
+Más problemas y sus soluciones: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). ¿Sigues atascado? Abre un
+[issue](https://github.com/Niko1221/Strata/issues) y adjunta `strata-.log` de la carpeta de Strata. ¿Has
+encontrado un problema de seguridad? Infórmalo en privado: [SECURITY.md](SECURITY.md).
+
+## ¿Cómo funciona?
+
+Los modelos como este suelen funcionar en servidores con cientos de gigabytes de memoria gráfica. Tu tarjeta gráfica
+tiene 12-24 GB. Strata hace que el modelo quepa **repartiendo el trabajo por todo tu PC**. Piensa en una cocina: lo
+que usas todo el rato se queda en la encimera, y el resto espera en la despensa.
+
+
+
+- **El modelo es un equipo de 24,576 pequeños especialistas ("expertos").** Cada palabra solo necesita 10 de ellos.
+- **Tu tarjeta gráfica** guarda los pocos miles de expertos que más se usan. **Tu RAM** los tiene todos, y **tu
+ procesador** trabaja con el resto al mismo tiempo. **Tu SSD** guarda una gran tabla de consulta.
+
+
+
+- **Adivinar y comprobar:** un pequeño ayudante adivina las siguientes palabras. El modelo grande las comprueba todas
+ a la vez. Recibes la misma respuesta, 1.6-1.8x antes.
+- **Los textos largos se leen en trozos grandes** (hasta 8,192 tokens cada vez), a más de 1,000 tokens por segundo.
+
+La explicación larga: [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md). Cada parte y sus números:
+[los detalles](docs/DETAILS.md#how-it-works) y el [artículo](docs/paper/Strata-Paper.pdf).
+
+## Créditos y licencia
+
+El modelo es [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next), del equipo de Qwen. Lo
+comprimieron [ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF), UkisAI (Swift 1.5)
+y Unsloth. Strata usa partes de [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp). Todos los créditos:
+[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#credits). Strata es de código abierto con la [licencia MIT](LICENSE).
+Algunas partes y cada modelo tienen sus propias licencias ([cuáles](docs/HOW_IT_WORKS.md#license)).
+
+## Apoya a Strata
+
+Strata es libre y de código abierto. Si te resulta útil, puedes apoyar su desarrollo:
+
+
diff --git a/README.fr.md b/README.fr.md
new file mode 100644
index 0000000000..22faf7d894
--- /dev/null
+++ b/README.fr.md
@@ -0,0 +1,220 @@
+
Faites tourner un modèle d'IA de 125 milliards de paramètres sur votre propre PC de jeu
+Carte graphique NVIDIA ou AMD (12 Go ou plus) · Windows ou Linux · gratuit et open source
+
+
+Un jardin de pagode en voxels, généré en une seule requête sur une RTX 5070 avec Strata (IQ3_S, contexte de 128K) ·
+vidéo complète (49 s)
+
+Strata fait tourner **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next)** sur un PC normal. C'est
+un grand modèle d'IA, très capable, qui demande d'habitude un serveur. Il discute, écrit du code, lit des images et
+fonctionne avec vos applications et vos agents de code. Rien ne quitte votre PC.
+
+## Quelle est sa vitesse ?
+
+Nous l'avons mesurée sur deux PC de jeu ordinaires. Un token correspond à environ ¾ d'un mot.
+
+- **Écrit les réponses :** la vitesse à laquelle la réponse s'affiche dans une courte discussion. 60 tokens par seconde, c'est plus rapide que votre lecture.
+- **Lit votre prompt :** la vitesse à laquelle il absorbe ce que vous envoyez (ici un document, du code ou un historique de discussion de 32K tokens).
+
+
+
NVIDIA : RTX 5070 (12 Go), Ryzen 5 7600, 64 Go de RAM
AMD : RX 9070 XT (16 Go), Ryzen 9 3900X, 47 Go de RAM
+
+NVIDIA : Q2_0 avec le moteur 0.1.36, les autres lignes avec 0.1.26 (réponses de 4K, prompts de 32K). Les tableaux
+complets sont dans [DETAILS.md](docs/DETAILS.md#speed-measured). Une carte avec plus de VRAM va plus vite : une
+RTX 3090 (24 Go) devrait écrire environ 100-140 tokens par seconde. Longues discussions et autres cartes :
+[vitesse de chaque modèle](docs/MODELS.md#how-fast-is-each-size), [résultats de la communauté](docs/COMMUNITY_BENCHMARKS.md).
+
+
+Strata est gratuit. S'il tourne bien sur votre PC, un café aide à poursuivre le travail.
+
+## Ce qu'il vous faut
+
+| | |
+| --- | --- |
+| **Carte graphique** | **NVIDIA** GeForce RTX série 20, 30, 40 ou 50, ou **AMD** Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700 ou série RX 6800 / 6900. Il faut **12 Go de VRAM ou plus**. |
+| **RAM** | 32 Go ou plus. Votre RAM décide [quel modèle](#quel-modèle-choisir-) tient. Avec 64 Go, toutes les tailles tournent. |
+| **Disque** | Environ 80 Go libres. Prenez un SSD si possible : le premier démarrage est bien plus rapide. |
+| **Système** | Windows 10 / 11 ou Linux, avec un pilote graphique NVIDIA ou AMD à jour. |
+
+L'installateur s'occupe de tout le reste. Deux ou trois cartes peuvent se partager le modèle ([multi-GPU](docs/MULTI_GPU.md)).
+
+Expérimental, écrit et testé par des membres de la communauté sur leurs propres machines :
+
+- **Cartes graphiques plus anciennes** (Tesla P40 / V100, GTX 10, Radeon VII / MI50, RX 6700 XT, RX 5500 XT) : [Older GPUs](docs/OLDER_GPUS.md).
+- **Intel Arc**, compilé depuis les sources sous Linux : [Intel Arc](docs/INTEL_ARC.md).
+- **Processeurs plus anciens sans AVX2** : ça marche, mais lentement. [Older CPUs](docs/INSTALL.md#older-cpus-experimental).
+
+La liste complète : [docs/INSTALL.md](docs/INSTALL.md#what-you-need).
+
+## Installation
+
+### Laissez votre IA l'installer
+
+Vous utilisez un assistant de code IA (Claude Code, Cursor, Codex, GitHub Copilot, ...) ? Collez-lui ceci :
+
+```text
+Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow docs/AI_SETUP.md in that repository.
+```
+
+Il vérifie votre carte graphique, votre RAM et votre disque, et choisit le modèle qui convient. Ensuite il l'installe,
+le démarre et vous explique comment connecter vos applications. Les outils d'IA peuvent aussi installer, démarrer et
+arrêter Strata grâce à son [serveur MCP](docs/MCP_SERVER.md).
+
+### Ou faites-le vous-même
+
+[Téléchargez Strata](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) et décompressez-le (ou faites un `git clone`).
+**Windows :** double-cliquez sur **`START-HERE.bat`**. **Linux :** lancez **`./setup.sh`** dans le dossier Strata.
+
+Les étapes sont les mêmes pour NVIDIA et AMD. L'installateur détecte votre carte et installe le bon moteur pour elle.
+Il vous pose quelques questions :
+
+- quel modèle et quelle taille,
+- combien de contexte (la quantité de texte que le modèle garde en tête),
+- s'il doit lire les images.
+
+Appuyez sur Entrée à chaque fois pour garder la réponse recommandée. Ensuite il télécharge le modèle (environ 70 Go)
+et le démarre. Si le téléchargement s'arrête, relancez-le : il reprend là où il s'était arrêté. Votre navigateur
+ouvre l'application Strata à l'adresse `http://127.0.0.1:8080`.
+
+> **Pendant le démarrage du modèle, votre PC peut ralentir ou ne plus répondre pendant 1-3 minutes** (plus longtemps la première fois).
+> Strata charge 35-55 Go dans votre RAM et en réserve une partie pour la carte graphique. C'est normal. Attendez, et
+> ne fermez pas la fenêtre. La fenêtre montre ce que fait Strata.
+
+**La fois suivante**, relancez `START-HERE.bat` (ou `./setup.sh`). Il démarre tout de suite et ne télécharge rien
+deux fois. Fermez sa fenêtre pour arrêter le modèle. `UPDATE.bat` (`./update.sh`) met Strata à jour sans le démarrer.
+Mises à jour, Docker, plusieurs cartes, emplacement des fichiers et toutes les options : [docs/INSTALL.md](docs/INSTALL.md).
+
+## Quel modèle choisir ?
+
+L'installateur en recommande un selon votre RAM. Le même modèle existe en plusieurs tailles, plus ou moins
+compressées. Les petites tailles sont plus rapides. Les grandes sont un peu plus intelligentes.
+
+| Votre RAM | Prenez | Pourquoi |
+| --- | --- | --- |
+| **32 Go** | **Coder** | il tient dans 32 Go et il est fait pour le code (avec une carte de 24 Go, Q2_0 et IQ2_XS tournent aussi) |
+| **48 Go** | **IQ2_XS** (ou Q2_0, le plus rapide) | les plus grandes tailles ne tiennent pas |
+| **64 Go** | **IQ2_XS** (recommandé), ou IQ3_XXS / IQ3_S | toutes les tailles tiennent ; IQ3_S est le meilleur et le plus lent |
+| **96 Go ou plus** | **IQ3_S**, ou l'UD-IQ4_XS d'Unsloth (~4 bits) | de la place pour les plus grandes tailles, avec tout le reste ouvert |
+
+- **[Coder](docs/MODELS.md#coder) :** une version pour le code, avec la moitié des experts en moins. Elle atteint 91 %
+ du score SWE-bench Verified du modèle complet (mesuré par ses auteurs) et tient dans 32 Go de RAM. Elle est moins
+ bonne en dehors du code, y compris pour le chinois et les autres textes CJK (#438). Pour ces cas, prenez Q2_0,
+ IQ2_XS ou IQ3_S, qui gardent tous les experts.
+- **[Swift 1.5](docs/MODELS.md#swift-15) :** un fine-tune qui réfléchit bien moins longtemps avant de répondre. Vous
+ avez la réponse plus tôt, avec à peu près la même qualité.
+- **[Unsloth UD-IQ4_XS](docs/MODELS.md#unsloth-ud-iq4_xs)** : la version ~4 bits d'Unsloth, entre IQ3_S et
+ UD-Q4_K_XL en qualité. Un téléchargement de 94 Go. Avec moins de ~80 Go de RAM, Strata en lit une partie depuis
+ le SSD pendant qu'il répond, il y est donc plus lent (un SSD NVMe aide).
+- **[Unsloth UD-Q4_K_XL](docs/MODELS.md#unsloth-ud-q4_k_xl-experimental)** (expérimental) : le plus proche du modèle
+ complet. Mais Strata en lit la plus grande partie depuis le SSD pendant qu'il répond, donc il n'écrit que
+ 7-8.5 tokens/s sur un PC avec 64 Go.
+- **[OrcaRouter's Uncensored IQ3_XXS](docs/MODELS.md#orcarouter-uncensored-iq3_xxs) :** vous l'installez à la main.
+ Il n'est pas dans le menu de l'installateur.
+
+Tailles, téléchargements et ce qui tient où : [docs/MODELS.md](docs/MODELS.md). Pour ajouter un autre modèle plus
+tard, lancez `SETUP.bat` (Linux : `./setup.sh --setup`).
+
+## Utilisation
+
+
+Le Monitor de l'application Strata (à gauche) pendant qu'un agent de code écrit le jardin de pagode de la vidéo (à droite)
+
+- **Dans le navigateur :** ouvrez `http://127.0.0.1:8080`. Vous y trouvez **Chat**, un **Monitor** en direct du
+ modèle et de votre GPU/CPU/RAM, et **About** avec les réglages et les adresses.
+- **Vos applications et agents de code :** ajoutez un fournisseur « OpenAI-compatible » avec l'URL de base
+ **`http://127.0.0.1:8080/v1`**. N'importe quelle clé API et n'importe quel nom de modèle fonctionnent.
+ - Applications qui utilisent l'API d'Anthropic : `http://127.0.0.1:8080/v1/messages` (Claude Code :
+ `ANTHROPIC_BASE_URL=http://127.0.0.1:8080`).
+ - Codex CLI et les autres applications qui utilisent l'API OpenAI Responses : `/v1/responses`
+ ([configuration](docs/DETAILS.md#the-responses-api-and-codex-cli)).
+- **Réflexion :** choisissez **off, low, medium ou high** dans le menu du chat ou dans le « reasoning effort » de
+ votre application. Off est le plus rapide. High est le meilleur pour les questions difficiles.
+- **Images :** répondez oui à « Images? » pendant l'installation. Ensuite cliquez sur **Picture** dans le chat, ou
+ joignez des images dans votre application. Les cartes AMD lisent les images sous Linux via le processeur ; sous
+ Windows, pas encore.
+- **Depuis votre téléphone ou un autre PC :** `START-HERE.bat --setup --host 0.0.0.0 --api-key `. Définissez
+ toujours une clé.
+- **Une requête à la fois :** par défaut, Strata répond à une requête et les autres attendent. Pour répondre à
+ plusieurs en même temps, mettez `"parallel": 2` ([BATCHING.md](docs/BATCHING.md)). Sur une carte de 12 Go, chaque
+ réponse devient alors plus lente.
+- **Longs prompts :** Strata lit en entier le premier message d'une discussion, environ 1 minute pour 30,000 tokens.
+ Les messages suivants démarrent en quelques secondes.
+
+Plus d'infos : [où sont stockées vos discussions](docs/INSTALL.md#where-things-are-stored), [l'API](docs/DETAILS.md#using-it).
+
+## Un problème ?
+
+- **Mon PC s'est figé au premier démarrage de Strata.** C'est normal pendant le chargement du modèle. Attendez, et ne
+ fermez pas la fenêtre. Toujours figé après 10 minutes ? Redémarrez le PC, fermez les autres programmes et
+ réessayez, ou choisissez une taille plus petite.
+- **Il s'est arrêté pendant le téléchargement ou l'installation.** Relancez `START-HERE.bat` (ou `./setup.sh`). Il
+ reprend là où il s'était arrêté.
+- **C'est très lent et le voyant du disque clignote sans arrêt, ou il affiche « the engine stopped unexpectedly ».**
+ Votre PC n'a pas assez de RAM libre. Fermez les autres programmes (les navigateurs en utilisent beaucoup), ou
+ choisissez une taille plus petite (Q2_0 ou IQ2_XS).
+- **Il dit que le port 8080 est déjà utilisé.** Strata tourne déjà. Cherchez sa fenêtre.
+
+Autres problèmes et leurs solutions : [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). Toujours bloqué ? Ouvrez une
+[issue](https://github.com/Niko1221/Strata/issues) et joignez `strata-.log` depuis le dossier Strata. Vous
+avez trouvé un problème de sécurité ? Signalez-le en privé : [SECURITY.md](SECURITY.md).
+
+## Comment ça marche ?
+
+Les modèles comme celui-ci tournent d'habitude sur des serveurs avec des centaines de gigaoctets de mémoire
+graphique. Votre carte graphique a 12-24 Go. Strata fait tenir le modèle en **répartissant le travail sur tout votre
+PC**. Pensez à une cuisine : ce que vous utilisez tout le temps reste sur le plan de travail, et le reste attend dans
+le placard.
+
+
+
+- **Le modèle est une équipe de 24,576 petits spécialistes (les « experts »).** Chaque mot n'en demande que 10.
+- **Votre carte graphique** garde les quelques milliers d'experts les plus utilisés. **Votre RAM** les contient tous,
+ et **votre processeur** travaille sur les autres en même temps. **Votre SSD** contient une grande table de
+ correspondance.
+
+
+
+- **Deviner, puis vérifier :** un petit assistant devine les quelques mots suivants. Le grand modèle les vérifie
+ tous d'un coup. Vous obtenez la même réponse, 1.6-1.8x plus tôt.
+- **Les longs textes sont lus par gros morceaux** (jusqu'à 8,192 tokens à la fois), à plus de 1,000 tokens par seconde.
+
+L'explication plus longue : [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md). Chaque partie et ses chiffres :
+[les détails](docs/DETAILS.md#how-it-works) et l'[article](docs/paper/Strata-Paper.pdf).
+
+## Crédits et licence
+
+Le modèle est [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next), de l'équipe Qwen. Il a été
+compressé par [ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF), UkisAI (Swift 1.5)
+et Unsloth. Strata utilise des parties de [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp). Tous les
+crédits : [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#credits). Strata est open source sous [licence MIT](LICENSE).
+Quelques parties et chaque modèle ont leur propre licence ([lesquelles](docs/HOW_IT_WORKS.md#license)).
+
+## Soutenir Strata
+
+Strata est gratuit et open source. S'il vous est utile, vous pouvez soutenir son développement :
+
+
diff --git a/README.ja.md b/README.ja.md
new file mode 100644
index 0000000000..897322fb5c
--- /dev/null
+++ b/README.ja.md
@@ -0,0 +1,210 @@
+
Downstream multi-lane fork of Niko1221/Strata.
Upstream Strata is created and maintained by Niko1221; this fork carries the GPU-per-lane / shared-arena extensions.
Run a 125-billion-parameter AI model on your own gaming PC
NVIDIA or AMD graphics card (12 GB or more) · Windows or Linux · free and open source
@@ -10,15 +12,16 @@ NVIDIA or AMD graphics card (12 GB or more) · Windows or Linux · free and open
A voxel pagoda garden, 1 shot prompt running on an RTX 5070 with Strata (IQ3_S, 128K context) ·
full video (49 s)
-Strata runs **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next)** - a large, smart AI model that
-normally needs a server - on a normal PC. It chats, writes code, reads pictures and works with your apps and coding
-agents, and nothing leaves your PC.
+Strata runs **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next)** on a normal PC. This is a
+large, smart AI model that usually needs a server. It chats, writes code, reads pictures and works with your apps
+and coding agents. Nothing leaves your PC.
## How fast is it?
-Measured on two ordinary gaming PCs. "Writes answers" is how fast the reply appears in a short chat; "reads your
-prompt" is how fast it takes in what you send (a 32K-token document, code or chat history). A token is about ¾ of a
-word, so 60 tokens per second is faster than you can read.
+We measured it on two ordinary gaming PCs. A token is about ¾ of a word.
+
+- **Writes answers:** how fast the reply appears in a short chat. 60 tokens per second is faster than you can read.
+- **Reads your prompt:** how fast it takes in what you send (here a 32K-token document, code or chat history).
NVIDIA: RTX 5070 (12 GB), Ryzen 5 7600, 64 GB RAM
AMD: RX 9070 XT (16 GB), Ryzen 9 3900X, 47 GB RAM
@@ -43,8 +46,10 @@ word, so 60 tokens per second is faster than you can read.
-A card with more VRAM is faster: an RTX 3090 (24 GB) should write roughly 100-140 tokens per second. Long chats,
-other cards: [speed of each model](docs/MODELS.md#how-fast-is-each-size), [community results](docs/COMMUNITY_BENCHMARKS.md).
+NVIDIA: Q2_0 with engine 0.1.36, the other rows with 0.1.26 (4K answers, 32K prompts). The full tables are in
+[DETAILS.md](docs/DETAILS.md#speed-measured). A card with more VRAM is faster: an RTX 3090 (24 GB) should write
+about 100-140 tokens per second. Long chats and other cards: [speed of each model](docs/MODELS.md#how-fast-is-each-size),
+[community results](docs/COMMUNITY_BENCHMARKS.md).
Strata is free. If it runs well on your PC, a coffee keeps the work on it going.
@@ -53,26 +58,33 @@ other cards: [speed of each model](docs/MODELS.md#how-fast-is-each-size), [commu
| | |
| --- | --- |
-| **Graphics card** | **NVIDIA** GeForce RTX 20, 30, 40 or 50 series, or **AMD** Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700 or RX 6800 / 6900 series - with **12 GB of VRAM or more** |
-| **RAM** | 32 GB or more - how much decides [which model](#which-model-should-i-pick) fits; 64 GB runs every size |
-| **Disk** | about 80 GB free, on an SSD if you can (the first start is much faster) |
-| **System** | Windows 10 / 11 or Linux, and a current graphics driver from NVIDIA or AMD |
+| **Graphics card** | **NVIDIA** GeForce RTX 20, 30, 40 or 50 series, or **AMD** Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700 or RX 6800 / 6900 series. It needs **12 GB of VRAM or more**. |
+| **RAM** | 32 GB or more. Your RAM decides [which model](#which-model-should-i-pick) fits. 64 GB runs every size. |
+| **Disk** | About 80 GB free. Use an SSD if you can: the first start is much faster. |
+| **System** | Windows 10 / 11 or Linux, and a current graphics driver from NVIDIA or AMD. |
+
+The installer sets up everything else. Two or three cards can share the model ([multi-GPU](docs/MULTI_GPU.md)).
+
+Experimental, written and tested by community members on their own machines:
+
+- **Older graphics cards** (Tesla P40 / V100, GTX 10, Radeon VII / MI50, RX 6700 XT, RX 5500 XT): [Older GPUs](docs/OLDER_GPUS.md).
+- **Intel Arc**, built from source on Linux: [Intel Arc](docs/INTEL_ARC.md).
+- **Older processors without AVX2**: they work, but slowly. [Older CPUs](docs/INSTALL.md#older-cpus-experimental).
-Everything else is installed for you. Two or three cards can share the model ([multi-GPU](docs/MULTI_GPU.md)).
The full list: [docs/INSTALL.md](docs/INSTALL.md#what-you-need).
## Install
### Let your AI set it up
-Use an AI coding assistant (Claude Code, Cursor, Codex, GitHub Copilot, ...)? Paste this into it:
+Do you use an AI coding assistant (Claude Code, Cursor, Codex, GitHub Copilot, ...)? Paste this into it:
```text
Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow docs/AI_SETUP.md in that repository.
```
-It checks your graphics card, RAM and disk, picks the model that fits, installs it, starts it and tells you how to
-connect your apps. AI tools can also install, start and stop Strata themselves through its
+It checks your graphics card, RAM and disk and picks the model that fits. Then it installs and starts it and tells
+you how to connect your apps. AI tools can also install, start and stop Strata through its
[MCP server](docs/MCP_SERVER.md).
### Or do it yourself
@@ -80,43 +92,51 @@ connect your apps. AI tools can also install, start and stop Strata themselves t
[Download Strata](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) and unzip it (or `git clone` it).
**Windows:** double-click **`START-HERE.bat`**. **Linux:** run **`./setup.sh`** in the Strata folder.
-The same steps for NVIDIA and AMD: the installer finds your card and sets up the right engine for it. It asks which
-model, which size, how much context (how much text it keeps in mind) and whether it should read pictures - press
-Enter each time for the recommended answer. Then it downloads the model (~70 GB; you can stop and it continues where
-it left off) and starts it. Your browser opens the Strata app at `http://127.0.0.1:8080`.
+The steps are the same for NVIDIA and AMD. The installer finds your card and sets up the right engine for it. It
+asks you a few questions:
+
+- which model and which size,
+- how much context (how much text the model keeps in mind),
+- whether it should read pictures.
+
+Press Enter each time for the recommended answer. Then it downloads the model (about 70 GB) and starts it. If the
+download stops, run it again: it continues where it left off. Your browser opens the Strata app at
+`http://127.0.0.1:8080`.
-> **While the model starts, your PC can be slow or stop responding for 1-3 minutes** (longest the first time): Strata
-> loads 35-55 GB into your RAM and locks part of it for the graphics card. That's normal - wait, and don't close the
-> window. The window tells you what it is doing.
+> **While the model starts, your PC can be slow or stop responding for 1-3 minutes** (longest the first time).
+> Strata loads 35-55 GB into your RAM and locks part of it for the graphics card. This is normal. Wait, and don't
+> close the window. The window shows what Strata is doing.
-**Next time**, run `START-HERE.bat` (or `./setup.sh`) again: it starts right away, nothing is downloaded twice. Close
+**Next time**, run `START-HERE.bat` (or `./setup.sh`) again. It starts right away and downloads nothing twice. Close
its window to stop the model. `UPDATE.bat` (`./update.sh`) updates Strata without starting it. Updating, Docker,
-several cards, where the files go and every option:
-[docs/INSTALL.md](docs/INSTALL.md).
+several cards, where the files go and every option: [docs/INSTALL.md](docs/INSTALL.md).
## Which model should I pick?
-The installer recommends one for your RAM. The same model comes in sizes that are compressed more or less: smaller
-is faster, larger is a bit smarter.
+The installer recommends one for your RAM. The same model comes in several sizes, compressed more or less. Smaller
+sizes are faster. Larger sizes are a bit smarter.
| Your RAM | Take | Why |
| --- | --- | --- |
| **32 GB** | **Coder** | it fits 32 GB, and it is made for code (with a 24 GB card, Q2_0 and IQ2_XS run too) |
| **48 GB** | **IQ2_XS** (or Q2_0, the fastest) | the larger sizes do not fit |
-| **64 GB** | **IQ2_XS** (recommended), or IQ3_XXS / IQ3_S | every size fits; IQ3_S is the best, and the slowest |
-| **96 GB or more** | **IQ3_S**, or Unsloth's 4-bit (experimental) | room for the largest sizes with everything else open |
-
-- **[Coder](docs/MODELS.md#coder)** - a coding version with half of the experts removed: 91% of the full model's
- SWE-bench Verified score (by its authors), fits 32 GB of RAM. Weaker outside code, including Chinese and other
- CJK text (#438): for those, take Q2_0, IQ2_XS or IQ3_S, which keep every expert.
-- **[Swift 1.5](docs/MODELS.md#swift-15)** - a fine-tune that thinks much shorter before it answers, so you get the
- answer sooner, at about the same quality.
-- **[Unsloth UD-Q4_K_XL](docs/MODELS.md#unsloth-ud-q4_k_xl-experimental)** (experimental) - the closest to the full
- model, but most of it is read from the SSD while it answers: 7-8.5 tokens/s on a 64 GB PC.
-- **[OrcaRouter's Uncensored IQ3_XXS](docs/MODELS.md#orcarouter-uncensored-iq3_xxs)** - a manual setup, not in the
- installer's menu.
-
-Sizes, downloads and what fits where: [docs/MODELS.md](docs/MODELS.md). You can add another model later with
+| **64 GB** | **IQ2_XS** (recommended), or IQ3_XXS / IQ3_S | every size fits; IQ3_S is the best and the slowest |
+| **96 GB or more** | **IQ3_S**, or Unsloth's UD-IQ4_XS (~4-bit) | room for the largest sizes with everything else open |
+
+- **[Coder](docs/MODELS.md#coder):** a coding version with half of the experts removed. It reaches 91% of the full
+ model's SWE-bench Verified score (measured by its authors) and fits 32 GB of RAM. It is weaker outside code,
+ including Chinese and other CJK text (#438). For those, take Q2_0, IQ2_XS or IQ3_S, which keep every expert.
+- **[Swift 1.5](docs/MODELS.md#swift-15):** a fine-tune that thinks for a much shorter time before it answers. You
+ get the answer sooner, at about the same quality.
+- **[Unsloth UD-IQ4_XS](docs/MODELS.md#unsloth-ud-iq4_xs):** Unsloth's ~4-bit version, between IQ3_S and
+ UD-Q4_K_XL in quality. A 94 GB download. With less than ~80 GB of RAM, Strata reads part of it from the SSD
+ while it answers, so it is slower there (an NVMe SSD helps).
+- **[Unsloth UD-Q4_K_XL](docs/MODELS.md#unsloth-ud-q4_k_xl-experimental)** (experimental): the closest to the full
+ model. But Strata reads most of it from the SSD while it answers, so it writes only 7-8.5 tokens/s on a 64 GB PC.
+- **[OrcaRouter's Uncensored IQ3_XXS](docs/MODELS.md#orcarouter-uncensored-iq3_xxs):** you set it up by hand. It is
+ not in the installer's menu.
+
+Sizes, downloads and what fits where: [docs/MODELS.md](docs/MODELS.md). To add another model later, run
`SETUP.bat` (Linux: `./setup.sh --setup`).
## Using it
@@ -141,61 +161,69 @@ For long-lived chats/agents, send a stable `X-Strata-Session-Id` on every turn s
The Strata app's Monitor (left) while a coding agent writes the pagoda garden from the video (right)
-- **In the browser:** `http://127.0.0.1:8080` - **Chat**, a live **Monitor** of the model and your GPU/CPU/RAM, and
- **About** with the settings and addresses.
-- **Your apps and coding agents:** add an "OpenAI-compatible" provider with base URL **`http://127.0.0.1:8080/v1`**,
- any API key and any model name. Apps that use Anthropic's API: `http://127.0.0.1:8080/v1/messages` (Claude Code:
- `ANTHROPIC_BASE_URL=http://127.0.0.1:8080`).
-- **Thinking:** choose **off, low, medium or high** in the chat menu or your app's "reasoning effort". Off is
- fastest; high is best for hard questions.
-- **Pictures:** say yes to "Images?" in setup, then click **Picture** in the chat, or attach them in your app
- (AMD cards: on Linux through the processor, not on Windows yet).
-- **From your phone or another PC:** `START-HERE.bat --setup --host 0.0.0.0 --api-key ` - always with a key.
-- **Good to know:** the ordinary single-engine server answers one generation request at a time. In **Strata-Lanes multi-lane mode**, each lane still runs one active generation, while separate lanes can serve separate requests concurrently. The first message of a chat is read in full; follow-ups can reuse lane-local state when session affinity is preserved.
+- **In the browser:** open `http://127.0.0.1:8080`. It has **Chat**, a live **Monitor** of the model and your
+ GPU/CPU/RAM, and **About** with the settings and addresses.
+- **Your apps and coding agents:** add an "OpenAI-compatible" provider with the base URL
+ **`http://127.0.0.1:8080/v1`**. Any API key and any model name work.
+ - Apps that use Anthropic's API: `http://127.0.0.1:8080/v1/messages` (Claude Code:
+ `ANTHROPIC_BASE_URL=http://127.0.0.1:8080`).
+ - Codex CLI and other apps that use the OpenAI Responses API: `/v1/responses`
+ ([setup](docs/DETAILS.md#the-responses-api-and-codex-cli)).
+- **Thinking:** choose **off, low, medium or high** in the chat menu or in your app's "reasoning effort". Off is the
+ fastest. High is best for hard questions.
+- **Pictures:** say yes to "Images?" in setup. Then click **Picture** in the chat, or attach pictures in your app.
+ AMD cards read pictures on Linux through the processor; on Windows they can't yet.
+- **From your phone or another PC:** `START-HERE.bat --setup --host 0.0.0.0 --api-key `. Always set a key.
+- **Concurrency:** the ordinary upstream-style single-engine server answers one request at a time by default; it can
+ opt into `"parallel": 2` ([BATCHING.md](docs/BATCHING.md)). In **Strata-Lanes multi-lane mode**, each ordinary
+ lane remains an independent whole-GPU engine by default, while separate lanes serve separate requests concurrently.
+- **Long prompts:** Strata reads the first message of a chat in full, about 1 minute per 30,000 tokens. Follow-up
+ messages start in seconds when their lane-local state can be reused.
More: [where your chats are stored](docs/INSTALL.md#where-things-are-stored), [the API](docs/DETAILS.md#using-it).
## Something went wrong?
-- **My PC froze the first time Strata started.** Normal while it loads the model: wait, don't close the window.
- Still frozen after 10 minutes? Restart the PC, close other programs and try again, or pick a smaller size.
-- **It stopped while downloading or installing.** Run `START-HERE.bat` (or `./setup.sh`) again: it continues where
+- **My PC froze the first time Strata started.** This is normal while it loads the model. Wait, and don't close the
+ window. Still frozen after 10 minutes? Restart the PC, close other programs and try again, or pick a smaller size.
+- **It stopped while downloading or installing.** Run `START-HERE.bat` (or `./setup.sh`) again. It continues where
it stopped.
-- **It's very slow and the disk light keeps blinking, or "the engine stopped unexpectedly".** Not enough free RAM:
- close other programs (browsers use a lot), or pick a smaller size (Q2_0 or IQ2_XS).
-- **It says port 8080 is already in use.** Strata is already running - look for its window.
+- **It's very slow and the disk light keeps blinking, or it says "the engine stopped unexpectedly".** Your PC does
+ not have enough free RAM. Close other programs (browsers use a lot), or pick a smaller size (Q2_0 or IQ2_XS).
+- **It says port 8080 is already in use.** Strata is already running. Look for its window.
More problems and their fixes: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). Still stuck? Open an
-[issue](https://github.com/Niko1221/Strata/issues) and attach `strata-.log` from the Strata folder.
+[issue](https://github.com/Niko1221/Strata/issues) and attach `strata-.log` from the Strata folder. Found a
+security problem? Report it privately: [SECURITY.md](SECURITY.md).
## How does it work?
-Models like this one normally run on servers with hundreds of gigabytes of graphics memory. Your graphics card has
-12-24 GB. Strata makes it fit by **sharing the work across your whole PC** - like a kitchen, where the things you use
-all the time stay on the counter and the rest waits in the pantry.
+Models like this one usually run on servers with hundreds of gigabytes of graphics memory. Your graphics card has
+12-24 GB. Strata makes the model fit by **sharing the work across your whole PC**. Think of a kitchen: the things
+you use all the time stay on the counter, and the rest waits in the pantry.
-- **The model is a team of 24,576 small specialists ("experts"),** and each word needs only 10 of them.
-- **Your graphics card** keeps the few thousand experts that are asked most often; **your RAM** holds all of them,
+- **The model is a team of 24,576 small specialists ("experts").** Each word needs only 10 of them.
+- **Your graphics card** keeps the few thousand experts that are used most often. **Your RAM** holds all of them,
and **your processor** works on the rest at the same time. **Your SSD** holds a big lookup table.
-- **Guess, then check:** a small helper guesses the next few words and the big model checks them all at once, so
- you get the same answer, 1.6-1.8x sooner.
-- **Long texts are read in big pieces** (up to 8,192 tokens at a time): over 1,000 tokens per second.
+- **Guess, then check:** a small helper guesses the next few words. The big model checks them all at once. You get
+ the same answer, 1.6-1.8x sooner.
+- **Long texts are read in big pieces** (up to 8,192 tokens at a time), at over 1,000 tokens per second.
-The longer explanation: [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md). Every part and its numbers: [the
-details](docs/DETAILS.md#how-it-works) and the [paper](docs/paper/Strata-Paper.pdf).
+The longer explanation: [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md). Every part and its numbers:
+[the details](docs/DETAILS.md#how-it-works) and the [paper](docs/paper/Strata-Paper.pdf).
## Credits and license
-The model is [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team, compressed by
-[ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF), UkisAI (Swift 1.5) and Unsloth;
-Strata is built with parts of [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp). All credits:
-[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#credits). Strata is open source under the [MIT License](LICENSE); a few
-parts and every model carry their own licenses ([which ones](docs/HOW_IT_WORKS.md#license)).
+The model is [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team. It was
+compressed by [ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF), UkisAI (Swift 1.5)
+and Unsloth. Strata uses parts of [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp). All credits:
+[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#credits). Strata is open source under the [MIT License](LICENSE). A few
+parts and every model have their own licenses ([which ones](docs/HOW_IT_WORKS.md#license)).
## Support Strata
diff --git a/README.pt-BR.md b/README.pt-BR.md
new file mode 100644
index 0000000000..a34dbf2f50
--- /dev/null
+++ b/README.pt-BR.md
@@ -0,0 +1,216 @@
+
Rode um modelo de IA de 125 bilhões de parâmetros no seu próprio PC gamer
+Placa de vídeo NVIDIA ou AMD (12 GB ou mais) · Windows ou Linux · gratuito e de código aberto
+
+
+Um jardim com pagode em voxels, feito com um único prompt numa RTX 5070 com o Strata (IQ3_S, contexto de 128K) ·
+vídeo completo (49 s)
+
+O Strata roda o **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next)** num PC comum. É um modelo
+de IA grande e inteligente que normalmente precisa de um servidor. Ele conversa, escreve código, entende imagens e
+funciona com seus apps e agentes de programação. Nada sai do seu PC.
+
+## Qual é a velocidade?
+
+Medimos em dois PCs gamer comuns. Um token equivale a cerca de ¾ de uma palavra.
+
+- **Escreve respostas:** a velocidade com que a resposta aparece numa conversa curta. 60 tokens por segundo é mais rápido do que você consegue ler.
+- **Lê seu prompt:** a velocidade com que ele absorve o que você envia (aqui, um documento, código ou histórico de conversa de 32K tokens).
+
+
+
+NVIDIA: Q2_0 com o engine 0.1.36, as outras linhas com 0.1.26 (respostas de 4K, prompts de 32K). As tabelas
+completas estão em [DETAILS.md](docs/DETAILS.md#speed-measured). Uma placa com mais VRAM é mais rápida: uma
+RTX 3090 (24 GB) deve escrever cerca de 100-140 tokens por segundo. Conversas longas e outras placas:
+[velocidade de cada modelo](docs/MODELS.md#how-fast-is-each-size), [resultados da comunidade](docs/COMMUNITY_BENCHMARKS.md).
+
+
+O Strata é gratuito. Se ele roda bem no seu PC, um café ajuda a manter o trabalho nele.
+
+## Do que você precisa
+
+| | |
+| --- | --- |
+| **Placa de vídeo** | **NVIDIA** GeForce RTX séries 20, 30, 40 ou 50, ou **AMD** Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700 ou séries RX 6800 / 6900. Ela precisa de **12 GB de VRAM ou mais**. |
+| **RAM** | 32 GB ou mais. Sua RAM decide [qual modelo](#qual-modelo-devo-escolher) cabe. Com 64 GB, roda qualquer tamanho. |
+| **Disco** | Cerca de 80 GB livres. Use um SSD se puder: a primeira inicialização fica bem mais rápida. |
+| **Sistema** | Windows 10 / 11 ou Linux, e um driver de vídeo atualizado da NVIDIA ou da AMD. |
+
+O instalador cuida de todo o resto. Duas ou três placas podem dividir o modelo ([multi-GPU](docs/MULTI_GPU.md)).
+
+Experimental, escrito e testado por membros da comunidade nas próprias máquinas:
+
+- **Placas de vídeo mais antigas** (Tesla P40 / V100, GTX 10, Radeon VII / MI50, RX 6700 XT, RX 5500 XT): [Older GPUs](docs/OLDER_GPUS.md).
+- **Intel Arc**, compilado a partir do código-fonte no Linux: [Intel Arc](docs/INTEL_ARC.md).
+- **Processadores mais antigos sem AVX2**: funcionam, mas devagar. [Older CPUs](docs/INSTALL.md#older-cpus-experimental).
+
+A lista completa: [docs/INSTALL.md](docs/INSTALL.md#what-you-need).
+
+## Instalação
+
+### Deixe sua IA instalar
+
+Você usa um assistente de programação com IA (Claude Code, Cursor, Codex, GitHub Copilot, ...)? Cole isto nele:
+
+```text
+Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow docs/AI_SETUP.md in that repository.
+```
+
+Ele verifica sua placa de vídeo, RAM e disco e escolhe o modelo que cabe. Depois instala, inicia e explica como
+conectar seus apps. Ferramentas de IA também podem instalar, iniciar e parar o Strata pelo
+[servidor MCP](docs/MCP_SERVER.md) dele.
+
+### Ou faça você mesmo
+
+[Baixe o Strata](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) e descompacte (ou use `git clone`).
+**Windows:** clique duas vezes em **`START-HERE.bat`**. **Linux:** rode **`./setup.sh`** na pasta do Strata.
+
+Os passos são os mesmos para NVIDIA e AMD. O instalador encontra sua placa e configura o engine certo para ela.
+Ele faz algumas perguntas:
+
+- qual modelo e qual tamanho,
+- quanto contexto (quanto texto o modelo guarda na memória),
+- se ele deve entender imagens.
+
+Aperte Enter em cada uma para usar a resposta recomendada. Depois ele baixa o modelo (cerca de 70 GB) e o inicia.
+Se o download parar, rode de novo: ele continua de onde parou. Seu navegador abre o app do Strata em
+`http://127.0.0.1:8080`.
+
+> **Enquanto o modelo inicia, seu PC pode ficar lento ou parar de responder por 1-3 minutos** (mais na primeira vez).
+> O Strata carrega 35-55 GB na sua RAM e reserva parte dela para a placa de vídeo. Isso é normal. Espere e não
+> feche a janela. A janela mostra o que o Strata está fazendo.
+
+**Da próxima vez**, rode `START-HERE.bat` (ou `./setup.sh`) de novo. Ele inicia na hora e não baixa nada duas
+vezes. Feche a janela para parar o modelo. `UPDATE.bat` (`./update.sh`) atualiza o Strata sem iniciá-lo.
+Atualização, Docker, várias placas, onde ficam os arquivos e todas as opções: [docs/INSTALL.md](docs/INSTALL.md).
+
+## Qual modelo devo escolher?
+
+O instalador recomenda um de acordo com sua RAM. O mesmo modelo vem em vários tamanhos, mais ou menos comprimidos.
+Os tamanhos menores são mais rápidos. Os maiores são um pouco mais inteligentes.
+
+| Sua RAM | Escolha | Por quê |
+| --- | --- | --- |
+| **32 GB** | **Coder** | cabe em 32 GB e é feito para código (com uma placa de 24 GB, Q2_0 e IQ2_XS também rodam) |
+| **48 GB** | **IQ2_XS** (ou Q2_0, o mais rápido) | os tamanhos maiores não cabem |
+| **64 GB** | **IQ2_XS** (recomendado), ou IQ3_XXS / IQ3_S | todos os tamanhos cabem; IQ3_S é o melhor e o mais lento |
+| **96 GB ou mais** | **IQ3_S**, ou o UD-IQ4_XS da Unsloth (~4-bit) | espaço para os maiores tamanhos com todo o resto aberto |
+
+- **[Coder](docs/MODELS.md#coder):** uma versão para programação com metade dos experts removida. Ela alcança 91%
+ da pontuação do modelo completo no SWE-bench Verified (medido pelos autores) e cabe em 32 GB de RAM. É mais fraca
+ fora do código, inclusive em chinês e outros textos CJK (#438). Para isso, escolha Q2_0, IQ2_XS ou IQ3_S, que
+ mantêm todos os experts.
+- **[Swift 1.5](docs/MODELS.md#swift-15):** um fine-tune que pensa por bem menos tempo antes de responder. Você
+ recebe a resposta mais cedo, com quase a mesma qualidade.
+- **[Unsloth UD-IQ4_XS](docs/MODELS.md#unsloth-ud-iq4_xs):** a versão de ~4 bits da Unsloth, entre IQ3_S e
+ UD-Q4_K_XL em qualidade. Um download de 94 GB. Com menos de ~80 GB de RAM, o Strata lê uma parte dele do SSD
+ enquanto responde, então ali ele fica mais lento (um SSD NVMe ajuda).
+- **[Unsloth UD-Q4_K_XL](docs/MODELS.md#unsloth-ud-q4_k_xl-experimental)** (experimental): o mais próximo do modelo
+ completo. Mas o Strata lê a maior parte dele do SSD enquanto responde, então ele escreve só 7-8.5 tokens/s num PC
+ com 64 GB.
+- **[OrcaRouter's Uncensored IQ3_XXS](docs/MODELS.md#orcarouter-uncensored-iq3_xxs):** você configura à mão. Ele
+ não está no menu do instalador.
+
+Tamanhos, downloads e o que cabe onde: [docs/MODELS.md](docs/MODELS.md). Para adicionar outro modelo depois, rode
+`SETUP.bat` (Linux: `./setup.sh --setup`).
+
+## Como usar
+
+
+O Monitor do app Strata (à esquerda) enquanto um agente de programação escreve o jardim com pagode do vídeo (à direita)
+
+- **No navegador:** abra `http://127.0.0.1:8080`. Lá tem o **Chat**, um **Monitor** ao vivo do modelo e da sua
+ GPU/CPU/RAM, e o **About** com as configurações e os endereços.
+- **Seus apps e agentes de programação:** adicione um provedor "OpenAI-compatible" com a URL base
+ **`http://127.0.0.1:8080/v1`**. Qualquer chave de API e qualquer nome de modelo funcionam.
+ - Apps que usam a API da Anthropic: `http://127.0.0.1:8080/v1/messages` (Claude Code:
+ `ANTHROPIC_BASE_URL=http://127.0.0.1:8080`).
+ - Codex CLI e outros apps que usam a OpenAI Responses API: `/v1/responses`
+ ([configuração](docs/DETAILS.md#the-responses-api-and-codex-cli)).
+- **Raciocínio:** escolha **off, low, medium ou high** no menu do chat ou no "reasoning effort" do seu app. Off é o
+ mais rápido. High é o melhor para perguntas difíceis.
+- **Imagens:** responda sim para "Images?" na instalação. Depois clique em **Picture** no chat, ou anexe imagens no
+ seu app. Placas AMD entendem imagens no Linux usando o processador; no Windows, ainda não.
+- **Do seu celular ou de outro PC:** `START-HERE.bat --setup --host 0.0.0.0 --api-key `. Sempre defina uma chave.
+- **Uma requisição por vez:** por padrão, o Strata responde uma requisição e as outras esperam. Para responder
+ várias ao mesmo tempo, defina `"parallel": 2` ([BATCHING.md](docs/BATCHING.md)). Numa placa de 12 GB, isso deixa
+ cada resposta mais lenta.
+- **Prompts longos:** o Strata lê a primeira mensagem de uma conversa inteira, cerca de 1 minuto a cada 30,000
+ tokens. As mensagens seguintes começam em segundos.
+
+Mais: [onde suas conversas ficam salvas](docs/INSTALL.md#where-things-are-stored), [a API](docs/DETAILS.md#using-it).
+
+## Algo deu errado?
+
+- **Meu PC travou na primeira vez que o Strata iniciou.** Isso é normal enquanto ele carrega o modelo. Espere e não
+ feche a janela. Ainda travado depois de 10 minutos? Reinicie o PC, feche outros programas e tente de novo, ou
+ escolha um tamanho menor.
+- **Parou durante o download ou a instalação.** Rode `START-HERE.bat` (ou `./setup.sh`) de novo. Ele continua de
+ onde parou.
+- **Está muito lento e a luz do disco não para de piscar, ou aparece "the engine stopped unexpectedly".** Seu PC
+ não tem RAM livre suficiente. Feche outros programas (navegadores usam muita), ou escolha um tamanho menor (Q2_0
+ ou IQ2_XS).
+- **Diz que a porta 8080 já está em uso.** O Strata já está rodando. Procure a janela dele.
+
+Mais problemas e como resolver: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). Ainda sem solução? Abra uma
+[issue](https://github.com/Niko1221/Strata/issues) e anexe o `strata-.log` da pasta do Strata. Encontrou um
+problema de segurança? Informe em particular: [SECURITY.md](SECURITY.md).
+
+## Como funciona?
+
+Modelos como este normalmente rodam em servidores com centenas de gigabytes de memória de vídeo. Sua placa de vídeo
+tem 12-24 GB. O Strata faz o modelo caber **dividindo o trabalho pelo PC inteiro**. Pense numa cozinha: o que você
+usa o tempo todo fica na bancada, e o resto espera na despensa.
+
+
+
+- **O modelo é uma equipe de 24,576 pequenos especialistas ("experts").** Cada palavra precisa de só 10 deles.
+- **Sua placa de vídeo** guarda os poucos milhares de experts usados com mais frequência. **Sua RAM** guarda todos
+ eles, e **seu processador** trabalha no resto ao mesmo tempo. **Seu SSD** guarda uma grande tabela de consulta.
+
+
+
+- **Adivinhar e conferir:** um pequeno ajudante adivinha as próximas palavras. O modelo grande confere todas de
+ uma vez. Você recebe a mesma resposta, 1.6-1.8x mais cedo.
+- **Textos longos são lidos em grandes pedaços** (até 8,192 tokens por vez), a mais de 1,000 tokens por segundo.
+
+A explicação mais longa: [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md). Cada parte e seus números:
+[os detalhes](docs/DETAILS.md#how-it-works) e o [artigo](docs/paper/Strata-Paper.pdf).
+
+## Créditos e licença
+
+O modelo é o [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next), da equipe Qwen. Ele foi
+comprimido por [ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF), UkisAI (Swift 1.5)
+e Unsloth. O Strata usa partes do [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp). Todos os créditos:
+[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#credits). O Strata é de código aberto sob a [MIT License](LICENSE).
+Algumas partes e todos os modelos têm licenças próprias ([quais](docs/HOW_IT_WORKS.md#license)).
+
+## Apoie o Strata
+
+O Strata é gratuito e de código aberto. Se ele é útil para você, você pode apoiar o desenvolvimento:
+
+
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000000..6eb1220dd1
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,199 @@
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000000..94be4eadee
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,44 @@
+# Security
+
+## Reporting a vulnerability
+
+Please report anything that should not be public until it is fixed **privately**, through GitHub's private
+vulnerability reporting: the repository's **Security** tab, **Report a vulnerability**
+([direct link](https://github.com/Niko1221/Strata/security/advisories/new)). Say what you found, how to reproduce it
+(the request, the config keys involved, the Strata version), and what an attacker gains. We answer there, fix it in
+a release, and credit you in the advisory unless you ask us not to.
+
+Ordinary hardening ideas and findings that are safe to discuss in public are welcome as an
+[issue](https://github.com/Niko1221/Strata/issues) (#544 collects audit findings) or as a pull request, which we
+review like our own code.
+
+Supported: the latest release. Fixes go into the next release, not into older ones.
+
+## What the server exposes
+
+Strata runs a model on your PC and serves it over HTTP (`serve/server.py`). Out of the box it is reachable from
+this PC only. The details and every setting are in [docs/DETAILS.md](docs/DETAILS.md) ("From other devices",
+"Host names", "Web pages without an API key", "Tools from MCP servers").
+
+- **Where it listens.** `127.0.0.1` by default. `--host 0.0.0.0` (or `"host"` in `strata-.json`) opens it to
+ your network, and the server then warns when no API key is set.
+- **API key.** `"api_key"` in the run config (or `STRATA_API_KEY`) is required on `/v1/*` and on every endpoint
+ that shows the model's state, requests or answers (`/status`, `/metrics`, `/settings`, `/mcp`, `/props`, `/slots`,
+ `/api/requests`, `/config`) and on every `POST`. It is compared in constant time. Set one before you open the
+ server to your network or put a tunnel in front of it.
+- **Host check (DNS rebinding, 0.1.38).** Without an API key the server answers only requests whose `Host` is a name
+ it knows (`localhost`, an IP address, the address it listens on and, when it listens beyond this PC, this PC's
+ name). Others get 403. `"allowed_hosts"` adds names; with an API key the key decides.
+- **Origin check (0.1.38).** Without an API key, a browser `POST` to `/v1/*` from another web site's page (it carries
+ an `Origin` header) gets 403 unless that origin is Strata's own page, `localhost`, an allowed host, or listed in
+ `"trusted_origins"` / `"cors_origins"`. Changing settings (`/settings`, and `/config`: the few run config keys the
+ page's Model settings may change - never the network, key, MCP or program keys), `/load` and `/unload` and the
+ MCP tools are accepted only as JSON from Strata's own page (or `"trusted_origins"`), so a page elsewhere cannot
+ change settings or run tools.
+- **CORS** is off unless `"cors_origins"` lists origins, and then only for `/v1/*`.
+- **MCP tools** are opt-in: only the servers you put in the run config, only for requests from Strata's own page
+ that ask for them. They run with your user's rights, and the model decides when to call them.
+- **The request monitor** (`/api-monitor`, which keeps the last prompts and answers in memory) is off unless
+ `"api_monitor": true` is set.
+
+Strata has not had an outside security audit yet.
diff --git a/bench/results/2026-10-03-community-2x-mi50/README.md b/bench/results/2026-10-03-community-2x-mi50/README.md
new file mode 100644
index 0000000000..8d0301fba0
--- /dev/null
+++ b/bench/results/2026-10-03-community-2x-mi50/README.md
@@ -0,0 +1,121 @@
+# Community benchmark: 2x AMD Instinct MI50 16 GB (gfx906), Xeon E5-2666 v3
+
+Measured on 2026-10-03 by [JeanP00l](https://github.com/JeanP00l) on a Linux server. This tests the Coder
+(IQ1_M) with a 131,072-token context, the layer split across two MI50 cards, and the gfx906 build of #638. That
+build is an opt-in HIP build, separate from the wave32 RDNA backend.
+
+The median decode throughput was **50.1 tok/s at 4,096 prompt tokens, 47.8 tok/s at 32,768 and 45.7 tok/s at
+128,000**. Prompts were read at 321, 523 and 517 tok/s. The requests are synthetic code-explanation requests
+with greedy decoding and a 256-token output cap. They do not establish general answer quality or performance on
+other workloads.
+
+## Hardware and software
+
+- **GPUs:** 2x AMD Instinct MI50 16 GB (Vega 20, gfx906, wave64; HIP names them "AMD Radeon VII"). Power limit
+ 85 W per card, set by the owner (the cards' own limit is higher). PCIe 3.0 x16 on both. Peer access between them
+ works, through the CPU's root complex (no direct bridge). Blower fans; no clock locking beyond the power limit.
+ The engine's PCIe probe measured 13.8 GB/s host to device.
+- **CPU and RAM:** Intel Xeon E5-2666 v3 (10 cores, 20 threads, AVX2, no AVX-512); the engine used 9 expert-pool
+ workers plus its host thread. 32 GB DDR4 quad-channel (Linux reports 31.2 GiB). 8 GiB swap file. One SATA SSD.
+- **OS and runtime:** Ubuntu 24.04.4 LTS, kernel 6.8.0-138-generic, the kernel's amdgpu driver. HIP 7.14 from the
+ community image `mixa3607/rocm-gfx906:7.14-complete` (AMD's current ROCm no longer ships gfx906 libraries).
+- **Source:** [`mi50-bench-build`](https://github.com/JeanP00l/Strata/tree/mi50-bench-build) at `ea51fd3`, which is
+ `main` 99f3dbd (engine 0.1.38) with #638 (at 17beb33), #639, #640 and #637 merged. #638's later commits
+ (a914b13) only fix the CUDA/RDNA compile and do not change the gfx906 binary's behaviour.
+- **Build:** `-DSTRATA_HIP_GFX906=ON -DCMAKE_HIP_ARCHITECTURES=gfx906 -DSTRATA_PORTABLE=ON`, ROCm clang, Ninja,
+ llama.cpp at the pinned 3cf0325.
+- **Other services:** the machine's other GPU services were stopped, so the two cards ran only Strata. CPU services
+ (a database, a sync daemon) kept running. This was not a fully isolated operating system.
+
+## Model and configuration
+
+**Model:** [`ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF`](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF),
+IQ1_M. The repository revision was not recorded; the shard SHA-256 hashes matched the `.sha256` files downloaded
+with them:
+
+- `Qwen3.8-Flash-Next-GSQ-RCO-IQ1_M-00001-of-00002.gguf` `e11083ba855e7666b48ea3f2db6a9c3a20c18751a012cc24f948de91b7087fad`
+- `Qwen3.8-Flash-Next-GSQ-RCO-IQ1_M-00002-of-00002.gguf` `316b46f3a2dbd68c900f43136ab9449f9dcc3725dfd8c794847c204bc161e113`
+
+The native pack and the MTP draft layer were prepared with the repository's tools on 2026-09-29. The expert profile is the bundled
+`data/expert-profile-coder.bin`.
+
+**Configuration** ([strata-coder.json](strata-coder.json); `/m` is the model directory, `/work` the build):
+
+- context 131,072, `--kv int8 --kv-resident 32768` (32,768 KV cells per attention layer in VRAM, the rest streamed
+ from RAM);
+- `layer_split 27`: layers 0-26 on GPU 0, 27-47 on GPU 1. With #639 each GPU loads only its own layers' dense
+ weights;
+- expert cache `auto`, `--vram-reserve-mib 600`: 6,912 + 5,376 slots = **all 12,288 experts in VRAM**, prefilled
+ from the profile, no eviction;
+- `--prefill 4096`, with the prompt path borrowing 1,193 + 1,084 cache slots;
+- MTP `--spec 4 --spec-min-p 0.5`; suffix drafting on (the default);
+- `--pcie-frac 0`, required with `STRATA_ARENA_MMAP=1` (#640, the expert arena as a read-only mapped file; 23.4
+ GiB of VRAM-held experts handed back to the OS at start);
+- no vision, no calibration, no speed projection, no control vectors;
+- reasoning off (`reasoning_effort: none`), temperature 0, 256 generated tokens per speed run.
+
+## Method
+
+[benchmark.py](benchmark.py) is the script of the
+[RTX 5090 report](../2026-09-30-community-rtx-5090/README.md), unchanged. It builds deterministic synthetic Python
+filler, puts a different nonce near the start of each request, and counts the rendered chat prompt with Strata's
+tokenizer. Its request hashes and the output text of every run are in [results.json](results.json); the aggregates
+are in [summary.json](summary.json).
+
+The server ran in a container with both cards (the repository's `serve/` and `tools/` from the source above):
+
+```bash
+docker run --device=/dev/kfd --device=/dev/dri --group-add video --ipc=host --ulimit memlock=-1 \
+ -e HSA_OVERRIDE_GFX_VERSION=9.0.6 -e STRATA_ARENA_MMAP=1 ... \
+ python3 serve/server.py --engine strata --config strata-coder.json --port 8080
+python3 benchmark.py --root /opt/strata --pack /m/pack-coder --url http://127.0.0.1:8080 --out results/
+python3 tools/needle_bench.py --url http://127.0.0.1:8080 --lengths 32k,128k --depths 10,50,90 --out needles.json
+```
+
+- One short warm-up request is excluded.
+- Three runs at each length ran serially, in increasing-length order, on the same loaded engine.
+- All nine speed requests read their whole prompt: **zero reused tokens**.
+- The expert cache was filled at start and kept between requests. Loading time is excluded.
+- TTFT is streaming, from just before the HTTP request to the first nonempty text delta, over loopback.
+- Prompt throughput is freshly read tokens / `prompt_ms`. Decode throughput is `engine_generated / decode_ms`.
+- [engine.log](engine.log) is the engine's whole log for this session, including the start-up decisions.
+
+## Results
+
+Each cell is the median **[minimum-maximum]** of three runs. Every request generated 256 tokens and stopped at
+the output limit. No speed request failed or was cancelled.
+
+| Prompt tokens | Reused | Prompt tok/s | Decode tok/s | TTFT seconds | Total seconds |
+| ---: | ---: | ---: | ---: | ---: | ---: |
+| 4,096 | 0 | 320.9 [309.9-324.7] | 50.1 [49.3-54.0] | 12.81 [12.66-13.26] | 17.90 [17.38-18.43] |
+| 32,768 | 0 | 522.8 [522.6-523.4] | 47.8 [47.6-48.5] | 62.81 [62.75-62.84] | 68.10 [68.10-68.15] |
+| 128,000 | 0 | 516.8 [516.5-516.9] | 45.7 [44.8-51.5] | 248.09 [248.06-248.27] | 253.75 [253.03-253.84] |
+
+- The decode expert-cache hit rate was 100% for every request. All experts are in VRAM; the CPU pool computes none.
+- The 4K prompt reads at about 60% of the longer ones' speed, because a 4,096-token chunk pays the fixed per-chunk
+ cost once.
+- Draft acceptance varies between runs (decode 44.8-51.5 tok/s at 128K) although the text is greedy.
+
+**Memory**, from [telemetry.jsonl](telemetry.jsonl) (1,699 one-second samples over 1,763 s, from start through
+the recall checks):
+
+- VRAM peak: 15.42 GiB on GPU 0 and 14.70 GiB on GPU 1 (`mem_info_vram_used`, the whole card).
+- RAM: `MemAvailable` never below **25.1 GiB** of 31.2.
+- Swap: no growth during the run. The swap file held ~1.3 GiB from earlier use before the start.
+
+These are sampled values; brief peaks between samples can be missed.
+
+## Recall and limitations
+
+The repository's unchanged `tools/needle_bench.py` found **all six needles**, at depths 10%, 50% and 90% at both
+lengths ([needles.json](needles.json)):
+
+- `32k` prompts were 25,776-25,777 tokens and `128k` prompts 103,725-103,727 tokens.
+- The last 128K case reused 49,785 prompt tokens; the other five reused none.
+
+**Limits of this report:**
+- One machine, one quantization, one configuration, and a small synthetic workload.
+- Long output, sampled decoding, thinking, coding-task correctness, vision, tool use, concurrency and a sustained
+ thermal run were not part of this report.
+- For context, on the same machine and model, llama.cpp (3cf0325, ROCm) measured 26.9 tok/s `tg128` on 2026-09-29.
+ That is a different workload, not a controlled comparison.
diff --git a/bench/results/2026-10-03-community-2x-mi50/benchmark.py b/bench/results/2026-10-03-community-2x-mi50/benchmark.py
new file mode 100644
index 0000000000..cdb6e4cc06
--- /dev/null
+++ b/bench/results/2026-10-03-community-2x-mi50/benchmark.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""Serial, fresh-prompt Strata benchmark. Run inside its prepared environment."""
+import argparse
+import hashlib
+import json
+import statistics
+import sys
+import time
+import urllib.request
+from pathlib import Path
+
+
+def get(url):
+ with urllib.request.urlopen(url, timeout=30) as response:
+ return json.load(response)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, default=Path("/workspace/Strata"))
+ parser.add_argument("--pack", type=Path, default=Path("/workspace/Strata-data/packs/iq2_xs"))
+ parser.add_argument("--url", default="http://127.0.0.1:18080")
+ parser.add_argument("--out", type=Path, default=Path("/workspace/results"))
+ parser.add_argument("--targets", default="4096,32768,128000")
+ parser.add_argument("--runs", type=int, default=3)
+ args = parser.parse_args()
+ args.out.mkdir(parents=True, exist_ok=True)
+ sys.path[:0] = [str(args.root), str(args.root / "tools")]
+ from strata_tokenizer import Tokenizer
+ from serve.frontend import ChatTemplate, openai_to_messages
+ directory = args.pack / "tokenizer"
+ vocab = json.loads((directory / "vocab.json").read_text())
+ tokens = [None] * len(vocab)
+ for token, number in vocab.items():
+ tokens[number] = token
+ tokenizer = Tokenizer(tokens, (directory / "merges.txt").read_text().splitlines(),
+ json.loads((directory / "token_type.json").read_text()))
+ template = ChatTemplate(directory / "chat_template.jinja")
+ initial = get(args.url + "/v1/status")
+ (args.out / "initial-status.json").write_text(json.dumps(initial, indent=2) + "\n")
+ rows = []
+
+ def request(content, maximum=256):
+ return {"model": "strata", "messages": [{"role": "user", "content": content}],
+ "temperature": 0, "reasoning_effort": "none", "max_tokens": maximum,
+ "stream": True, "stream_options": {"include_usage": True}}
+
+ def count(req):
+ messages, tools, kwargs = openai_to_messages(req)
+ return len(tokenizer.encode(template.render(messages, tools, **kwargs), parse_special=True))
+
+ def perform(label, req, expected=None):
+ body = json.dumps(req).encode()
+ (args.out / (label + "-request.json")).write_bytes(body + b"\n")
+ print("start", label, "tokens", expected, flush=True)
+ chunks, texts, first, finish = [], [], None, None
+ started = time.perf_counter()
+ wire = urllib.request.Request(args.url + "/v1/chat/completions", data=body,
+ headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(wire, timeout=1800) as response:
+ for line in response:
+ if not line.startswith(b"data: "):
+ continue
+ payload = line[6:].strip()
+ if payload == b"[DONE]":
+ break
+ chunk = json.loads(payload)
+ chunks.append(chunk)
+ for choice in chunk.get("choices", []):
+ delta = choice.get("delta", {})
+ text = (delta.get("content") or "") + (delta.get("reasoning_content") or "")
+ if text:
+ first = first if first is not None else time.perf_counter() - started
+ texts.append(text)
+ finish = choice.get("finish_reason") or finish
+ elapsed = time.perf_counter() - started
+ metrics = get(args.url + "/metrics")
+ engine = metrics["requests"][0]
+ usage = next((chunk["usage"] for chunk in reversed(chunks) if "usage" in chunk), {})
+ row = {"label": label, "expected_prompt_tokens": expected,
+ "request_sha256": hashlib.sha256(body).hexdigest(),
+ "client_ttft_s": first, "client_elapsed_s": elapsed, "finish_reason": finish,
+ "usage": usage, "engine": engine, "text": "".join(texts)}
+ if expected is not None and engine["prompt_tokens"] != expected:
+ raise RuntimeError(f"token count mismatch: {expected} vs {engine['prompt_tokens']}")
+ if not texts:
+ raise RuntimeError(f"no text received for {label}")
+ (args.out / (label + "-raw.json")).write_text(json.dumps({"chunks": chunks, "row": row}, indent=2) + "\n")
+ rows.append(row)
+ (args.out / "results.json").write_text(json.dumps(rows, indent=2) + "\n")
+ print("done", label, json.dumps({"ttft_s": first, "elapsed_s": elapsed, "engine": engine}), flush=True)
+ return row
+
+ perform("warmup", request("Reply with exactly the word READY.", 16))
+ # Each nonce occurs before the filler so previous long prompts cannot be reused.
+ filler = "\n".join(f"def task_{i:05d}(value: int) -> int: return (value * {(i % 97) + 1} + {i}) % 100003"
+ for i in range(12000))
+ ending = ("\n\nWrite a detailed explanation of the code above. Discuss deterministic integer transforms, "
+ "modulo arithmetic, testing, naming, complexity, and maintainability. Write at least 600 words.")
+ for target in map(int, args.targets.split(",")):
+ for run in range(1, args.runs + 1):
+ prefix = f"Benchmark nonce: series-{target}-trial-{run}.\nReview this synthetic Python module:\n"
+ lo, hi = 0, len(filler)
+ while lo < hi:
+ middle = (lo + hi + 1) // 2
+ if count(request(prefix + filler[:middle] + ending)) <= target:
+ lo = middle
+ else:
+ hi = middle - 1
+ req = request(prefix + filler[:lo] + ending)
+ actual = count(req)
+ if actual < target - 20 or actual > target:
+ raise RuntimeError(f"could not construct target {target}: {actual}")
+ perform(f"tokens-{target}-run-{run}", req, actual)
+ measured = [row for row in rows if row["label"] != "warmup"]
+ groups = {}
+ for target in map(int, args.targets.split(",")):
+ subset = [r for r in measured if r["label"].startswith(f"tokens-{target}-")]
+ values = {
+ "prompt_tokens": [r["engine"]["prompt_tokens"] for r in subset],
+ "cached_tokens": [r["engine"].get("reused", 0) for r in subset],
+ "output_tokens": [r["engine"]["engine_generated"] for r in subset],
+ "client_ttft_s": [r["client_ttft_s"] for r in subset],
+ "client_elapsed_s": [r["client_elapsed_s"] for r in subset],
+ "prefill_tok_s": [(r["engine"]["prompt_tokens"] - (r["engine"].get("reused") or 0)) / (r["engine"]["prompt_ms"] / 1000) for r in subset],
+ "decode_tok_s": [r["engine"]["engine_generated"] / (r["engine"]["decode_ms"] / 1000) for r in subset],
+ }
+ groups[str(target)] = {key: {"median": statistics.median(v), "min": min(v), "max": max(v)}
+ for key, v in values.items()}
+ (args.out / "summary.json").write_text(json.dumps(groups, indent=2) + "\n")
+ print("summary", json.dumps(groups), flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bench/results/2026-10-03-community-2x-mi50/initial-status.json b/bench/results/2026-10-03-community-2x-mi50/initial-status.json
new file mode 100644
index 0000000000..fd96e13c05
--- /dev/null
+++ b/bench/results/2026-10-03-community-2x-mi50/initial-status.json
@@ -0,0 +1,50 @@
+{
+ "service": "strata",
+ "model": "qwen3.8-flash-next-coder",
+ "loaded": true,
+ "auto_load": true,
+ "structured_output": {
+ "formats": [
+ "json_object",
+ "json_schema"
+ ],
+ "method": "prompt_and_validate",
+ "constrained_decoding": false,
+ "stream_buffered": true
+ },
+ "engine": "0.1.38",
+ "started": 1791055531,
+ "uptime_s": 2,
+ "cache_max_tokens": 131072,
+ "context": {
+ "native": 131072,
+ "max_positions": 131072
+ },
+ "concurrency": {
+ "serving": 1,
+ "requested": 1
+ },
+ "dialects": [
+ "/v1/chat/completions",
+ "/v1/messages"
+ ],
+ "vision": {
+ "enabled": false,
+ "available": false,
+ "error": null
+ },
+ "activity": {
+ "requests": 0,
+ "in_flight": 0,
+ "last_request_at": null
+ },
+ "last_timings": null,
+ "machine": {
+ "at": 1791055534,
+ "gpu": null,
+ "ram": {
+ "used_gib": 4.9,
+ "total_gib": 31.2
+ }
+ }
+}
diff --git a/bench/results/2026-10-03-community-2x-mi50/needles.json b/bench/results/2026-10-03-community-2x-mi50/needles.json
new file mode 100644
index 0000000000..0eda889604
--- /dev/null
+++ b/bench/results/2026-10-03-community-2x-mi50/needles.json
@@ -0,0 +1,56 @@
+[
+ {
+ "length": "32k",
+ "depth": 10,
+ "prompt_tokens": 25776,
+ "seconds": 52.6,
+ "found": true,
+ "word": "meadow-copper-504",
+ "answer": "meadow-copper-504"
+ },
+ {
+ "length": "32k",
+ "depth": 50,
+ "prompt_tokens": 25776,
+ "seconds": 50.7,
+ "found": true,
+ "word": "falcon-quartz-940",
+ "answer": "falcon-quartz-940"
+ },
+ {
+ "length": "32k",
+ "depth": 90,
+ "prompt_tokens": 25777,
+ "seconds": 50.8,
+ "found": true,
+ "word": "willow-cobalt-696",
+ "answer": "willow-cobalt-696"
+ },
+ {
+ "length": "128k",
+ "depth": 10,
+ "prompt_tokens": 103727,
+ "seconds": 206.9,
+ "found": true,
+ "word": "falcon-saffron-138",
+ "answer": "falcon-saffron-138"
+ },
+ {
+ "length": "128k",
+ "depth": 50,
+ "prompt_tokens": 103725,
+ "seconds": 205.9,
+ "found": true,
+ "word": "quartz-tundra-528",
+ "answer": "quartz-tundra-528"
+ },
+ {
+ "length": "128k",
+ "depth": 90,
+ "prompt_tokens": 103725,
+ "seconds": 112.5,
+ "found": true,
+ "word": "quartz-glacier-192",
+ "answer": "quartz-glacier-192"
+ }
+]
\ No newline at end of file
diff --git a/bench/results/2026-10-03-community-2x-mi50/results.json b/bench/results/2026-10-03-community-2x-mi50/results.json
new file mode 100644
index 0000000000..10d3c2b675
--- /dev/null
+++ b/bench/results/2026-10-03-community-2x-mi50/results.json
@@ -0,0 +1,382 @@
+[
+ {
+ "label": "warmup",
+ "expected_prompt_tokens": null,
+ "request_sha256": "b63e57873441a996a529b246a66cf12de70a7c671777f99329354d5c195f788f",
+ "client_ttft_s": 0.44287488800182473,
+ "client_elapsed_s": 0.5121355729934294,
+ "finish_reason": "stop",
+ "usage": {
+ "prompt_tokens": 19,
+ "completion_tokens": 2,
+ "total_tokens": 21,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791055534.084382,
+ "duration_s": 0.5,
+ "finish": "stop",
+ "prompt_tokens": 19,
+ "reused": 0,
+ "output_tokens": 2,
+ "prompt_total": 19,
+ "prompt_read": 19,
+ "engine_generated": 2,
+ "prompt_ms": 398.1,
+ "decode_ms": 108.4,
+ "decode_tok_s": 18.5,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 3,
+ "drafts_accepted": 3
+ },
+ "text": "READY"
+ },
+ {
+ "label": "tokens-4096-run-1",
+ "expected_prompt_tokens": 4096,
+ "request_sha256": "5d94c652067354bbe72930d2bdad3886564f08cf2546b32411af02d33ad3ce05",
+ "client_ttft_s": 13.26403624098748,
+ "client_elapsed_s": 18.427036346998648,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 4096,
+ "completion_tokens": 256,
+ "total_tokens": 4352,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791055535.8394356,
+ "duration_s": 18.4,
+ "finish": "length",
+ "prompt_tokens": 4096,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 4096,
+ "prompt_read": 4096,
+ "engine_generated": 256,
+ "prompt_ms": 13216.0,
+ "decode_ms": 5189.0,
+ "decode_tok_s": 49.3,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 210,
+ "drafts_accepted": 145
+ },
+ "text": "The provided Python module presents a series of deterministic integer transforms, each encapsulated within a uniquely named function. These functions, labeled from `task_00000` to `task_00112` (and potentially beyond), follow a consistent mathematical pattern: they take an integer input, apply a linear transformation of the form `(value * multiplier + offset) % modulus`, and return the result. This structure is a classic example of a linear congruential generator (LCG) component, often used in pseudo-random number generation, hashing, or cryptographic primitives, though here it appears to be a synthetic benchmark or test harness.\n\n### Deterministic Integer Transforms and Modulo Arithmetic\n\nEach function implements a linear congruential step: `f(x) = (a * x + c) % m`, where `a` is the multiplier, `c` is the increment, and `m` is the modulus (100003 in all cases). The modulus 100003 is a prime number, which is significant because it ensures that the multiplicative group modulo 100003 is cyclic, and for certain choices of `a` and `c`, the transformation can be a permutation of the"
+ },
+ {
+ "label": "tokens-4096-run-2",
+ "expected_prompt_tokens": 4096,
+ "request_sha256": "8da8a04e70e4d916f6630729082bdfb9d6ebbb47117aad60bc0541bce198a213",
+ "client_ttft_s": 12.663496508001117,
+ "client_elapsed_s": 17.377855258993804,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 4096,
+ "completion_tokens": 256,
+ "total_tokens": 4352,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791055555.3915784,
+ "duration_s": 17.4,
+ "finish": "length",
+ "prompt_tokens": 4096,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 4096,
+ "prompt_read": 4096,
+ "engine_generated": 256,
+ "prompt_ms": 12615.4,
+ "decode_ms": 4740.3,
+ "decode_tok_s": 54.0,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 208,
+ "drafts_accepted": 156
+ },
+ "text": "The provided Python module consists of a series of functions, each named with a specific pattern (`task_XXXXX`), that perform a deterministic integer transformation. Each function takes a single integer argument `value` and returns an integer result computed using a linear transformation followed by a modulo operation. The general form of each function is:\n\n```python\ndef task_XXXXX(value: int) -> int:\n return (value * A + B) % 100003\n```\n\nwhere `A` and `B` are constants specific to each function. The modulus `100003` is a prime number, which is significant in modular arithmetic because it ensures that the multiplicative group modulo 100003 is cyclic and that every non-zero element has a multiplicative inverse. This property is often exploited in cryptographic algorithms, hash functions, and pseudo-random number generators.\n\n### Deterministic Integer Transforms\n\nEach function implements a linear congruential transformation, a common technique in number theory and computer science. The transformation `value * A + B` is an affine transformation over the integers. When combined with the modulo operation, it becomes a function from the finite field (or ring) of integers modulo 10"
+ },
+ {
+ "label": "tokens-4096-run-3",
+ "expected_prompt_tokens": 4096,
+ "request_sha256": "946079edb58e764050805886156dc8f877cc29e1ca8d01c84e9a1e7917d49492",
+ "client_ttft_s": 12.811999237994314,
+ "client_elapsed_s": 17.900370211005793,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 4096,
+ "completion_tokens": 256,
+ "total_tokens": 4352,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791055573.9388902,
+ "duration_s": 17.9,
+ "finish": "length",
+ "prompt_tokens": 4096,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 4096,
+ "prompt_read": 4096,
+ "engine_generated": 256,
+ "prompt_ms": 12763.9,
+ "decode_ms": 5114.7,
+ "decode_tok_s": 50.1,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 222,
+ "drafts_accepted": 146
+ },
+ "text": "The provided Python module presents a series of deterministic integer transforms, each encapsulated within a uniquely named function. These functions, labeled from `task_00000` to `task_00112` (and potentially beyond), follow a consistent mathematical pattern: they take an integer input, apply a linear transformation, and return the result modulo a large prime number, specifically 100003. This structure serves as a synthetic benchmark for evaluating code generation, pattern recognition, and algorithmic consistency in automated systems.\n\n### Deterministic Integer Transforms and Modulo Arithmetic\n\nAt the core of each function is the expression `(value * multiplier + offset) % 100003`. This is a classic linear congruential generator (LCG) step, often used in pseudo-random number generation or cryptographic hashing, though here it is applied deterministically to a single input. The multiplier and offset vary per function, creating a unique mapping for each task. For instance, `task_00000` uses a multiplier of 1 and an offset of 0, effectively returning `value % 100003`. In contrast, `task_00097` uses a multiplier of 1"
+ },
+ {
+ "label": "tokens-32768-run-1",
+ "expected_prompt_tokens": 32768,
+ "request_sha256": "e37cfc2ab9867d91130927440df24a57255a736b8d0d829d07d828e22e3de572",
+ "client_ttft_s": 62.74767600699852,
+ "client_elapsed_s": 68.09618598900852,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 32768,
+ "completion_tokens": 256,
+ "total_tokens": 33024,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791055594.1634562,
+ "duration_s": 68.0,
+ "finish": "length",
+ "prompt_tokens": 32768,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 32768,
+ "prompt_read": 32768,
+ "engine_generated": 256,
+ "prompt_ms": 62608.4,
+ "decode_ms": 5375.3,
+ "decode_tok_s": 47.6,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 230,
+ "drafts_accepted": 152
+ },
+ "text": "This Python module presents a highly repetitive, synthetic collection of functions that serve as a benchmark for deterministic integer transforms. The code consists of a series of functions named `task_00000` through `task_00888` (and potentially beyond), each performing a specific linear transformation on an input integer `value` and returning the result modulo 100003.\n\n### Deterministic Integer Transforms and Modulo Arithmetic\n\nAt the core of each function is a linear congruential generator (LCG) style operation: `(value * multiplier + addend) % 100003`. The modulus, 100003, is a prime number. This choice of modulus is significant because it ensures that the multiplicative group of integers modulo 100003 is cyclic, which is a desirable property for cryptographic or pseudo-random number generation contexts, although here it is used for deterministic hashing or transformation.\n\nThe structure of the functions reveals a pattern in the `multiplier` and `addend` parameters. For the first 97 functions (`task_00000` to `task_00096`), the multiplier increases linearly from 1 to"
+ },
+ {
+ "label": "tokens-32768-run-2",
+ "expected_prompt_tokens": 32768,
+ "request_sha256": "ed2dfaa11aecad9f4563f33bb5f5d4b8704486508700b8fa572722cc1225ee26",
+ "client_ttft_s": 62.81415034100064,
+ "client_elapsed_s": 68.14694995200261,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 32768,
+ "completion_tokens": 256,
+ "total_tokens": 33024,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791055664.4150822,
+ "duration_s": 68.0,
+ "finish": "length",
+ "prompt_tokens": 32768,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 32768,
+ "prompt_read": 32768,
+ "engine_generated": 256,
+ "prompt_ms": 62675.9,
+ "decode_ms": 5359.3,
+ "decode_tok_s": 47.8,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 222,
+ "drafts_accepted": 147
+ },
+ "text": "This Python module presents a highly repetitive, synthetic collection of functions that perform deterministic integer transforms. Each function follows a strict pattern: it accepts an integer `value`, applies a linear transformation of the form `(value * multiplier + offset) % modulus`, and returns the result. The modulus is consistently set to `100003`, which is a prime number. This choice of modulus is significant because it ensures that the multiplicative group of integers modulo 100003 is cyclic, and every non-zero multiplier has a unique modular inverse. This property is often desirable in cryptographic or hashing contexts, though here it appears to be used for simple arithmetic obfuscation or benchmarking.\n\n### Deterministic Integer Transforms and Modulo Arithmetic\n\nThe core operation in each function is a linear congruential generator (LCG) step, commonly used in pseudo-random number generation. The formula $f(x) = (ax + c) \\pmod m$ defines an affine transformation over the finite field $\\mathbb{Z}_m$. In this module, $m = 100003$. The multipliers ($a$) and offsets ($c$) vary per function. For example, `task_00000`"
+ },
+ {
+ "label": "tokens-32768-run-3",
+ "expected_prompt_tokens": 32768,
+ "request_sha256": "2a3c7342622e80c3cd8b37e062f0a2f64640c119ece8cccbf168639041eac079",
+ "client_ttft_s": 62.844907227001386,
+ "client_elapsed_s": 68.09568050300004,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 32768,
+ "completion_tokens": 256,
+ "total_tokens": 33024,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791055734.689819,
+ "duration_s": 68.0,
+ "finish": "length",
+ "prompt_tokens": 32768,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 32768,
+ "prompt_read": 32768,
+ "engine_generated": 256,
+ "prompt_ms": 62706.6,
+ "decode_ms": 5277.5,
+ "decode_tok_s": 48.5,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 234,
+ "drafts_accepted": 153
+ },
+ "text": "This Python module presents a highly repetitive, synthetic collection of functions that perform deterministic integer transforms. Each function follows a strict pattern: it accepts an integer `value`, applies a linear transformation of the form `(value * multiplier + offset) % modulus`, and returns the result. The modulus is consistently set to `100003`, which is a prime number. This choice of modulus is significant because it ensures that the multiplicative group of integers modulo 100003 is cyclic, meaning that for any non-zero multiplier, the transformation is bijective (a permutation) over the set of integers modulo 100003. This property is often desirable in cryptographic contexts or pseudo-random number generation, although here it appears to be used for benchmarking or synthetic data generation.\n\n### Deterministic Integer Transforms and Modulo Arithmetic\n\nThe core operation in each function is a linear congruential generator (LCG) step, defined by the formula $f(x) = (ax + c) \\pmod m$. In this module, $m = 100003$. The multiplier $a$ and the increment $c$ vary per function. For example, `task_00000` uses $a"
+ },
+ {
+ "label": "tokens-128000-run-1",
+ "expected_prompt_tokens": 128000,
+ "request_sha256": "3235aac3f934cf1d889c25973ca172b355509dc2522fdfb463fbefe979a9075d",
+ "client_ttft_s": 248.26734286299325,
+ "client_elapsed_s": 253.83500957599608,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 128000,
+ "completion_tokens": 256,
+ "total_tokens": 128256,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791055809.118869,
+ "duration_s": 253.5,
+ "finish": "length",
+ "prompt_tokens": 128000,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 128000,
+ "prompt_read": 128000,
+ "engine_generated": 256,
+ "prompt_ms": 247841.6,
+ "decode_ms": 5596.2,
+ "decode_tok_s": 45.7,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 232,
+ "drafts_accepted": 152
+ },
+ "text": "This Python module represents a synthetic benchmark designed to test the performance and correctness of integer arithmetic operations, specifically focusing on linear transforms and modular arithmetic. The code consists of a series of functions, each named `task_XXXXX`, where `XXXXX` is a zero-padded integer index. Each function accepts a single integer argument `value` and returns an integer result.\n\n### 1. Deterministic Integer Transforms\n\nThe core logic of every function in this module is identical in structure:\n```python\nreturn (value * A + B) % M\n```\nWhere:\n- `value` is the input integer.\n- `A` is a multiplier (coefficient).\n- `B` is an additive constant.\n- `M` is the modulus, which is consistently `100003` throughout the entire module.\n\nThis represents a **linear congruential transform**. In computer science, such transforms are fundamental in pseudorandom number generation (LCG), hashing functions, and cryptographic primitives. The specific form `(value * A + B) % M` ensures that the output is always within the range `[0, M-1]`, regardless of how large the input `value` is.\n\n### 2"
+ },
+ {
+ "label": "tokens-128000-run-2",
+ "expected_prompt_tokens": 128000,
+ "request_sha256": "5f4b8bc9f0afc7765320abd89960c0938e0d7cf5974e962fdbd0613fef31c8d4",
+ "client_ttft_s": 248.0881588330085,
+ "client_elapsed_s": 253.0268927290017,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 128000,
+ "completion_tokens": 256,
+ "total_tokens": 128256,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791056068.9450965,
+ "duration_s": 252.7,
+ "finish": "length",
+ "prompt_tokens": 128000,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 128000,
+ "prompt_read": 128000,
+ "engine_generated": 256,
+ "prompt_ms": 247669.4,
+ "decode_ms": 4967.1,
+ "decode_tok_s": 51.5,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 205,
+ "drafts_accepted": 163
+ },
+ "text": "This Python module represents a synthetic benchmark designed to test the performance and correctness of integer arithmetic operations, specifically focusing on linear transforms and modular arithmetic. The code consists of a series of functions, each named `task_XXXXX`, where `XXXXX` is a zero-padded integer index. Each function accepts a single integer argument `value` and returns an integer result.\n\n### 1. Deterministic Integer Transforms\n\nThe core logic of each function is a linear transformation of the form:\n$$ f(x) = (a \\cdot x + b) \\pmod m $$\nwhere:\n- $x$ is the input `value`.\n- $a$ is the multiplier (coefficient).\n- $b$ is the additive constant.\n- $m$ is the modulus, which is consistently set to `100003` across all functions.\n\nThe multiplier $a$ and the constant $b$ vary deterministically based on the function's index. For example:\n- `task_00000`: $a=1, b=0$\n- `task_00001`: $a=2, b=1$\n- `task_00002`:"
+ },
+ {
+ "label": "tokens-128000-run-3",
+ "expected_prompt_tokens": 128000,
+ "request_sha256": "5c3fb71db9f9f44765f28389d1032f2c8b6687d1bf5ca6f33aeb924072cfa6e4",
+ "client_ttft_s": 248.0611828550027,
+ "client_elapsed_s": 253.7529949669988,
+ "finish_reason": "length",
+ "usage": {
+ "prompt_tokens": 128000,
+ "completion_tokens": 256,
+ "total_tokens": 128256,
+ "prompt_tokens_details": {
+ "cached_tokens": 0
+ }
+ },
+ "engine": {
+ "projection": null,
+ "time": 1791056328.0391443,
+ "duration_s": 253.4,
+ "finish": "length",
+ "prompt_tokens": 128000,
+ "reused": 0,
+ "output_tokens": 256,
+ "prompt_total": 128000,
+ "prompt_read": 128000,
+ "engine_generated": 256,
+ "prompt_ms": 247650.3,
+ "decode_ms": 5720.0,
+ "decode_tok_s": 44.8,
+ "hit_rate": 1.0,
+ "ram_blobs": 0,
+ "file_blobs": 0,
+ "file_mb": 0.0,
+ "drafts_offered": 235,
+ "drafts_accepted": 149
+ },
+ "text": "This Python module represents a synthetic benchmark designed to test the performance and correctness of integer arithmetic operations, specifically focusing on linear transformations combined with modular arithmetic. The code consists of a series of functions, each named `task_XXXXX`, where `XXXXX` is a zero-padded integer index. Each function accepts a single integer argument `value` and returns a new integer calculated using the formula:\n\n$$ f(value) = (value \\times A + B) \\pmod{M} $$\n\nwhere $A$ is a multiplier, $B$ is an additive constant, and $M$ is the modulus, which is consistently set to `100003` throughout the module.\n\n### Deterministic Integer Transforms and Modulo Arithmetic\n\nThe core logic of each function is a deterministic linear congruential transformation. In computer science, such transformations are fundamental to pseudorandom number generators (PRNGs), hash functions, and cryptographic primitives. The specific form $ (value \\times A + B) \\pmod{M} $ ensures that the output is always within the range $[0, M-1]$.\n\nThe modulus $M = 100003$ is a prime number. This choice is significant because"
+ }
+]
diff --git a/bench/results/2026-10-03-community-2x-mi50/strata-coder.json b/bench/results/2026-10-03-community-2x-mi50/strata-coder.json
new file mode 100644
index 0000000000..d766bbf9c2
--- /dev/null
+++ b/bench/results/2026-10-03-community-2x-mi50/strata-coder.json
@@ -0,0 +1,45 @@
+{
+ "exe": "/work/build-906/strata",
+ "args": [
+ "--pack",
+ "/m/pack-coder",
+ "--native",
+ "/m/coder/Qwen3.8-Flash-Next-GSQ-RCO-IQ1_M-00001-of-00002.gguf",
+ "--ple-gguf",
+ "/m/coder/Qwen3.8-Flash-Next-GSQ-RCO-IQ1_M-00002-of-00002.gguf",
+ "--expert-profile",
+ "/opt/strata/data/expert-profile-coder.bin",
+ "--expert-cache",
+ "auto",
+ "--prefill",
+ "4096",
+ "--spec",
+ "4",
+ "--spec-min-p",
+ "0.5",
+ "--mtp",
+ "/m/mtp/rt",
+ "--max-context",
+ "131072",
+ "--kv",
+ "int8",
+ "--vram-reserve-mib",
+ "600",
+ "--kv-resident",
+ "32768",
+ "--pcie-frac",
+ "0"
+ ],
+ "cwd": "/opt/strata",
+ "tokenizer": "/m/pack-coder/tokenizer",
+ "model_name": "qwen3.8-flash-next-coder",
+ "log": "/work/strata-coder.log",
+ "lib_dirs": [],
+ "port": 8080,
+ "host": "0.0.0.0",
+ "gpu": [
+ 0,
+ 1
+ ],
+ "layer_split": "27"
+}
\ No newline at end of file
diff --git a/bench/results/2026-10-03-community-2x-mi50/summary.json b/bench/results/2026-10-03-community-2x-mi50/summary.json
new file mode 100644
index 0000000000..eddf5ac372
--- /dev/null
+++ b/bench/results/2026-10-03-community-2x-mi50/summary.json
@@ -0,0 +1,113 @@
+{
+ "4096": {
+ "prompt_tokens": {
+ "median": 4096,
+ "min": 4096,
+ "max": 4096
+ },
+ "cached_tokens": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ },
+ "output_tokens": {
+ "median": 256,
+ "min": 256,
+ "max": 256
+ },
+ "client_ttft_s": {
+ "median": 12.811999237994314,
+ "min": 12.663496508001117,
+ "max": 13.26403624098748
+ },
+ "client_elapsed_s": {
+ "median": 17.900370211005793,
+ "min": 17.377855258993804,
+ "max": 18.427036346998648
+ },
+ "prefill_tok_s": {
+ "median": 320.90505253096626,
+ "min": 309.9273607748184,
+ "max": 324.68253087496237
+ },
+ "decode_tok_s": {
+ "median": 50.05181144544157,
+ "min": 49.3351320100212,
+ "max": 54.00502077927557
+ }
+ },
+ "32768": {
+ "prompt_tokens": {
+ "median": 32768,
+ "min": 32768,
+ "max": 32768
+ },
+ "cached_tokens": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ },
+ "output_tokens": {
+ "median": 256,
+ "min": 256,
+ "max": 256
+ },
+ "client_ttft_s": {
+ "median": 62.81415034100064,
+ "min": 62.74767600699852,
+ "max": 62.844907227001386
+ },
+ "client_elapsed_s": {
+ "median": 68.09618598900852,
+ "min": 68.09568050300004,
+ "max": 68.14694995200261
+ },
+ "prefill_tok_s": {
+ "median": 522.8165850031671,
+ "min": 522.5606236026192,
+ "max": 523.3802492956216
+ },
+ "decode_tok_s": {
+ "median": 47.767432313921596,
+ "min": 47.625248823321485,
+ "max": 48.50781620085268
+ }
+ },
+ "128000": {
+ "prompt_tokens": {
+ "median": 128000,
+ "min": 128000,
+ "max": 128000
+ },
+ "cached_tokens": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ },
+ "output_tokens": {
+ "median": 256,
+ "min": 256,
+ "max": 256
+ },
+ "client_ttft_s": {
+ "median": 248.0881588330085,
+ "min": 248.0611828550027,
+ "max": 248.26734286299325
+ },
+ "client_elapsed_s": {
+ "median": 253.7529949669988,
+ "min": 253.0268927290017,
+ "max": 253.83500957599608
+ },
+ "prefill_tok_s": {
+ "median": 516.8179839737974,
+ "min": 516.4588995551998,
+ "max": 516.857843499483
+ },
+ "decode_tok_s": {
+ "median": 45.745327186305,
+ "min": 44.75524475524476,
+ "max": 51.5391274586781
+ }
+ }
+}
diff --git a/bench/results/2026-10-03-community-2x-mi50/telemetry.jsonl b/bench/results/2026-10-03-community-2x-mi50/telemetry.jsonl
new file mode 100644
index 0000000000..bafdea8089
--- /dev/null
+++ b/bench/results/2026-10-03-community-2x-mi50/telemetry.jsonl
@@ -0,0 +1,1699 @@
+{"t": 1791055498.231, "mem_avail_kib": 30786360, "swap_free_kib": 7052840, "vram_used": [10924032,10924032]}
+{"t": 1791055499.271, "mem_avail_kib": 30377400, "swap_free_kib": 7052840, "vram_used": [10964992,10964992]}
+{"t": 1791055500.334, "mem_avail_kib": 30173356, "swap_free_kib": 7053096, "vram_used": [1064579072,11710464]}
+{"t": 1791055501.391, "mem_avail_kib": 30172568, "swap_free_kib": 7053096, "vram_used": [1064579072,11710464]}
+{"t": 1791055502.433, "mem_avail_kib": 30169280, "swap_free_kib": 7053096, "vram_used": [1064579072,11710464]}
+{"t": 1791055503.477, "mem_avail_kib": 30191972, "swap_free_kib": 7053096, "vram_used": [1588826112,11661312]}
+{"t": 1791055504.566, "mem_avail_kib": 30031712, "swap_free_kib": 7053096, "vram_used": [2394238976,892637184]}
+{"t": 1791055505.624, "mem_avail_kib": 30051892, "swap_free_kib": 7053096, "vram_used": [2394238976,892637184]}
+{"t": 1791055506.686, "mem_avail_kib": 30032244, "swap_free_kib": 7053096, "vram_used": [2394238976,892637184]}
+{"t": 1791055507.731, "mem_avail_kib": 30040204, "swap_free_kib": 7053096, "vram_used": [2394189824,1863573504]}
+{"t": 1791055508.773, "mem_avail_kib": 28961132, "swap_free_kib": 7053096, "vram_used": [2820055040,2377576448]}
+{"t": 1791055509.818, "mem_avail_kib": 28300928, "swap_free_kib": 7053096, "vram_used": [2820943872,3018100736]}
+{"t": 1791055510.884, "mem_avail_kib": 28350460, "swap_free_kib": 7053096, "vram_used": [2820943872,3726938112]}
+{"t": 1791055512.032, "mem_avail_kib": 28368196, "swap_free_kib": 7053096, "vram_used": [2820943872,3726938112]}
+{"t": 1791055513.107, "mem_avail_kib": 28343544, "swap_free_kib": 7053096, "vram_used": [2820943872,3726938112]}
+{"t": 1791055514.156, "mem_avail_kib": 28271040, "swap_free_kib": 7053096, "vram_used": [16318500864,3802742784]}
+{"t": 1791055515.207, "mem_avail_kib": 28260748, "swap_free_kib": 7053096, "vram_used": [16318509056,3802742784]}
+{"t": 1791055516.252, "mem_avail_kib": 28267500, "swap_free_kib": 7053096, "vram_used": [16318509056,3802742784]}
+{"t": 1791055517.291, "mem_avail_kib": 28265100, "swap_free_kib": 7053096, "vram_used": [16318509056,3802742784]}
+{"t": 1791055518.332, "mem_avail_kib": 28258636, "swap_free_kib": 7053096, "vram_used": [16318509056,3802742784]}
+{"t": 1791055519.379, "mem_avail_kib": 28277724, "swap_free_kib": 7053096, "vram_used": [16318509056,15456628736]}
+{"t": 1791055520.431, "mem_avail_kib": 28278036, "swap_free_kib": 7053096, "vram_used": [16318509056,15456628736]}
+{"t": 1791055521.479, "mem_avail_kib": 28278204, "swap_free_kib": 7053096, "vram_used": [16318509056,15456628736]}
+{"t": 1791055522.525, "mem_avail_kib": 28272556, "swap_free_kib": 7053096, "vram_used": [16318509056,15456628736]}
+{"t": 1791055523.573, "mem_avail_kib": 28265648, "swap_free_kib": 7053096, "vram_used": [16318509056,15456628736]}
+{"t": 1791055524.619, "mem_avail_kib": 28246724, "swap_free_kib": 7053096, "vram_used": [16318509056,15456628736]}
+{"t": 1791055525.666, "mem_avail_kib": 28241676, "swap_free_kib": 7053352, "vram_used": [16318509056,15456628736]}
+{"t": 1791055526.719, "mem_avail_kib": 28245824, "swap_free_kib": 7053608, "vram_used": [16318509056,15456628736]}
+{"t": 1791055527.769, "mem_avail_kib": 28248292, "swap_free_kib": 7053608, "vram_used": [16318509056,15456628736]}
+{"t": 1791055528.860, "mem_avail_kib": 28238060, "swap_free_kib": 7053608, "vram_used": [16318509056,15456628736]}
+{"t": 1791055529.899, "mem_avail_kib": 28231984, "swap_free_kib": 7053608, "vram_used": [16318513152,15456632832]}
+{"t": 1791055530.936, "mem_avail_kib": 27595720, "swap_free_kib": 7053608, "vram_used": [16374341632,15571443712]}
+{"t": 1791055531.981, "mem_avail_kib": 27529568, "swap_free_kib": 7053608, "vram_used": [16475328512,15663824896]}
+{"t": 1791055533.023, "mem_avail_kib": 27517964, "swap_free_kib": 7053608, "vram_used": [16475328512,15663824896]}
+{"t": 1791055534.065, "mem_avail_kib": 27358404, "swap_free_kib": 7053608, "vram_used": [16475328512,15663824896]}
+{"t": 1791055535.108, "mem_avail_kib": 27225240, "swap_free_kib": 7053608, "vram_used": [16557064192,15718391808]}
+{"t": 1791055536.145, "mem_avail_kib": 27226976, "swap_free_kib": 7053608, "vram_used": [16557481984,15718391808]}
+{"t": 1791055537.183, "mem_avail_kib": 27227508, "swap_free_kib": 7053608, "vram_used": [16560840704,15718416384]}
+{"t": 1791055538.221, "mem_avail_kib": 27226424, "swap_free_kib": 7053608, "vram_used": [16560840704,15718416384]}
+{"t": 1791055539.259, "mem_avail_kib": 27218752, "swap_free_kib": 7053608, "vram_used": [16560840704,15718416384]}
+{"t": 1791055540.297, "mem_avail_kib": 27224788, "swap_free_kib": 7054120, "vram_used": [16560840704,15718416384]}
+{"t": 1791055541.333, "mem_avail_kib": 27209492, "swap_free_kib": 7054120, "vram_used": [16561463296,15718420480]}
+{"t": 1791055542.372, "mem_avail_kib": 27218412, "swap_free_kib": 7054120, "vram_used": [16561463296,15718420480]}
+{"t": 1791055543.410, "mem_avail_kib": 27208168, "swap_free_kib": 7054120, "vram_used": [16561471488,15720955904]}
+{"t": 1791055544.449, "mem_avail_kib": 27197644, "swap_free_kib": 7054120, "vram_used": [16561483776,15728717824]}
+{"t": 1791055545.486, "mem_avail_kib": 27225460, "swap_free_kib": 7054120, "vram_used": [16561483776,15728717824]}
+{"t": 1791055546.522, "mem_avail_kib": 27222844, "swap_free_kib": 7054120, "vram_used": [16561483776,15728717824]}
+{"t": 1791055547.560, "mem_avail_kib": 27216632, "swap_free_kib": 7054120, "vram_used": [16561483776,15728717824]}
+{"t": 1791055548.601, "mem_avail_kib": 27109628, "swap_free_kib": 7054120, "vram_used": [16561487872,15729442816]}
+{"t": 1791055549.641, "mem_avail_kib": 27107924, "swap_free_kib": 7054120, "vram_used": [16562458624,15730737152]}
+{"t": 1791055550.677, "mem_avail_kib": 27109980, "swap_free_kib": 7054120, "vram_used": [16562458624,15730737152]}
+{"t": 1791055551.717, "mem_avail_kib": 27102036, "swap_free_kib": 7054120, "vram_used": [16562458624,15730737152]}
+{"t": 1791055552.756, "mem_avail_kib": 27094240, "swap_free_kib": 7054120, "vram_used": [16562458624,15730737152]}
+{"t": 1791055553.797, "mem_avail_kib": 27104564, "swap_free_kib": 7054120, "vram_used": [16562458624,15730737152]}
+{"t": 1791055554.835, "mem_avail_kib": 27102540, "swap_free_kib": 7054120, "vram_used": [16562458624,15730737152]}
+{"t": 1791055555.873, "mem_avail_kib": 27188636, "swap_free_kib": 7054120, "vram_used": [16562458624,15730737152]}
+{"t": 1791055556.915, "mem_avail_kib": 27199532, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055557.953, "mem_avail_kib": 27201496, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055558.992, "mem_avail_kib": 27210500, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055560.029, "mem_avail_kib": 27205028, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055561.065, "mem_avail_kib": 27217216, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055562.104, "mem_avail_kib": 27223476, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055563.142, "mem_avail_kib": 27227648, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055564.181, "mem_avail_kib": 27240288, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055565.217, "mem_avail_kib": 27237232, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055566.257, "mem_avail_kib": 27223536, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055567.293, "mem_avail_kib": 27211536, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055568.330, "mem_avail_kib": 27105576, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055569.368, "mem_avail_kib": 27108292, "swap_free_kib": 7054376, "vram_used": [16562458624,15730737152]}
+{"t": 1791055570.402, "mem_avail_kib": 27100288, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055571.441, "mem_avail_kib": 27094024, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055572.477, "mem_avail_kib": 27101680, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055573.515, "mem_avail_kib": 27100552, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055574.552, "mem_avail_kib": 27178496, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055575.589, "mem_avail_kib": 27181928, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055576.626, "mem_avail_kib": 27191396, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055577.664, "mem_avail_kib": 27189424, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055578.701, "mem_avail_kib": 27200084, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055579.742, "mem_avail_kib": 27211748, "swap_free_kib": 7054376, "vram_used": [16562458624,15730896896]}
+{"t": 1791055580.783, "mem_avail_kib": 27221836, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055581.821, "mem_avail_kib": 27231360, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055582.861, "mem_avail_kib": 27222248, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055583.900, "mem_avail_kib": 27228260, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055584.938, "mem_avail_kib": 27231048, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055585.978, "mem_avail_kib": 27236244, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055587.018, "mem_avail_kib": 27121292, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055588.053, "mem_avail_kib": 27122072, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055589.090, "mem_avail_kib": 27122960, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055590.127, "mem_avail_kib": 27114652, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055591.163, "mem_avail_kib": 27117328, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055592.207, "mem_avail_kib": 27107232, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055593.244, "mem_avail_kib": 27103404, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055594.284, "mem_avail_kib": 27146720, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055595.321, "mem_avail_kib": 27160428, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055596.356, "mem_avail_kib": 27166948, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055597.395, "mem_avail_kib": 27164564, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055598.431, "mem_avail_kib": 27178824, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055599.470, "mem_avail_kib": 27196184, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055600.507, "mem_avail_kib": 27206660, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055601.545, "mem_avail_kib": 27208100, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055602.587, "mem_avail_kib": 27207380, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055603.625, "mem_avail_kib": 27198348, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055604.663, "mem_avail_kib": 27199968, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055605.699, "mem_avail_kib": 27208528, "swap_free_kib": 7054376, "vram_used": [16562462720,15730900992]}
+{"t": 1791055606.736, "mem_avail_kib": 27212224, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055607.776, "mem_avail_kib": 27203128, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055608.815, "mem_avail_kib": 27196956, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055609.854, "mem_avail_kib": 27201396, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055610.889, "mem_avail_kib": 27206104, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055611.929, "mem_avail_kib": 27208428, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055612.968, "mem_avail_kib": 27213028, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055614.005, "mem_avail_kib": 27225020, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055615.041, "mem_avail_kib": 27210732, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055616.080, "mem_avail_kib": 27209548, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055617.121, "mem_avail_kib": 27217180, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055618.160, "mem_avail_kib": 27220700, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055619.196, "mem_avail_kib": 27230556, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055620.233, "mem_avail_kib": 27221736, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055621.270, "mem_avail_kib": 27218132, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055622.308, "mem_avail_kib": 27148140, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055623.346, "mem_avail_kib": 27132364, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055624.384, "mem_avail_kib": 27128956, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055625.423, "mem_avail_kib": 27135464, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055626.460, "mem_avail_kib": 27139852, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055627.498, "mem_avail_kib": 27092632, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055628.536, "mem_avail_kib": 27091820, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055629.575, "mem_avail_kib": 27099320, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055630.615, "mem_avail_kib": 27098028, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055631.654, "mem_avail_kib": 27095252, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055632.692, "mem_avail_kib": 27097848, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055633.732, "mem_avail_kib": 27096964, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055634.771, "mem_avail_kib": 27100540, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055635.809, "mem_avail_kib": 27098044, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055636.848, "mem_avail_kib": 27083572, "swap_free_kib": 7054376, "vram_used": [16562462720,15731044352]}
+{"t": 1791055637.889, "mem_avail_kib": 27081652, "swap_free_kib": 7054632, "vram_used": [16562462720,15731044352]}
+{"t": 1791055638.930, "mem_avail_kib": 27092164, "swap_free_kib": 7054632, "vram_used": [16562462720,15731044352]}
+{"t": 1791055639.967, "mem_avail_kib": 27076424, "swap_free_kib": 7054632, "vram_used": [16562462720,15731044352]}
+{"t": 1791055641.007, "mem_avail_kib": 27086376, "swap_free_kib": 7054632, "vram_used": [16562462720,15731044352]}
+{"t": 1791055642.043, "mem_avail_kib": 27094036, "swap_free_kib": 7054632, "vram_used": [16562462720,15731044352]}
+{"t": 1791055643.082, "mem_avail_kib": 27091056, "swap_free_kib": 7054632, "vram_used": [16562462720,15731044352]}
+{"t": 1791055644.119, "mem_avail_kib": 27083248, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055645.157, "mem_avail_kib": 27083096, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055646.194, "mem_avail_kib": 27084144, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055647.236, "mem_avail_kib": 27084076, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055648.273, "mem_avail_kib": 27080940, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055649.311, "mem_avail_kib": 27073976, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055650.349, "mem_avail_kib": 27078040, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055651.388, "mem_avail_kib": 27079168, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055652.427, "mem_avail_kib": 27084012, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055653.464, "mem_avail_kib": 27087476, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055654.502, "mem_avail_kib": 27079020, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055655.540, "mem_avail_kib": 27044148, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055656.579, "mem_avail_kib": 26957072, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055657.616, "mem_avail_kib": 26941260, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055658.653, "mem_avail_kib": 26944536, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055659.689, "mem_avail_kib": 26948040, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055660.729, "mem_avail_kib": 26946764, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055661.768, "mem_avail_kib": 26956812, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055662.805, "mem_avail_kib": 26958276, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055663.841, "mem_avail_kib": 26966880, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055664.877, "mem_avail_kib": 27187456, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055665.916, "mem_avail_kib": 27192464, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055666.954, "mem_avail_kib": 27202652, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055667.995, "mem_avail_kib": 27209164, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055669.032, "mem_avail_kib": 27199048, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055670.071, "mem_avail_kib": 27201904, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055671.110, "mem_avail_kib": 27211872, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055672.148, "mem_avail_kib": 27197860, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055673.188, "mem_avail_kib": 27200252, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055674.230, "mem_avail_kib": 27203396, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055675.272, "mem_avail_kib": 27205876, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055676.313, "mem_avail_kib": 27215284, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055677.349, "mem_avail_kib": 27215468, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055678.387, "mem_avail_kib": 27215768, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055679.426, "mem_avail_kib": 27224480, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055680.465, "mem_avail_kib": 27220480, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055681.504, "mem_avail_kib": 27224980, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055682.541, "mem_avail_kib": 27213148, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055683.579, "mem_avail_kib": 27215612, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055684.616, "mem_avail_kib": 27210608, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055685.655, "mem_avail_kib": 27211608, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055686.695, "mem_avail_kib": 27230392, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055687.737, "mem_avail_kib": 27232452, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055688.776, "mem_avail_kib": 27222972, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055689.819, "mem_avail_kib": 27226164, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055690.858, "mem_avail_kib": 27226732, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055691.894, "mem_avail_kib": 27215112, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055692.932, "mem_avail_kib": 27136108, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055693.970, "mem_avail_kib": 27123876, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055695.008, "mem_avail_kib": 27131908, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055696.043, "mem_avail_kib": 27134480, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055697.081, "mem_avail_kib": 27123560, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055698.122, "mem_avail_kib": 27088224, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055699.161, "mem_avail_kib": 27082256, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055700.200, "mem_avail_kib": 27083448, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055701.238, "mem_avail_kib": 27084708, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055702.275, "mem_avail_kib": 27083268, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055703.312, "mem_avail_kib": 27084276, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055704.349, "mem_avail_kib": 27070776, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055705.385, "mem_avail_kib": 27072360, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055706.421, "mem_avail_kib": 27064332, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055707.461, "mem_avail_kib": 27074996, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055708.499, "mem_avail_kib": 27069124, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055709.536, "mem_avail_kib": 27064808, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055710.576, "mem_avail_kib": 27065376, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055711.616, "mem_avail_kib": 27071152, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055712.658, "mem_avail_kib": 27073824, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055713.696, "mem_avail_kib": 27078324, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055714.735, "mem_avail_kib": 27077388, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055715.776, "mem_avail_kib": 27081472, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055716.817, "mem_avail_kib": 27059232, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055717.855, "mem_avail_kib": 27056972, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055718.896, "mem_avail_kib": 27070544, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055719.934, "mem_avail_kib": 27063232, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055720.972, "mem_avail_kib": 27063100, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055722.013, "mem_avail_kib": 27068064, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055723.053, "mem_avail_kib": 27061360, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055724.091, "mem_avail_kib": 27072316, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055725.129, "mem_avail_kib": 27071112, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055726.165, "mem_avail_kib": 27079216, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055727.206, "mem_avail_kib": 26964468, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055728.244, "mem_avail_kib": 26960688, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055729.279, "mem_avail_kib": 26968196, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055730.316, "mem_avail_kib": 26968836, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055731.352, "mem_avail_kib": 26972928, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055732.388, "mem_avail_kib": 26975004, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055733.429, "mem_avail_kib": 26973536, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055734.470, "mem_avail_kib": 26967296, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055735.509, "mem_avail_kib": 27167356, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055736.548, "mem_avail_kib": 27173116, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055737.586, "mem_avail_kib": 27170296, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055738.623, "mem_avail_kib": 27166192, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055739.662, "mem_avail_kib": 27171320, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055740.699, "mem_avail_kib": 27176712, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055741.737, "mem_avail_kib": 27179900, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055742.776, "mem_avail_kib": 27171924, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055743.815, "mem_avail_kib": 27175460, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055744.853, "mem_avail_kib": 27182596, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055745.888, "mem_avail_kib": 27187884, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055746.924, "mem_avail_kib": 27188676, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055747.961, "mem_avail_kib": 27193968, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055749.000, "mem_avail_kib": 27188348, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055750.041, "mem_avail_kib": 27192248, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055751.079, "mem_avail_kib": 27208128, "swap_free_kib": 7054632, "vram_used": [16562462720,15780212736]}
+{"t": 1791055752.120, "mem_avail_kib": 27211044, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055753.162, "mem_avail_kib": 27213464, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055754.208, "mem_avail_kib": 27199404, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055755.250, "mem_avail_kib": 27203928, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055756.290, "mem_avail_kib": 27205276, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055757.330, "mem_avail_kib": 27215248, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055758.370, "mem_avail_kib": 27212892, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055759.406, "mem_avail_kib": 27202072, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055760.448, "mem_avail_kib": 27183044, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055761.486, "mem_avail_kib": 27189404, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055762.524, "mem_avail_kib": 27118668, "swap_free_kib": 7054888, "vram_used": [16562462720,15780212736]}
+{"t": 1791055763.564, "mem_avail_kib": 27119428, "swap_free_kib": 7055144, "vram_used": [16562462720,15780212736]}
+{"t": 1791055764.602, "mem_avail_kib": 27111956, "swap_free_kib": 7055144, "vram_used": [16562462720,15780212736]}
+{"t": 1791055765.640, "mem_avail_kib": 27118060, "swap_free_kib": 7055400, "vram_used": [16562462720,15780212736]}
+{"t": 1791055766.680, "mem_avail_kib": 27101512, "swap_free_kib": 7055400, "vram_used": [16562462720,15780212736]}
+{"t": 1791055767.717, "mem_avail_kib": 27059700, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055768.756, "mem_avail_kib": 27064220, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055769.795, "mem_avail_kib": 27077464, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055770.834, "mem_avail_kib": 27079996, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055771.871, "mem_avail_kib": 27085352, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055772.911, "mem_avail_kib": 27073280, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055773.951, "mem_avail_kib": 27069192, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055774.989, "mem_avail_kib": 27074492, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055776.026, "mem_avail_kib": 27080564, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055777.069, "mem_avail_kib": 27069436, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055778.108, "mem_avail_kib": 27042728, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055779.148, "mem_avail_kib": 27050112, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055780.187, "mem_avail_kib": 27055592, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055781.225, "mem_avail_kib": 27052344, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055782.264, "mem_avail_kib": 27057472, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055783.301, "mem_avail_kib": 27064884, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055784.338, "mem_avail_kib": 27060428, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055785.378, "mem_avail_kib": 27071728, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055786.415, "mem_avail_kib": 27068660, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055787.454, "mem_avail_kib": 27080756, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055788.492, "mem_avail_kib": 27085204, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055789.530, "mem_avail_kib": 27073348, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055790.571, "mem_avail_kib": 27073200, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055791.610, "mem_avail_kib": 27084916, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055792.648, "mem_avail_kib": 27089456, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055793.687, "mem_avail_kib": 27077496, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055794.722, "mem_avail_kib": 27080172, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055795.759, "mem_avail_kib": 27085100, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055796.800, "mem_avail_kib": 26962752, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055797.837, "mem_avail_kib": 26968120, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055798.878, "mem_avail_kib": 26960616, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055799.914, "mem_avail_kib": 26948040, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055800.951, "mem_avail_kib": 26952144, "swap_free_kib": 7055656, "vram_used": [16562462720,15780212736]}
+{"t": 1791055801.989, "mem_avail_kib": 26953992, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055803.028, "mem_avail_kib": 26960532, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055804.067, "mem_avail_kib": 26965552, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055805.106, "mem_avail_kib": 26974128, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055806.144, "mem_avail_kib": 26972560, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055807.183, "mem_avail_kib": 26975264, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055808.221, "mem_avail_kib": 26982524, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055809.259, "mem_avail_kib": 27194788, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055810.295, "mem_avail_kib": 27192136, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055811.334, "mem_avail_kib": 27194644, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055812.370, "mem_avail_kib": 27187420, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055813.411, "mem_avail_kib": 27187204, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055814.448, "mem_avail_kib": 27188332, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055815.483, "mem_avail_kib": 27177640, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055816.520, "mem_avail_kib": 27174588, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055817.559, "mem_avail_kib": 27176848, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055818.601, "mem_avail_kib": 27182692, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055819.642, "mem_avail_kib": 27187836, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055820.684, "mem_avail_kib": 27195216, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055821.724, "mem_avail_kib": 27199644, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055822.763, "mem_avail_kib": 27191188, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055823.802, "mem_avail_kib": 27196408, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055824.838, "mem_avail_kib": 27197040, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055825.874, "mem_avail_kib": 27201972, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055826.913, "mem_avail_kib": 27185608, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055827.953, "mem_avail_kib": 27191132, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055828.996, "mem_avail_kib": 27184156, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055830.037, "mem_avail_kib": 27195604, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055831.076, "mem_avail_kib": 27198932, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055832.113, "mem_avail_kib": 27203004, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055833.153, "mem_avail_kib": 27195552, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055834.195, "mem_avail_kib": 27181956, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055835.233, "mem_avail_kib": 27174912, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055836.268, "mem_avail_kib": 27177720, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055837.305, "mem_avail_kib": 27096924, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055838.343, "mem_avail_kib": 27066736, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055839.384, "mem_avail_kib": 27068032, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055840.422, "mem_avail_kib": 27064496, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055841.460, "mem_avail_kib": 27077312, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055842.497, "mem_avail_kib": 27020740, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055843.534, "mem_avail_kib": 27012660, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055844.573, "mem_avail_kib": 27017972, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055845.612, "mem_avail_kib": 27028316, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055846.649, "mem_avail_kib": 27037696, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055847.687, "mem_avail_kib": 27048544, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055848.726, "mem_avail_kib": 27051524, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055849.763, "mem_avail_kib": 27057704, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055850.799, "mem_avail_kib": 27064040, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055851.838, "mem_avail_kib": 27062364, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055852.875, "mem_avail_kib": 27069208, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055853.914, "mem_avail_kib": 27059192, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055854.954, "mem_avail_kib": 27057308, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055855.992, "mem_avail_kib": 27062296, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055857.031, "mem_avail_kib": 27071592, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055858.067, "mem_avail_kib": 27075048, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055859.109, "mem_avail_kib": 27076368, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055860.147, "mem_avail_kib": 27079348, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055861.185, "mem_avail_kib": 27076916, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055862.221, "mem_avail_kib": 27080080, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055863.258, "mem_avail_kib": 27087536, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055864.296, "mem_avail_kib": 27086868, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055865.334, "mem_avail_kib": 27078000, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055866.370, "mem_avail_kib": 27022304, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055867.407, "mem_avail_kib": 27016408, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055868.444, "mem_avail_kib": 27022060, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055869.483, "mem_avail_kib": 27025664, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055870.521, "mem_avail_kib": 27017064, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055871.559, "mem_avail_kib": 26952836, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055872.594, "mem_avail_kib": 26957724, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055873.631, "mem_avail_kib": 26959336, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055874.667, "mem_avail_kib": 26956340, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055875.706, "mem_avail_kib": 26956024, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055876.744, "mem_avail_kib": 26957508, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055877.782, "mem_avail_kib": 26961868, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055878.820, "mem_avail_kib": 26956108, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055879.859, "mem_avail_kib": 26964524, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055880.896, "mem_avail_kib": 26957848, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055881.938, "mem_avail_kib": 26961056, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055882.977, "mem_avail_kib": 26965252, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055884.014, "mem_avail_kib": 26967340, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055885.053, "mem_avail_kib": 26963848, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055886.089, "mem_avail_kib": 26960116, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055887.125, "mem_avail_kib": 26952072, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055888.163, "mem_avail_kib": 26946456, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055889.200, "mem_avail_kib": 26949076, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055890.237, "mem_avail_kib": 26950316, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055891.274, "mem_avail_kib": 26944588, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055892.311, "mem_avail_kib": 26946096, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055893.350, "mem_avail_kib": 26950504, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055894.388, "mem_avail_kib": 26959364, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055895.426, "mem_avail_kib": 26897296, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055896.462, "mem_avail_kib": 26886288, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055897.504, "mem_avail_kib": 26887292, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055898.546, "mem_avail_kib": 26893456, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055899.585, "mem_avail_kib": 26867416, "swap_free_kib": 7055912, "vram_used": [16562462720,15780212736]}
+{"t": 1791055900.622, "mem_avail_kib": 26882632, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055901.658, "mem_avail_kib": 26835604, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055902.695, "mem_avail_kib": 26835292, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055903.733, "mem_avail_kib": 26841128, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055904.771, "mem_avail_kib": 26847472, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055905.805, "mem_avail_kib": 26834784, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055906.842, "mem_avail_kib": 26833228, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055907.882, "mem_avail_kib": 26829424, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055908.919, "mem_avail_kib": 26833128, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055909.957, "mem_avail_kib": 26848500, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055910.996, "mem_avail_kib": 26849664, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055912.033, "mem_avail_kib": 26842484, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055913.072, "mem_avail_kib": 26840556, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055914.112, "mem_avail_kib": 26842864, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055915.149, "mem_avail_kib": 26833392, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055916.189, "mem_avail_kib": 26837860, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055917.227, "mem_avail_kib": 26837744, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055918.265, "mem_avail_kib": 26842960, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055919.307, "mem_avail_kib": 26843360, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055920.346, "mem_avail_kib": 26847904, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055921.386, "mem_avail_kib": 26845176, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055922.426, "mem_avail_kib": 26838244, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055923.465, "mem_avail_kib": 26833300, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055924.502, "mem_avail_kib": 26830768, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055925.539, "mem_avail_kib": 26755348, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055926.575, "mem_avail_kib": 26758488, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055927.615, "mem_avail_kib": 26766984, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055928.652, "mem_avail_kib": 26770060, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055929.691, "mem_avail_kib": 26772532, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055930.727, "mem_avail_kib": 26782348, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055931.765, "mem_avail_kib": 26733304, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055932.803, "mem_avail_kib": 26731644, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055933.839, "mem_avail_kib": 26733236, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055934.877, "mem_avail_kib": 26737008, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055935.916, "mem_avail_kib": 26729460, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055936.954, "mem_avail_kib": 26729484, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055937.992, "mem_avail_kib": 26733140, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055939.030, "mem_avail_kib": 26733180, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055940.070, "mem_avail_kib": 26735396, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055941.106, "mem_avail_kib": 26735716, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055942.143, "mem_avail_kib": 26728044, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055943.184, "mem_avail_kib": 26728068, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055944.225, "mem_avail_kib": 26736532, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055945.263, "mem_avail_kib": 26722368, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055946.299, "mem_avail_kib": 26717752, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055947.336, "mem_avail_kib": 26712156, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055948.377, "mem_avail_kib": 26707700, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055949.416, "mem_avail_kib": 26700744, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055950.456, "mem_avail_kib": 26704772, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055951.498, "mem_avail_kib": 26720240, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055952.540, "mem_avail_kib": 26723660, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055953.580, "mem_avail_kib": 26725448, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055954.616, "mem_avail_kib": 26733992, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055955.652, "mem_avail_kib": 26726104, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055956.688, "mem_avail_kib": 26661972, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055957.727, "mem_avail_kib": 26668216, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055958.765, "mem_avail_kib": 26667440, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055959.803, "mem_avail_kib": 26665844, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055960.844, "mem_avail_kib": 26669992, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055961.884, "mem_avail_kib": 26670312, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055962.923, "mem_avail_kib": 26620728, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055963.961, "mem_avail_kib": 26618116, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055964.996, "mem_avail_kib": 26609020, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055966.034, "mem_avail_kib": 26603424, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055967.071, "mem_avail_kib": 26601940, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055968.108, "mem_avail_kib": 26601968, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055969.149, "mem_avail_kib": 26606744, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055970.187, "mem_avail_kib": 26612860, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055971.225, "mem_avail_kib": 26603300, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055972.263, "mem_avail_kib": 26617248, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055973.302, "mem_avail_kib": 26601532, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055974.339, "mem_avail_kib": 26595316, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055975.378, "mem_avail_kib": 26588948, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055976.417, "mem_avail_kib": 26581960, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055977.454, "mem_avail_kib": 26580832, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055978.492, "mem_avail_kib": 26587904, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055979.529, "mem_avail_kib": 26580052, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055980.573, "mem_avail_kib": 26592072, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055981.612, "mem_avail_kib": 26602408, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055982.651, "mem_avail_kib": 26609672, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055983.689, "mem_avail_kib": 26599384, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055984.726, "mem_avail_kib": 26600456, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055985.764, "mem_avail_kib": 26601536, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055986.800, "mem_avail_kib": 26605764, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055987.838, "mem_avail_kib": 26601476, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055988.876, "mem_avail_kib": 26539840, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055989.917, "mem_avail_kib": 26549632, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055990.960, "mem_avail_kib": 26554508, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055991.996, "mem_avail_kib": 26557172, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055993.035, "mem_avail_kib": 26559132, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055994.071, "mem_avail_kib": 26558932, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055995.111, "mem_avail_kib": 26508892, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055996.151, "mem_avail_kib": 26505396, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055997.186, "mem_avail_kib": 26500328, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055998.226, "mem_avail_kib": 26503400, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791055999.264, "mem_avail_kib": 26508472, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791056000.303, "mem_avail_kib": 26499316, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791056001.339, "mem_avail_kib": 26503112, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791056002.377, "mem_avail_kib": 26500700, "swap_free_kib": 7056168, "vram_used": [16562462720,15780212736]}
+{"t": 1791056003.414, "mem_avail_kib": 26494204, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056004.453, "mem_avail_kib": 26475432, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056005.490, "mem_avail_kib": 26478272, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056006.530, "mem_avail_kib": 26469108, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056007.570, "mem_avail_kib": 26480516, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056008.610, "mem_avail_kib": 26488048, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056009.648, "mem_avail_kib": 26497012, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056010.686, "mem_avail_kib": 26503304, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056011.722, "mem_avail_kib": 26505548, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056012.761, "mem_avail_kib": 26499584, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056013.806, "mem_avail_kib": 26499544, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056014.846, "mem_avail_kib": 26499948, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056015.886, "mem_avail_kib": 26496844, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056016.924, "mem_avail_kib": 26503252, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056017.961, "mem_avail_kib": 26501492, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056018.998, "mem_avail_kib": 26506836, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056020.034, "mem_avail_kib": 26502124, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056021.069, "mem_avail_kib": 26502204, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056022.106, "mem_avail_kib": 26418472, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056023.144, "mem_avail_kib": 26422932, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056024.180, "mem_avail_kib": 26416784, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056025.220, "mem_avail_kib": 26422548, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056026.259, "mem_avail_kib": 26425028, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056027.294, "mem_avail_kib": 26417928, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056028.331, "mem_avail_kib": 26374484, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056029.370, "mem_avail_kib": 26475908, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056030.406, "mem_avail_kib": 26477268, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056031.444, "mem_avail_kib": 26470940, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056032.484, "mem_avail_kib": 26483548, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056033.522, "mem_avail_kib": 26494140, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056034.560, "mem_avail_kib": 26492772, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056035.601, "mem_avail_kib": 26484268, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056036.640, "mem_avail_kib": 26483784, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056037.678, "mem_avail_kib": 26474496, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056038.714, "mem_avail_kib": 26474740, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056039.750, "mem_avail_kib": 26468500, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056040.786, "mem_avail_kib": 26474560, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056041.823, "mem_avail_kib": 26473304, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056042.862, "mem_avail_kib": 26481400, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056043.902, "mem_avail_kib": 26489612, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056044.941, "mem_avail_kib": 26497944, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056045.978, "mem_avail_kib": 26502496, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056047.020, "mem_avail_kib": 26492140, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056048.059, "mem_avail_kib": 26491252, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056049.100, "mem_avail_kib": 26496820, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056050.138, "mem_avail_kib": 26502980, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056051.177, "mem_avail_kib": 26502276, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056052.216, "mem_avail_kib": 26503716, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056053.256, "mem_avail_kib": 26508472, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056054.295, "mem_avail_kib": 26507916, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056055.340, "mem_avail_kib": 26505080, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056056.380, "mem_avail_kib": 26370928, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056057.417, "mem_avail_kib": 26375044, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056058.455, "mem_avail_kib": 26375952, "swap_free_kib": 7056424, "vram_used": [16562462720,15780212736]}
+{"t": 1791056059.493, "mem_avail_kib": 26388432, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056060.529, "mem_avail_kib": 26399116, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056061.567, "mem_avail_kib": 26401792, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056062.604, "mem_avail_kib": 26410992, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056063.642, "mem_avail_kib": 26428944, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056064.681, "mem_avail_kib": 26443564, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056065.717, "mem_avail_kib": 26440300, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056066.757, "mem_avail_kib": 26445148, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056067.795, "mem_avail_kib": 26447432, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056068.836, "mem_avail_kib": 26459360, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056069.874, "mem_avail_kib": 27160652, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056070.912, "mem_avail_kib": 27153184, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056071.949, "mem_avail_kib": 27156136, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056072.989, "mem_avail_kib": 27163684, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056074.027, "mem_avail_kib": 27160772, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056075.065, "mem_avail_kib": 27168152, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056076.102, "mem_avail_kib": 27152460, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056077.142, "mem_avail_kib": 27161560, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056078.180, "mem_avail_kib": 27156604, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056079.220, "mem_avail_kib": 27155992, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056080.261, "mem_avail_kib": 27148936, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056081.299, "mem_avail_kib": 27154572, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056082.335, "mem_avail_kib": 27135620, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056083.372, "mem_avail_kib": 27147816, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056084.413, "mem_avail_kib": 27152776, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056085.451, "mem_avail_kib": 27159968, "swap_free_kib": 7056680, "vram_used": [16562462720,15780212736]}
+{"t": 1791056086.486, "mem_avail_kib": 27163956, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056087.524, "mem_avail_kib": 27164560, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056088.562, "mem_avail_kib": 27160000, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056089.602, "mem_avail_kib": 27156140, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056090.643, "mem_avail_kib": 27162940, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056091.682, "mem_avail_kib": 27167484, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056092.719, "mem_avail_kib": 27172256, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056093.757, "mem_avail_kib": 27179256, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056094.796, "mem_avail_kib": 27172512, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056095.833, "mem_avail_kib": 27161336, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056096.875, "mem_avail_kib": 27093708, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056097.919, "mem_avail_kib": 27099928, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056098.958, "mem_avail_kib": 27105480, "swap_free_kib": 7057192, "vram_used": [16562462720,15780212736]}
+{"t": 1791056099.997, "mem_avail_kib": 27107512, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056101.034, "mem_avail_kib": 27105132, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056102.073, "mem_avail_kib": 27057848, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056103.110, "mem_avail_kib": 27054940, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056104.149, "mem_avail_kib": 27061248, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056105.186, "mem_avail_kib": 27052944, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056106.224, "mem_avail_kib": 27044384, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056107.264, "mem_avail_kib": 27051396, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056108.301, "mem_avail_kib": 27057152, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056109.342, "mem_avail_kib": 27047636, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056110.380, "mem_avail_kib": 27044136, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056111.416, "mem_avail_kib": 27051244, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056112.452, "mem_avail_kib": 27037292, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056113.488, "mem_avail_kib": 27041392, "swap_free_kib": 7057704, "vram_used": [16562462720,15780212736]}
+{"t": 1791056114.526, "mem_avail_kib": 27052100, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056115.562, "mem_avail_kib": 27060292, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056116.603, "mem_avail_kib": 27052104, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056117.642, "mem_avail_kib": 27053824, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056118.682, "mem_avail_kib": 27056972, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056119.722, "mem_avail_kib": 27060620, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056120.763, "mem_avail_kib": 27058968, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056121.800, "mem_avail_kib": 27053384, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056122.837, "mem_avail_kib": 27049972, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056123.874, "mem_avail_kib": 27048216, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056124.911, "mem_avail_kib": 27045528, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056125.945, "mem_avail_kib": 26974804, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056126.982, "mem_avail_kib": 26973428, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056128.020, "mem_avail_kib": 26981284, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056129.063, "mem_avail_kib": 26984344, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056130.099, "mem_avail_kib": 26974396, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056131.139, "mem_avail_kib": 26928388, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056132.176, "mem_avail_kib": 26921292, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056133.214, "mem_avail_kib": 26915944, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056134.251, "mem_avail_kib": 26923340, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056135.288, "mem_avail_kib": 26926644, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056136.325, "mem_avail_kib": 26922148, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056137.366, "mem_avail_kib": 26926284, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056138.404, "mem_avail_kib": 26935284, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056139.442, "mem_avail_kib": 26934052, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056140.480, "mem_avail_kib": 26932784, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056141.519, "mem_avail_kib": 26937600, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056142.558, "mem_avail_kib": 26947676, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056143.594, "mem_avail_kib": 26917988, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056144.632, "mem_avail_kib": 26926396, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056145.670, "mem_avail_kib": 26924076, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056146.708, "mem_avail_kib": 26932924, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056147.746, "mem_avail_kib": 26936096, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056148.782, "mem_avail_kib": 26927864, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056149.819, "mem_avail_kib": 26934088, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056150.859, "mem_avail_kib": 26938940, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056151.897, "mem_avail_kib": 26936200, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056152.936, "mem_avail_kib": 26930348, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056153.975, "mem_avail_kib": 26935920, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056155.015, "mem_avail_kib": 26856312, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056156.052, "mem_avail_kib": 26859028, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056157.088, "mem_avail_kib": 26862032, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056158.125, "mem_avail_kib": 26874388, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056159.164, "mem_avail_kib": 26875240, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056160.202, "mem_avail_kib": 26874388, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056161.240, "mem_avail_kib": 26827160, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056162.280, "mem_avail_kib": 26830064, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056163.322, "mem_avail_kib": 26829156, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056164.360, "mem_avail_kib": 26827184, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056165.397, "mem_avail_kib": 26827320, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056166.433, "mem_avail_kib": 26828344, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056167.470, "mem_avail_kib": 26830900, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056168.506, "mem_avail_kib": 26835156, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056169.541, "mem_avail_kib": 26822744, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056170.579, "mem_avail_kib": 26825920, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056171.615, "mem_avail_kib": 26832956, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056172.657, "mem_avail_kib": 26830464, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056173.697, "mem_avail_kib": 26830576, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056174.734, "mem_avail_kib": 26834536, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056175.773, "mem_avail_kib": 26834636, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056176.811, "mem_avail_kib": 26838456, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056177.847, "mem_avail_kib": 26845532, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056178.885, "mem_avail_kib": 26849912, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056179.924, "mem_avail_kib": 26842688, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056180.965, "mem_avail_kib": 26845188, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056182.004, "mem_avail_kib": 26848964, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056183.045, "mem_avail_kib": 26858260, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056184.085, "mem_avail_kib": 26851808, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056185.122, "mem_avail_kib": 26794952, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056186.159, "mem_avail_kib": 26770972, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056187.199, "mem_avail_kib": 26772204, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056188.239, "mem_avail_kib": 26770428, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056189.274, "mem_avail_kib": 26777372, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056190.313, "mem_avail_kib": 26772236, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056191.350, "mem_avail_kib": 26722336, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056192.390, "mem_avail_kib": 26723520, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056193.428, "mem_avail_kib": 26728316, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056194.466, "mem_avail_kib": 26728640, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056195.509, "mem_avail_kib": 26717428, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056196.547, "mem_avail_kib": 26720120, "swap_free_kib": 7057960, "vram_used": [16562462720,15780212736]}
+{"t": 1791056197.586, "mem_avail_kib": 26727212, "swap_free_kib": 7058216, "vram_used": [16562462720,15780212736]}
+{"t": 1791056198.624, "mem_avail_kib": 26719348, "swap_free_kib": 7058216, "vram_used": [16562462720,15780212736]}
+{"t": 1791056199.662, "mem_avail_kib": 26726360, "swap_free_kib": 7058216, "vram_used": [16562462720,15780212736]}
+{"t": 1791056200.702, "mem_avail_kib": 26727572, "swap_free_kib": 7058216, "vram_used": [16562462720,15780212736]}
+{"t": 1791056201.740, "mem_avail_kib": 26726808, "swap_free_kib": 7058216, "vram_used": [16562462720,15780212736]}
+{"t": 1791056202.777, "mem_avail_kib": 26710352, "swap_free_kib": 7058216, "vram_used": [16562462720,15780212736]}
+{"t": 1791056203.817, "mem_avail_kib": 26715956, "swap_free_kib": 7058216, "vram_used": [16562462720,15780212736]}
+{"t": 1791056204.856, "mem_avail_kib": 26686244, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056205.892, "mem_avail_kib": 26692748, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056206.928, "mem_avail_kib": 26691344, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056207.967, "mem_avail_kib": 26706640, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056209.007, "mem_avail_kib": 26711480, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056210.046, "mem_avail_kib": 26714560, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056211.089, "mem_avail_kib": 26715768, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056212.127, "mem_avail_kib": 26714748, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056213.166, "mem_avail_kib": 26716824, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056214.205, "mem_avail_kib": 26722028, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056215.242, "mem_avail_kib": 26719692, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056216.281, "mem_avail_kib": 26661052, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056217.316, "mem_avail_kib": 26629380, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056218.353, "mem_avail_kib": 26627628, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056219.389, "mem_avail_kib": 26629080, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056220.430, "mem_avail_kib": 26634212, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056221.468, "mem_avail_kib": 26627980, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056222.508, "mem_avail_kib": 26578936, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056223.546, "mem_avail_kib": 26591204, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056224.588, "mem_avail_kib": 26593616, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056225.627, "mem_avail_kib": 26594836, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056226.665, "mem_avail_kib": 26589616, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056227.698, "mem_avail_kib": 26599280, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056228.736, "mem_avail_kib": 26600436, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056229.774, "mem_avail_kib": 26603676, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056230.813, "mem_avail_kib": 26605368, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056231.851, "mem_avail_kib": 26606572, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056232.888, "mem_avail_kib": 26601284, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056233.926, "mem_avail_kib": 26606720, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056234.967, "mem_avail_kib": 26618212, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056236.005, "mem_avail_kib": 26608300, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056237.044, "mem_avail_kib": 26604076, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056238.081, "mem_avail_kib": 26603260, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056239.119, "mem_avail_kib": 26598644, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056240.158, "mem_avail_kib": 26603504, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056241.197, "mem_avail_kib": 26608884, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056242.237, "mem_avail_kib": 26600264, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056243.276, "mem_avail_kib": 26590872, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056244.317, "mem_avail_kib": 26595832, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056245.357, "mem_avail_kib": 26603048, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056246.395, "mem_avail_kib": 26608292, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056247.432, "mem_avail_kib": 26600056, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056248.473, "mem_avail_kib": 26522500, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056249.511, "mem_avail_kib": 26514572, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056250.549, "mem_avail_kib": 26515632, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056251.585, "mem_avail_kib": 26524244, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056252.624, "mem_avail_kib": 26524988, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056253.666, "mem_avail_kib": 26524348, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056254.703, "mem_avail_kib": 26516788, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056255.740, "mem_avail_kib": 26469156, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056256.780, "mem_avail_kib": 26469304, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056257.816, "mem_avail_kib": 26461976, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056258.853, "mem_avail_kib": 26462492, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056259.890, "mem_avail_kib": 26466816, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056260.927, "mem_avail_kib": 26451764, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056261.966, "mem_avail_kib": 26455332, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056263.004, "mem_avail_kib": 26462756, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056264.040, "mem_avail_kib": 26465096, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056265.077, "mem_avail_kib": 26459236, "swap_free_kib": 7058472, "vram_used": [16562462720,15780212736]}
+{"t": 1791056266.117, "mem_avail_kib": 26445820, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056267.154, "mem_avail_kib": 26454668, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056268.190, "mem_avail_kib": 26466980, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056269.229, "mem_avail_kib": 26464116, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056270.267, "mem_avail_kib": 26466836, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056271.306, "mem_avail_kib": 26468528, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056272.347, "mem_avail_kib": 26471008, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056273.385, "mem_avail_kib": 26463288, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056274.424, "mem_avail_kib": 26465856, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056275.463, "mem_avail_kib": 26485364, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056276.500, "mem_avail_kib": 26483660, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056277.540, "mem_avail_kib": 26489328, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056278.580, "mem_avail_kib": 26493088, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056279.622, "mem_avail_kib": 26484592, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056280.659, "mem_avail_kib": 26474480, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056281.695, "mem_avail_kib": 26409780, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056282.733, "mem_avail_kib": 26404464, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056283.771, "mem_avail_kib": 26405244, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056284.810, "mem_avail_kib": 26409472, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056285.849, "mem_avail_kib": 26410444, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056286.886, "mem_avail_kib": 26409880, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056287.922, "mem_avail_kib": 26394588, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056288.958, "mem_avail_kib": 26407828, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056289.996, "mem_avail_kib": 26419372, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056291.034, "mem_avail_kib": 26421544, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056292.074, "mem_avail_kib": 26441988, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056293.110, "mem_avail_kib": 26451816, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056294.149, "mem_avail_kib": 26449856, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056295.192, "mem_avail_kib": 26460632, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056296.230, "mem_avail_kib": 26468496, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056297.268, "mem_avail_kib": 26476476, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056298.306, "mem_avail_kib": 26483440, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056299.344, "mem_avail_kib": 26481528, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056300.383, "mem_avail_kib": 26472552, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056301.422, "mem_avail_kib": 26475336, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056302.459, "mem_avail_kib": 26471252, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056303.495, "mem_avail_kib": 26474984, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056304.532, "mem_avail_kib": 26483512, "swap_free_kib": 7058728, "vram_used": [16562462720,15780212736]}
+{"t": 1791056305.569, "mem_avail_kib": 26487368, "swap_free_kib": 7058984, "vram_used": [16562462720,15780212736]}
+{"t": 1791056306.611, "mem_avail_kib": 26480544, "swap_free_kib": 7058984, "vram_used": [16562462720,15780212736]}
+{"t": 1791056307.649, "mem_avail_kib": 26491436, "swap_free_kib": 7058984, "vram_used": [16562462720,15780212736]}
+{"t": 1791056308.686, "mem_avail_kib": 26500192, "swap_free_kib": 7058984, "vram_used": [16562462720,15780212736]}
+{"t": 1791056309.728, "mem_avail_kib": 26503584, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056310.768, "mem_avail_kib": 26502732, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056311.806, "mem_avail_kib": 26501228, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056312.844, "mem_avail_kib": 26496984, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056313.880, "mem_avail_kib": 26487656, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056314.921, "mem_avail_kib": 26494244, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056315.959, "mem_avail_kib": 26431008, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056316.997, "mem_avail_kib": 26367740, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056318.038, "mem_avail_kib": 26378032, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056319.077, "mem_avail_kib": 26389748, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056320.111, "mem_avail_kib": 26396336, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056321.148, "mem_avail_kib": 26409160, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056322.187, "mem_avail_kib": 26423448, "swap_free_kib": 7059240, "vram_used": [16562462720,15780212736]}
+{"t": 1791056323.226, "mem_avail_kib": 26427008, "swap_free_kib": 7059752, "vram_used": [16562462720,15780212736]}
+{"t": 1791056324.262, "mem_avail_kib": 26436748, "swap_free_kib": 7059752, "vram_used": [16562462720,15780212736]}
+{"t": 1791056325.298, "mem_avail_kib": 26436444, "swap_free_kib": 7059752, "vram_used": [16562462720,15780212736]}
+{"t": 1791056326.338, "mem_avail_kib": 26419732, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056327.376, "mem_avail_kib": 26418196, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056328.414, "mem_avail_kib": 27107208, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056329.453, "mem_avail_kib": 27114624, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056330.493, "mem_avail_kib": 27109288, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056331.531, "mem_avail_kib": 27115992, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056332.567, "mem_avail_kib": 27128344, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056333.603, "mem_avail_kib": 27130812, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056334.639, "mem_avail_kib": 27139632, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056335.677, "mem_avail_kib": 27142536, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056336.718, "mem_avail_kib": 27144676, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056337.755, "mem_avail_kib": 27157084, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056338.794, "mem_avail_kib": 27163160, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056339.831, "mem_avail_kib": 27161192, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056340.869, "mem_avail_kib": 27167648, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056341.907, "mem_avail_kib": 27164496, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056342.942, "mem_avail_kib": 27166156, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056343.979, "mem_avail_kib": 27159380, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056345.020, "mem_avail_kib": 27171320, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056346.059, "mem_avail_kib": 27172392, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056347.095, "mem_avail_kib": 27168528, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056348.134, "mem_avail_kib": 27172960, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056349.175, "mem_avail_kib": 27167676, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056350.213, "mem_avail_kib": 27161588, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056351.250, "mem_avail_kib": 27165364, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056352.289, "mem_avail_kib": 27156116, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056353.327, "mem_avail_kib": 27157784, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056354.365, "mem_avail_kib": 27158216, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056355.405, "mem_avail_kib": 27157900, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056356.443, "mem_avail_kib": 27085888, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056357.482, "mem_avail_kib": 27093184, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056358.519, "mem_avail_kib": 27097112, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056359.557, "mem_avail_kib": 27092320, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056360.598, "mem_avail_kib": 27083200, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056361.636, "mem_avail_kib": 27031248, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056362.674, "mem_avail_kib": 27039524, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056363.716, "mem_avail_kib": 27049964, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056364.752, "mem_avail_kib": 27052340, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056365.789, "mem_avail_kib": 27054632, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056366.826, "mem_avail_kib": 27055652, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056367.864, "mem_avail_kib": 27043812, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056368.903, "mem_avail_kib": 27043312, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056369.944, "mem_avail_kib": 27037444, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056370.980, "mem_avail_kib": 27041392, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056372.021, "mem_avail_kib": 27039728, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056373.060, "mem_avail_kib": 27049312, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056374.097, "mem_avail_kib": 27052924, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056375.134, "mem_avail_kib": 27053828, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056376.169, "mem_avail_kib": 27056776, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056377.205, "mem_avail_kib": 27046544, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056378.241, "mem_avail_kib": 27056468, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056379.279, "mem_avail_kib": 27058276, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056380.317, "mem_avail_kib": 27064068, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056381.354, "mem_avail_kib": 27059344, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056382.390, "mem_avail_kib": 27072476, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056383.425, "mem_avail_kib": 27065612, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056384.463, "mem_avail_kib": 26998608, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056385.500, "mem_avail_kib": 26991096, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056386.536, "mem_avail_kib": 26991280, "swap_free_kib": 7060008, "vram_used": [16562462720,15780212736]}
+{"t": 1791056387.573, "mem_avail_kib": 26952792, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056388.610, "mem_avail_kib": 26957176, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056389.647, "mem_avail_kib": 26966136, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056390.684, "mem_avail_kib": 26924604, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056391.724, "mem_avail_kib": 26929364, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056392.764, "mem_avail_kib": 26931860, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056393.805, "mem_avail_kib": 26933268, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056394.841, "mem_avail_kib": 26925500, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056395.881, "mem_avail_kib": 26938012, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056396.918, "mem_avail_kib": 26929728, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056397.955, "mem_avail_kib": 26932664, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056398.994, "mem_avail_kib": 26918164, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056400.031, "mem_avail_kib": 26929620, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056401.068, "mem_avail_kib": 26933580, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056402.106, "mem_avail_kib": 26939924, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056403.145, "mem_avail_kib": 26930428, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056404.184, "mem_avail_kib": 26938356, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056405.223, "mem_avail_kib": 26943280, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056406.260, "mem_avail_kib": 26946776, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056407.295, "mem_avail_kib": 26936652, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056408.331, "mem_avail_kib": 26941648, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056409.366, "mem_avail_kib": 26939432, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056410.404, "mem_avail_kib": 26933112, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056411.442, "mem_avail_kib": 26941192, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056412.482, "mem_avail_kib": 26934848, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056413.520, "mem_avail_kib": 26937536, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056414.557, "mem_avail_kib": 26862620, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056415.597, "mem_avail_kib": 26867168, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056416.634, "mem_avail_kib": 26860468, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056417.672, "mem_avail_kib": 26860476, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056418.716, "mem_avail_kib": 26859876, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056419.754, "mem_avail_kib": 26816916, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056420.791, "mem_avail_kib": 26814644, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056421.828, "mem_avail_kib": 26819700, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056422.867, "mem_avail_kib": 26830876, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056423.907, "mem_avail_kib": 26831360, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056424.948, "mem_avail_kib": 26829344, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056425.987, "mem_avail_kib": 26834072, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056427.024, "mem_avail_kib": 26834624, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056428.062, "mem_avail_kib": 26826336, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056429.098, "mem_avail_kib": 26823144, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056430.135, "mem_avail_kib": 26817984, "swap_free_kib": 7060264, "vram_used": [16562462720,15780212736]}
+{"t": 1791056431.173, "mem_avail_kib": 26825216, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056432.209, "mem_avail_kib": 26819872, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056433.248, "mem_avail_kib": 26812552, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056434.285, "mem_avail_kib": 26812104, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056435.327, "mem_avail_kib": 26810664, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056436.366, "mem_avail_kib": 26818768, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056437.404, "mem_avail_kib": 26827684, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056438.441, "mem_avail_kib": 26825888, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056439.479, "mem_avail_kib": 26827940, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056440.519, "mem_avail_kib": 26823660, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056441.558, "mem_avail_kib": 26820544, "swap_free_kib": 7060520, "vram_used": [16562462720,15780212736]}
+{"t": 1791056442.594, "mem_avail_kib": 26825624, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056443.632, "mem_avail_kib": 26816164, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056444.670, "mem_avail_kib": 26748920, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056445.706, "mem_avail_kib": 26759020, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056446.743, "mem_avail_kib": 26761728, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056447.779, "mem_avail_kib": 26756980, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056448.816, "mem_avail_kib": 26723176, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056449.853, "mem_avail_kib": 26727756, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056450.895, "mem_avail_kib": 26690000, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056451.937, "mem_avail_kib": 26688288, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056452.977, "mem_avail_kib": 26686608, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056454.016, "mem_avail_kib": 26690356, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056455.055, "mem_avail_kib": 26693484, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056456.093, "mem_avail_kib": 26690756, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056457.132, "mem_avail_kib": 26687712, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056458.166, "mem_avail_kib": 26703320, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056459.204, "mem_avail_kib": 26695548, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056460.245, "mem_avail_kib": 26699980, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056461.284, "mem_avail_kib": 26693664, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056462.323, "mem_avail_kib": 26703528, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056463.360, "mem_avail_kib": 26705700, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056464.395, "mem_avail_kib": 26704740, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056465.431, "mem_avail_kib": 26707748, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056466.469, "mem_avail_kib": 26706972, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056467.507, "mem_avail_kib": 26712500, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056468.545, "mem_avail_kib": 26687612, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056469.582, "mem_avail_kib": 26693524, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056470.620, "mem_avail_kib": 26688688, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056471.661, "mem_avail_kib": 26687240, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056472.701, "mem_avail_kib": 26684420, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056473.739, "mem_avail_kib": 26696344, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056474.779, "mem_avail_kib": 26701084, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056475.820, "mem_avail_kib": 26625316, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056476.862, "mem_avail_kib": 26632568, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056477.899, "mem_avail_kib": 26631368, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056478.935, "mem_avail_kib": 26643548, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056479.972, "mem_avail_kib": 26646812, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056481.011, "mem_avail_kib": 26649036, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056482.047, "mem_avail_kib": 26597980, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056483.084, "mem_avail_kib": 26597352, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056484.122, "mem_avail_kib": 26601356, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056485.162, "mem_avail_kib": 26600404, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056486.201, "mem_avail_kib": 26591804, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056487.238, "mem_avail_kib": 26594940, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056488.275, "mem_avail_kib": 26589616, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056489.313, "mem_avail_kib": 26595720, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056490.349, "mem_avail_kib": 26586124, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056491.388, "mem_avail_kib": 26585280, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056492.426, "mem_avail_kib": 26595164, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056493.463, "mem_avail_kib": 26599156, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056494.501, "mem_avail_kib": 26591680, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056495.537, "mem_avail_kib": 26584956, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056496.576, "mem_avail_kib": 26581144, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056497.614, "mem_avail_kib": 26596828, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056498.652, "mem_avail_kib": 26606072, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056499.689, "mem_avail_kib": 26607228, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056500.727, "mem_avail_kib": 26596608, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056501.766, "mem_avail_kib": 26602828, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056502.804, "mem_avail_kib": 26584404, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056503.842, "mem_avail_kib": 26594072, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056504.884, "mem_avail_kib": 26606064, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056505.920, "mem_avail_kib": 26612924, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056506.957, "mem_avail_kib": 26614020, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056507.994, "mem_avail_kib": 26529720, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056509.030, "mem_avail_kib": 26510572, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056510.066, "mem_avail_kib": 26494876, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056511.105, "mem_avail_kib": 26503716, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056512.143, "mem_avail_kib": 26509276, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056513.182, "mem_avail_kib": 26512172, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056514.218, "mem_avail_kib": 26468064, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056515.255, "mem_avail_kib": 26462744, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056516.292, "mem_avail_kib": 26459220, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056517.328, "mem_avail_kib": 26458756, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056518.364, "mem_avail_kib": 26468484, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056519.400, "mem_avail_kib": 26467308, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056520.440, "mem_avail_kib": 26474920, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056521.479, "mem_avail_kib": 26465712, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056522.514, "mem_avail_kib": 26457924, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056523.552, "mem_avail_kib": 26458152, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056524.588, "mem_avail_kib": 26467844, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056525.624, "mem_avail_kib": 26473036, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056526.660, "mem_avail_kib": 26476116, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056527.698, "mem_avail_kib": 26470296, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056528.734, "mem_avail_kib": 26478668, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056529.773, "mem_avail_kib": 26480912, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056530.810, "mem_avail_kib": 26473896, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056531.846, "mem_avail_kib": 26483160, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056532.883, "mem_avail_kib": 26494944, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056533.920, "mem_avail_kib": 26490612, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056534.958, "mem_avail_kib": 26491580, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056535.997, "mem_avail_kib": 26492172, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056537.034, "mem_avail_kib": 26481332, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056538.069, "mem_avail_kib": 26477212, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056539.105, "mem_avail_kib": 26484236, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056540.142, "mem_avail_kib": 26480300, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056541.187, "mem_avail_kib": 26413868, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056542.226, "mem_avail_kib": 26419576, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056543.262, "mem_avail_kib": 26419544, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056544.299, "mem_avail_kib": 26417352, "swap_free_kib": 7060776, "vram_used": [16562462720,15780212736]}
+{"t": 1791056545.338, "mem_avail_kib": 26410184, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056546.373, "mem_avail_kib": 26411440, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056547.410, "mem_avail_kib": 26364204, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056548.449, "mem_avail_kib": 26429448, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056549.488, "mem_avail_kib": 26442104, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056550.524, "mem_avail_kib": 26445372, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056551.560, "mem_avail_kib": 26444128, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056552.602, "mem_avail_kib": 26447020, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056553.639, "mem_avail_kib": 26449068, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056554.677, "mem_avail_kib": 26459664, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056555.715, "mem_avail_kib": 26464968, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056556.758, "mem_avail_kib": 26466256, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056557.800, "mem_avail_kib": 26475960, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056558.839, "mem_avail_kib": 26470196, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056559.878, "mem_avail_kib": 26470076, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056560.916, "mem_avail_kib": 26470032, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056561.952, "mem_avail_kib": 26473744, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056562.991, "mem_avail_kib": 26483032, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056564.028, "mem_avail_kib": 26476552, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056565.065, "mem_avail_kib": 26478520, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056566.103, "mem_avail_kib": 26480716, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056567.143, "mem_avail_kib": 26483992, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056568.179, "mem_avail_kib": 26482280, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056569.214, "mem_avail_kib": 26474100, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056570.253, "mem_avail_kib": 26466308, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056571.290, "mem_avail_kib": 26456712, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056572.328, "mem_avail_kib": 26460928, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056573.365, "mem_avail_kib": 26455888, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056574.401, "mem_avail_kib": 26459284, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056575.438, "mem_avail_kib": 26330540, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056576.475, "mem_avail_kib": 26333524, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056577.512, "mem_avail_kib": 26345164, "swap_free_kib": 7061032, "vram_used": [16562462720,15780212736]}
+{"t": 1791056578.550, "mem_avail_kib": 26356328, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056579.586, "mem_avail_kib": 26368708, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056580.623, "mem_avail_kib": 26378068, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056581.662, "mem_avail_kib": 26536596, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056582.701, "mem_avail_kib": 27246944, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056583.735, "mem_avail_kib": 27246184, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056584.770, "mem_avail_kib": 27246884, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056585.808, "mem_avail_kib": 27249868, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056586.845, "mem_avail_kib": 27264692, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056587.884, "mem_avail_kib": 27278076, "swap_free_kib": 7061288, "vram_used": [16562462720,15780212736]}
+{"t": 1791056588.921, "mem_avail_kib": 27278280, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056589.959, "mem_avail_kib": 27286908, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056590.996, "mem_avail_kib": 27280784, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056592.030, "mem_avail_kib": 27284304, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056593.068, "mem_avail_kib": 27289076, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056594.105, "mem_avail_kib": 27295184, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056595.141, "mem_avail_kib": 27295844, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056596.179, "mem_avail_kib": 27300520, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056597.216, "mem_avail_kib": 27308812, "swap_free_kib": 7061544, "vram_used": [16562462720,15780212736]}
+{"t": 1791056598.249, "mem_avail_kib": 27309044, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056599.285, "mem_avail_kib": 27293708, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056600.322, "mem_avail_kib": 27290800, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056601.359, "mem_avail_kib": 27299680, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056602.398, "mem_avail_kib": 27287892, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056603.436, "mem_avail_kib": 27281440, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056604.475, "mem_avail_kib": 27283740, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056605.510, "mem_avail_kib": 27287336, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056606.546, "mem_avail_kib": 27291688, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056607.585, "mem_avail_kib": 27298488, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056608.624, "mem_avail_kib": 27293124, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056609.663, "mem_avail_kib": 27289564, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056610.704, "mem_avail_kib": 27290292, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056611.741, "mem_avail_kib": 27218636, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056612.778, "mem_avail_kib": 27228940, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056613.817, "mem_avail_kib": 27228884, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056614.854, "mem_avail_kib": 27217352, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056615.891, "mem_avail_kib": 27221384, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056616.929, "mem_avail_kib": 27180548, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056617.967, "mem_avail_kib": 27180168, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056619.000, "mem_avail_kib": 27168504, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056620.034, "mem_avail_kib": 27171584, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056621.071, "mem_avail_kib": 27170460, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056622.109, "mem_avail_kib": 27172984, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056623.149, "mem_avail_kib": 27167644, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056624.187, "mem_avail_kib": 27168872, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056625.226, "mem_avail_kib": 27174068, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056626.266, "mem_avail_kib": 27168032, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056627.304, "mem_avail_kib": 27177440, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056628.342, "mem_avail_kib": 27175160, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056629.376, "mem_avail_kib": 27183136, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056630.416, "mem_avail_kib": 27183732, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056631.458, "mem_avail_kib": 27145584, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056632.494, "mem_avail_kib": 27153072, "swap_free_kib": 7062056, "vram_used": [16562462720,15780212736]}
+{"t": 1791056633.537, "mem_avail_kib": 27029244, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056634.576, "mem_avail_kib": 27165880, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056635.613, "mem_avail_kib": 27171352, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056636.649, "mem_avail_kib": 27174252, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056637.688, "mem_avail_kib": 27178088, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056638.731, "mem_avail_kib": 27176348, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056639.768, "mem_avail_kib": 27193528, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056640.806, "mem_avail_kib": 27212948, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056641.844, "mem_avail_kib": 27230136, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056642.880, "mem_avail_kib": 27243648, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056643.917, "mem_avail_kib": 27245220, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056644.957, "mem_avail_kib": 27256476, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056645.996, "mem_avail_kib": 27262944, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056647.033, "mem_avail_kib": 27260152, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056648.072, "mem_avail_kib": 27276816, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056649.111, "mem_avail_kib": 27286940, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056650.146, "mem_avail_kib": 27282488, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056651.183, "mem_avail_kib": 27276668, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056652.222, "mem_avail_kib": 27279968, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056653.259, "mem_avail_kib": 27277928, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056654.295, "mem_avail_kib": 27289496, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056655.335, "mem_avail_kib": 27284848, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056656.378, "mem_avail_kib": 27285108, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056657.419, "mem_avail_kib": 27294072, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056658.457, "mem_avail_kib": 27299836, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056659.493, "mem_avail_kib": 27302556, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056660.531, "mem_avail_kib": 27293024, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056661.570, "mem_avail_kib": 27282192, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056662.609, "mem_avail_kib": 27224944, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056663.645, "mem_avail_kib": 27226892, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056664.682, "mem_avail_kib": 27217612, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056665.721, "mem_avail_kib": 27217320, "swap_free_kib": 7062056, "vram_used": [16562462720,15780356096]}
+{"t": 1791056666.759, "mem_avail_kib": 27220928, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056667.797, "mem_avail_kib": 27166284, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056668.835, "mem_avail_kib": 27169080, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056669.872, "mem_avail_kib": 27157400, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056670.911, "mem_avail_kib": 27161852, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056671.949, "mem_avail_kib": 27164192, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056672.991, "mem_avail_kib": 27171880, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056674.032, "mem_avail_kib": 27175900, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056675.071, "mem_avail_kib": 27180048, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056676.108, "mem_avail_kib": 27178816, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056677.146, "mem_avail_kib": 27183336, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056678.188, "mem_avail_kib": 27182644, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056679.225, "mem_avail_kib": 27180336, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056680.260, "mem_avail_kib": 27171328, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056681.298, "mem_avail_kib": 27160368, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056682.336, "mem_avail_kib": 27166876, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056683.375, "mem_avail_kib": 27160132, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056684.415, "mem_avail_kib": 27044632, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056685.456, "mem_avail_kib": 27183840, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056686.494, "mem_avail_kib": 27183056, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056687.531, "mem_avail_kib": 27206132, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056688.567, "mem_avail_kib": 27208128, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056689.603, "mem_avail_kib": 27216564, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056690.640, "mem_avail_kib": 27224072, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056691.678, "mem_avail_kib": 27237128, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056692.716, "mem_avail_kib": 27225840, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056693.755, "mem_avail_kib": 27232016, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056694.792, "mem_avail_kib": 27232940, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056695.830, "mem_avail_kib": 27249248, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056696.867, "mem_avail_kib": 27255452, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056697.908, "mem_avail_kib": 27250532, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056698.948, "mem_avail_kib": 27254992, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056699.985, "mem_avail_kib": 27262772, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056701.023, "mem_avail_kib": 27273692, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056702.062, "mem_avail_kib": 27269552, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056703.102, "mem_avail_kib": 27273960, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056704.139, "mem_avail_kib": 27279876, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056705.175, "mem_avail_kib": 27284552, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056706.213, "mem_avail_kib": 27285008, "swap_free_kib": 7063336, "vram_used": [16562462720,15780356096]}
+{"t": 1791056707.248, "mem_avail_kib": 27283904, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056708.285, "mem_avail_kib": 27289520, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056709.325, "mem_avail_kib": 27298924, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056710.364, "mem_avail_kib": 27298892, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056711.400, "mem_avail_kib": 27302720, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056712.436, "mem_avail_kib": 27306144, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056713.473, "mem_avail_kib": 27234856, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056714.514, "mem_avail_kib": 27236428, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056715.552, "mem_avail_kib": 27222628, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056716.590, "mem_avail_kib": 27221956, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056717.628, "mem_avail_kib": 27220216, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056718.666, "mem_avail_kib": 27172220, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056719.706, "mem_avail_kib": 27169292, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056720.744, "mem_avail_kib": 27159108, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056721.782, "mem_avail_kib": 27167504, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056722.823, "mem_avail_kib": 27168972, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056723.860, "mem_avail_kib": 27177064, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056724.897, "mem_avail_kib": 27167316, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056725.935, "mem_avail_kib": 27175624, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056726.974, "mem_avail_kib": 27170764, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056728.013, "mem_avail_kib": 27176080, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056729.052, "mem_avail_kib": 27178284, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056730.090, "mem_avail_kib": 27170172, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056731.130, "mem_avail_kib": 27172264, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056732.166, "mem_avail_kib": 27176696, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056733.210, "mem_avail_kib": 27173748, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056734.251, "mem_avail_kib": 27178952, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056735.291, "mem_avail_kib": 27064460, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056736.329, "mem_avail_kib": 27049120, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056737.367, "mem_avail_kib": 27181884, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056738.404, "mem_avail_kib": 27182768, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056739.442, "mem_avail_kib": 27179504, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056740.476, "mem_avail_kib": 27181000, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056741.517, "mem_avail_kib": 27193236, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056742.556, "mem_avail_kib": 27210160, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056743.597, "mem_avail_kib": 27222064, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056744.637, "mem_avail_kib": 27221168, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056745.676, "mem_avail_kib": 27238156, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056746.715, "mem_avail_kib": 27243164, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056747.751, "mem_avail_kib": 27249048, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056748.786, "mem_avail_kib": 27264620, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056749.822, "mem_avail_kib": 27253444, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056750.861, "mem_avail_kib": 27260392, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056751.901, "mem_avail_kib": 27267208, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056752.939, "mem_avail_kib": 27254616, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056753.976, "mem_avail_kib": 27273968, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056755.016, "mem_avail_kib": 27280096, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056756.056, "mem_avail_kib": 27290644, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056757.095, "mem_avail_kib": 27280580, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056758.131, "mem_avail_kib": 27283628, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056759.167, "mem_avail_kib": 27274432, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056760.205, "mem_avail_kib": 27276652, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056761.242, "mem_avail_kib": 27270784, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056762.281, "mem_avail_kib": 27272056, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056763.321, "mem_avail_kib": 27278708, "swap_free_kib": 7063592, "vram_used": [16562462720,15780356096]}
+{"t": 1791056764.358, "mem_avail_kib": 27197104, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056765.394, "mem_avail_kib": 27205764, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056766.429, "mem_avail_kib": 27211032, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056767.466, "mem_avail_kib": 27205440, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056768.502, "mem_avail_kib": 27197648, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056769.541, "mem_avail_kib": 27156648, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056770.581, "mem_avail_kib": 27150580, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056771.616, "mem_avail_kib": 27158092, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056772.655, "mem_avail_kib": 27151404, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056773.694, "mem_avail_kib": 27158576, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056774.732, "mem_avail_kib": 27167108, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056775.767, "mem_avail_kib": 27168544, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056776.804, "mem_avail_kib": 27169652, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056777.840, "mem_avail_kib": 27169424, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056778.874, "mem_avail_kib": 27171516, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056779.911, "mem_avail_kib": 27166564, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056780.948, "mem_avail_kib": 27161048, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056781.985, "mem_avail_kib": 27169404, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056783.023, "mem_avail_kib": 27176032, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056784.062, "mem_avail_kib": 27174376, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056785.098, "mem_avail_kib": 27170624, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056786.134, "mem_avail_kib": 27167532, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056787.173, "mem_avail_kib": 27166348, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056788.210, "mem_avail_kib": 27172048, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056789.248, "mem_avail_kib": 27175260, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056790.288, "mem_avail_kib": 27173536, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056791.325, "mem_avail_kib": 27165868, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056792.368, "mem_avail_kib": 27152892, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056793.406, "mem_avail_kib": 27030652, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056794.439, "mem_avail_kib": 27043260, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056795.478, "mem_avail_kib": 27043704, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056796.515, "mem_avail_kib": 27035424, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056797.553, "mem_avail_kib": 27040464, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056798.591, "mem_avail_kib": 27042400, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056799.628, "mem_avail_kib": 27039104, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056800.665, "mem_avail_kib": 27033548, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056801.700, "mem_avail_kib": 26978336, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056802.735, "mem_avail_kib": 26971956, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056803.775, "mem_avail_kib": 26980216, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056804.814, "mem_avail_kib": 26982276, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056805.852, "mem_avail_kib": 26986832, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056806.890, "mem_avail_kib": 26948560, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056807.930, "mem_avail_kib": 26948988, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056808.966, "mem_avail_kib": 26951868, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056810.002, "mem_avail_kib": 26940112, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056811.041, "mem_avail_kib": 26944016, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056812.079, "mem_avail_kib": 26949708, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056813.120, "mem_avail_kib": 26945644, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056814.159, "mem_avail_kib": 26943744, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056815.198, "mem_avail_kib": 26910316, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056816.233, "mem_avail_kib": 26899016, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056817.270, "mem_avail_kib": 26904452, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056818.305, "mem_avail_kib": 26910580, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056819.347, "mem_avail_kib": 26904028, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056820.385, "mem_avail_kib": 26902196, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056821.423, "mem_avail_kib": 26899152, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056822.461, "mem_avail_kib": 26906520, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056823.499, "mem_avail_kib": 26900256, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056824.533, "mem_avail_kib": 26893500, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056825.569, "mem_avail_kib": 26910824, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056826.606, "mem_avail_kib": 26912284, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056827.644, "mem_avail_kib": 26911564, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056828.681, "mem_avail_kib": 26919200, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056829.719, "mem_avail_kib": 26925228, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056830.758, "mem_avail_kib": 26929044, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056831.793, "mem_avail_kib": 26850008, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056832.831, "mem_avail_kib": 26852896, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056833.868, "mem_avail_kib": 26865016, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056834.907, "mem_avail_kib": 26864896, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056835.944, "mem_avail_kib": 26867136, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056836.982, "mem_avail_kib": 26817568, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056838.023, "mem_avail_kib": 26820036, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056839.060, "mem_avail_kib": 26811416, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056840.095, "mem_avail_kib": 26811936, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056841.130, "mem_avail_kib": 26812128, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056842.167, "mem_avail_kib": 26820260, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056843.208, "mem_avail_kib": 26815316, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056844.247, "mem_avail_kib": 26821004, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056845.281, "mem_avail_kib": 26835368, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056846.317, "mem_avail_kib": 26831480, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056847.353, "mem_avail_kib": 26825416, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056848.391, "mem_avail_kib": 26826536, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056849.430, "mem_avail_kib": 26831128, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056850.469, "mem_avail_kib": 26822828, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056851.507, "mem_avail_kib": 26813012, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056852.546, "mem_avail_kib": 26817212, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056853.583, "mem_avail_kib": 26819072, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056854.622, "mem_avail_kib": 26823168, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056855.660, "mem_avail_kib": 26827620, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056856.699, "mem_avail_kib": 26825708, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056857.738, "mem_avail_kib": 26816848, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056858.776, "mem_avail_kib": 26821920, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056859.815, "mem_avail_kib": 26826656, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056860.852, "mem_avail_kib": 26820228, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056861.887, "mem_avail_kib": 26744668, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056862.925, "mem_avail_kib": 26751844, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056863.964, "mem_avail_kib": 26759528, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056865.000, "mem_avail_kib": 26764416, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056866.043, "mem_avail_kib": 26754976, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056867.082, "mem_avail_kib": 26748568, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056868.121, "mem_avail_kib": 26709380, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056869.157, "mem_avail_kib": 26705412, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056870.193, "mem_avail_kib": 26687324, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056871.230, "mem_avail_kib": 26682380, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056872.266, "mem_avail_kib": 26685172, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056873.303, "mem_avail_kib": 26693936, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056874.340, "mem_avail_kib": 26691652, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056875.376, "mem_avail_kib": 26679728, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056876.414, "mem_avail_kib": 26694328, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056877.451, "mem_avail_kib": 26681808, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056878.488, "mem_avail_kib": 26674752, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056879.524, "mem_avail_kib": 26670300, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056880.560, "mem_avail_kib": 26677420, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056881.598, "mem_avail_kib": 26680136, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056882.637, "mem_avail_kib": 26673872, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056883.675, "mem_avail_kib": 26671700, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056884.713, "mem_avail_kib": 26680096, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056885.748, "mem_avail_kib": 26687196, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056886.786, "mem_avail_kib": 26686556, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056887.827, "mem_avail_kib": 26681220, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056888.862, "mem_avail_kib": 26677588, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056889.903, "mem_avail_kib": 26700524, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056890.945, "mem_avail_kib": 26704172, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056891.983, "mem_avail_kib": 26707240, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056893.020, "mem_avail_kib": 26636548, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056894.057, "mem_avail_kib": 26637328, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056895.095, "mem_avail_kib": 26641952, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056896.132, "mem_avail_kib": 26642128, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056897.170, "mem_avail_kib": 26642644, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056898.207, "mem_avail_kib": 26646672, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056899.244, "mem_avail_kib": 26589056, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056900.280, "mem_avail_kib": 26587188, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056901.317, "mem_avail_kib": 26589372, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056902.352, "mem_avail_kib": 26585352, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056903.389, "mem_avail_kib": 26586308, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056904.425, "mem_avail_kib": 26588040, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056905.462, "mem_avail_kib": 26591516, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056906.496, "mem_avail_kib": 26602804, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056907.531, "mem_avail_kib": 26595900, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056908.568, "mem_avail_kib": 26601196, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056909.605, "mem_avail_kib": 26596976, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056910.641, "mem_avail_kib": 26586220, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056911.679, "mem_avail_kib": 26584368, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056912.719, "mem_avail_kib": 26592776, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056913.756, "mem_avail_kib": 26599676, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056914.793, "mem_avail_kib": 26597960, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056915.830, "mem_avail_kib": 26599456, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056916.868, "mem_avail_kib": 26600032, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056917.912, "mem_avail_kib": 26606280, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056918.951, "mem_avail_kib": 26610500, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056919.984, "mem_avail_kib": 26592576, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056921.019, "mem_avail_kib": 26595364, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056922.058, "mem_avail_kib": 26593052, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056923.096, "mem_avail_kib": 26599416, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056924.137, "mem_avail_kib": 26598716, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056925.177, "mem_avail_kib": 26526708, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056926.217, "mem_avail_kib": 26532616, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056927.253, "mem_avail_kib": 26525132, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056928.289, "mem_avail_kib": 26527404, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056929.327, "mem_avail_kib": 26534524, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056930.368, "mem_avail_kib": 26517360, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056931.407, "mem_avail_kib": 26508320, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056932.444, "mem_avail_kib": 26518856, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056933.482, "mem_avail_kib": 26532072, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056934.527, "mem_avail_kib": 26525560, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056935.566, "mem_avail_kib": 26542084, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056936.604, "mem_avail_kib": 26525880, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056937.642, "mem_avail_kib": 26543500, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056938.681, "mem_avail_kib": 26551764, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056939.720, "mem_avail_kib": 26563440, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056940.758, "mem_avail_kib": 26567696, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056941.796, "mem_avail_kib": 26582568, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056942.835, "mem_avail_kib": 26492888, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056943.870, "mem_avail_kib": 27202780, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056944.906, "mem_avail_kib": 27200072, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056945.944, "mem_avail_kib": 27206604, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056946.983, "mem_avail_kib": 27216936, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056948.020, "mem_avail_kib": 27229080, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056949.059, "mem_avail_kib": 27241764, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056950.096, "mem_avail_kib": 27240568, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056951.134, "mem_avail_kib": 27240920, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056952.172, "mem_avail_kib": 27258576, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056953.210, "mem_avail_kib": 27259612, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056954.247, "mem_avail_kib": 27272748, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056955.286, "mem_avail_kib": 27277216, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056956.326, "mem_avail_kib": 27282428, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056957.361, "mem_avail_kib": 27281552, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056958.400, "mem_avail_kib": 27283852, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056959.440, "mem_avail_kib": 27280848, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056960.479, "mem_avail_kib": 27289980, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056961.515, "mem_avail_kib": 27290736, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056962.552, "mem_avail_kib": 27295236, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056963.590, "mem_avail_kib": 27296780, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056964.630, "mem_avail_kib": 27286064, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056965.668, "mem_avail_kib": 27293052, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056966.705, "mem_avail_kib": 27287168, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056967.743, "mem_avail_kib": 27294492, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056968.780, "mem_avail_kib": 27295432, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056969.818, "mem_avail_kib": 27284960, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056970.856, "mem_avail_kib": 27289032, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056971.892, "mem_avail_kib": 27216080, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056972.928, "mem_avail_kib": 27212760, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056973.970, "mem_avail_kib": 27217044, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056975.010, "mem_avail_kib": 27224000, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056976.049, "mem_avail_kib": 27235856, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056977.089, "mem_avail_kib": 27178204, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056978.127, "mem_avail_kib": 27174724, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056979.165, "mem_avail_kib": 27177504, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056980.201, "mem_avail_kib": 27175176, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056981.240, "mem_avail_kib": 27163472, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056982.279, "mem_avail_kib": 27168700, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056983.315, "mem_avail_kib": 27161536, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056984.352, "mem_avail_kib": 27165080, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056985.390, "mem_avail_kib": 27154624, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056986.426, "mem_avail_kib": 27147688, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056987.462, "mem_avail_kib": 27157564, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056988.501, "mem_avail_kib": 27152092, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056989.540, "mem_avail_kib": 27159644, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056990.577, "mem_avail_kib": 27160712, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056991.614, "mem_avail_kib": 27164748, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056992.650, "mem_avail_kib": 27158332, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056993.687, "mem_avail_kib": 27161512, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056994.727, "mem_avail_kib": 27155344, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056995.764, "mem_avail_kib": 27146944, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056996.798, "mem_avail_kib": 27160024, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056997.840, "mem_avail_kib": 27143144, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056998.876, "mem_avail_kib": 27138880, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791056999.912, "mem_avail_kib": 27030400, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791057000.948, "mem_avail_kib": 27031624, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791057001.988, "mem_avail_kib": 27024940, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791057003.029, "mem_avail_kib": 27029412, "swap_free_kib": 7063848, "vram_used": [16562462720,15780356096]}
+{"t": 1791057004.069, "mem_avail_kib": 27035228, "swap_free_kib": 7064104, "vram_used": [16562462720,15780356096]}
+{"t": 1791057005.106, "mem_avail_kib": 27041836, "swap_free_kib": 7064104, "vram_used": [16562462720,15780356096]}
+{"t": 1791057006.144, "mem_avail_kib": 27045192, "swap_free_kib": 7064104, "vram_used": [16562462720,15780356096]}
+{"t": 1791057007.182, "mem_avail_kib": 26988336, "swap_free_kib": 7064104, "vram_used": [16562462720,15780356096]}
+{"t": 1791057008.219, "mem_avail_kib": 27001344, "swap_free_kib": 7064104, "vram_used": [16562462720,15780356096]}
+{"t": 1791057009.257, "mem_avail_kib": 27001116, "swap_free_kib": 7064104, "vram_used": [16562462720,15780356096]}
+{"t": 1791057010.293, "mem_avail_kib": 26998056, "swap_free_kib": 7064104, "vram_used": [16562462720,15780356096]}
+{"t": 1791057011.332, "mem_avail_kib": 27001124, "swap_free_kib": 7064360, "vram_used": [16562462720,15780356096]}
+{"t": 1791057012.373, "mem_avail_kib": 26998588, "swap_free_kib": 7064360, "vram_used": [16562462720,15780356096]}
+{"t": 1791057013.411, "mem_avail_kib": 26947260, "swap_free_kib": 7064360, "vram_used": [16562462720,15780356096]}
+{"t": 1791057014.451, "mem_avail_kib": 26939092, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057015.488, "mem_avail_kib": 26938508, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057016.527, "mem_avail_kib": 26936284, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057017.564, "mem_avail_kib": 26933688, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057018.600, "mem_avail_kib": 26933448, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057019.640, "mem_avail_kib": 26916876, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057020.677, "mem_avail_kib": 26914256, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057021.716, "mem_avail_kib": 26920008, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057022.753, "mem_avail_kib": 26922608, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057023.790, "mem_avail_kib": 26934740, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057024.828, "mem_avail_kib": 26941320, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057025.869, "mem_avail_kib": 26947652, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057026.909, "mem_avail_kib": 26934888, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057027.945, "mem_avail_kib": 26931616, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057028.982, "mem_avail_kib": 26933752, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057030.019, "mem_avail_kib": 26930524, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057031.059, "mem_avail_kib": 26928352, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057032.098, "mem_avail_kib": 26933440, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057033.138, "mem_avail_kib": 26938800, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057034.178, "mem_avail_kib": 26943004, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057035.216, "mem_avail_kib": 26935168, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057036.253, "mem_avail_kib": 26928456, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057037.291, "mem_avail_kib": 26854016, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057038.328, "mem_avail_kib": 26864556, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057039.366, "mem_avail_kib": 26869516, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057040.403, "mem_avail_kib": 26887532, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057041.444, "mem_avail_kib": 26880252, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057042.481, "mem_avail_kib": 26851308, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057043.519, "mem_avail_kib": 26837640, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057044.556, "mem_avail_kib": 26842932, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057045.595, "mem_avail_kib": 26845048, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057046.634, "mem_avail_kib": 26847252, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057047.672, "mem_avail_kib": 26846076, "swap_free_kib": 7064616, "vram_used": [16562462720,15780356096]}
+{"t": 1791057048.709, "mem_avail_kib": 26838116, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057049.745, "mem_avail_kib": 26845448, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057050.782, "mem_avail_kib": 26833368, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057051.820, "mem_avail_kib": 26831704, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057052.858, "mem_avail_kib": 26827604, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057053.898, "mem_avail_kib": 26829840, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057054.939, "mem_avail_kib": 26838380, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057055.977, "mem_avail_kib": 26837196, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057057.015, "mem_avail_kib": 26828992, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057058.052, "mem_avail_kib": 26821608, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057059.090, "mem_avail_kib": 26824316, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057060.128, "mem_avail_kib": 26826412, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057061.165, "mem_avail_kib": 26822128, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057062.203, "mem_avail_kib": 26816756, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057063.242, "mem_avail_kib": 26820560, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057064.282, "mem_avail_kib": 26825456, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057065.320, "mem_avail_kib": 26830708, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057066.357, "mem_avail_kib": 26824308, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057067.394, "mem_avail_kib": 26752540, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057068.430, "mem_avail_kib": 26747444, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057069.466, "mem_avail_kib": 26740600, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057070.503, "mem_avail_kib": 26734032, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057071.542, "mem_avail_kib": 26731196, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057072.579, "mem_avail_kib": 26729872, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057073.617, "mem_avail_kib": 26684864, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057074.657, "mem_avail_kib": 26678148, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057075.693, "mem_avail_kib": 26689868, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057076.732, "mem_avail_kib": 26695888, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057077.772, "mem_avail_kib": 26702588, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057078.809, "mem_avail_kib": 26697280, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057079.850, "mem_avail_kib": 26702228, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057080.886, "mem_avail_kib": 26704592, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057081.923, "mem_avail_kib": 26698412, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057082.961, "mem_avail_kib": 26701956, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057083.999, "mem_avail_kib": 26704804, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057085.038, "mem_avail_kib": 26704812, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057086.076, "mem_avail_kib": 26707056, "swap_free_kib": 7065128, "vram_used": [16562462720,15780356096]}
+{"t": 1791057087.115, "mem_avail_kib": 26684320, "swap_free_kib": 7065640, "vram_used": [16562462720,15780356096]}
+{"t": 1791057088.155, "mem_avail_kib": 26678496, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057089.192, "mem_avail_kib": 26683716, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057090.229, "mem_avail_kib": 26685556, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057091.265, "mem_avail_kib": 26687284, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057092.304, "mem_avail_kib": 26691696, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057093.341, "mem_avail_kib": 26682872, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057094.380, "mem_avail_kib": 26686088, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057095.417, "mem_avail_kib": 26694112, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057096.453, "mem_avail_kib": 26696244, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057097.489, "mem_avail_kib": 26699616, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057098.527, "mem_avail_kib": 26706152, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057099.563, "mem_avail_kib": 26645076, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057100.599, "mem_avail_kib": 26651648, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057101.639, "mem_avail_kib": 26645064, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057102.675, "mem_avail_kib": 26636728, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057103.712, "mem_avail_kib": 26637844, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057104.753, "mem_avail_kib": 26637228, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057105.792, "mem_avail_kib": 26583192, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057106.832, "mem_avail_kib": 26581180, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057107.872, "mem_avail_kib": 26581108, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057108.911, "mem_avail_kib": 26588108, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057109.948, "mem_avail_kib": 26599952, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057110.985, "mem_avail_kib": 26600108, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057112.023, "mem_avail_kib": 26601732, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057113.061, "mem_avail_kib": 26602484, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057114.097, "mem_avail_kib": 26592736, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057115.135, "mem_avail_kib": 26594956, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057116.174, "mem_avail_kib": 26599640, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057117.211, "mem_avail_kib": 26593160, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057118.251, "mem_avail_kib": 26600084, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057119.288, "mem_avail_kib": 26573664, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057120.327, "mem_avail_kib": 26567104, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057121.365, "mem_avail_kib": 26584752, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057122.400, "mem_avail_kib": 26595396, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057123.437, "mem_avail_kib": 26591652, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057124.477, "mem_avail_kib": 26585420, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057125.517, "mem_avail_kib": 26584796, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057126.558, "mem_avail_kib": 26584636, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057127.593, "mem_avail_kib": 26580720, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057128.632, "mem_avail_kib": 26581332, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057129.668, "mem_avail_kib": 26573608, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057130.703, "mem_avail_kib": 26583168, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057131.741, "mem_avail_kib": 26518672, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057132.780, "mem_avail_kib": 26528668, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057133.816, "mem_avail_kib": 26532224, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057134.853, "mem_avail_kib": 26529100, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057135.891, "mem_avail_kib": 26530788, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057136.929, "mem_avail_kib": 26530888, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057137.964, "mem_avail_kib": 26527108, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057139.001, "mem_avail_kib": 26538132, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057140.040, "mem_avail_kib": 26557344, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057141.078, "mem_avail_kib": 26555040, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057142.114, "mem_avail_kib": 26559288, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057143.158, "mem_avail_kib": 26550828, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057144.196, "mem_avail_kib": 26550164, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057145.234, "mem_avail_kib": 26554032, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057146.270, "mem_avail_kib": 26558576, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057147.310, "mem_avail_kib": 26567844, "swap_free_kib": 7065896, "vram_used": [16562462720,15780356096]}
+{"t": 1791057148.348, "mem_avail_kib": 26467652, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057149.389, "mem_avail_kib": 26936572, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057150.426, "mem_avail_kib": 26950892, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057151.466, "mem_avail_kib": 26957240, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057152.504, "mem_avail_kib": 26969276, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057153.541, "mem_avail_kib": 26974864, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057154.579, "mem_avail_kib": 26975064, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057155.620, "mem_avail_kib": 26978340, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057156.659, "mem_avail_kib": 26989320, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057157.696, "mem_avail_kib": 27002780, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057158.734, "mem_avail_kib": 27006948, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057159.772, "mem_avail_kib": 27007132, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057160.810, "mem_avail_kib": 27013664, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057161.849, "mem_avail_kib": 27018108, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057162.887, "mem_avail_kib": 27028380, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057163.925, "mem_avail_kib": 27033712, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057164.965, "mem_avail_kib": 27038304, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057166.003, "mem_avail_kib": 27031064, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057167.042, "mem_avail_kib": 27036896, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057168.080, "mem_avail_kib": 27036708, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057169.119, "mem_avail_kib": 27038844, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057170.157, "mem_avail_kib": 27040860, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057171.196, "mem_avail_kib": 27045052, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057172.233, "mem_avail_kib": 27035660, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057173.271, "mem_avail_kib": 27033884, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057174.314, "mem_avail_kib": 27033848, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057175.353, "mem_avail_kib": 27034968, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057176.393, "mem_avail_kib": 27048860, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057177.431, "mem_avail_kib": 27053672, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057178.470, "mem_avail_kib": 27061072, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057179.510, "mem_avail_kib": 27055232, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057180.548, "mem_avail_kib": 26960632, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057181.585, "mem_avail_kib": 26952380, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057182.621, "mem_avail_kib": 26942108, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057183.660, "mem_avail_kib": 26942236, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057184.699, "mem_avail_kib": 26951896, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057185.737, "mem_avail_kib": 26946176, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057186.772, "mem_avail_kib": 26891816, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057187.808, "mem_avail_kib": 26895856, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057188.847, "mem_avail_kib": 26900296, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057189.885, "mem_avail_kib": 26908432, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057190.924, "mem_avail_kib": 26910856, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057191.964, "mem_avail_kib": 26899196, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057193.003, "mem_avail_kib": 26909492, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057194.043, "mem_avail_kib": 26917308, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057195.082, "mem_avail_kib": 26921092, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057196.124, "mem_avail_kib": 26928576, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057197.164, "mem_avail_kib": 26934672, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057198.207, "mem_avail_kib": 26936748, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057199.246, "mem_avail_kib": 26935680, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057200.287, "mem_avail_kib": 26932848, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057201.326, "mem_avail_kib": 26925164, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057202.364, "mem_avail_kib": 26925404, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057203.402, "mem_avail_kib": 26928744, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057204.442, "mem_avail_kib": 26935124, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057205.479, "mem_avail_kib": 26928952, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057206.518, "mem_avail_kib": 26932008, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057207.559, "mem_avail_kib": 26929600, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057208.597, "mem_avail_kib": 26929688, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057209.633, "mem_avail_kib": 26924308, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057210.669, "mem_avail_kib": 26925888, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057211.706, "mem_avail_kib": 26856688, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057212.743, "mem_avail_kib": 26849444, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057213.780, "mem_avail_kib": 26856692, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057214.818, "mem_avail_kib": 26861644, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057215.854, "mem_avail_kib": 26864968, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057216.890, "mem_avail_kib": 26859064, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057217.926, "mem_avail_kib": 26808096, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057218.966, "mem_avail_kib": 26815488, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057220.005, "mem_avail_kib": 26820624, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057221.042, "mem_avail_kib": 26821724, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057222.079, "mem_avail_kib": 26819284, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057223.118, "mem_avail_kib": 26812176, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057224.157, "mem_avail_kib": 26805956, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057225.195, "mem_avail_kib": 26817024, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057226.233, "mem_avail_kib": 26813616, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057227.273, "mem_avail_kib": 26822488, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057228.312, "mem_avail_kib": 26821472, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057229.351, "mem_avail_kib": 26815252, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057230.390, "mem_avail_kib": 26819828, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057231.430, "mem_avail_kib": 26818056, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057232.468, "mem_avail_kib": 26813328, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057233.506, "mem_avail_kib": 26804028, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057234.542, "mem_avail_kib": 26810440, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057235.580, "mem_avail_kib": 26804100, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057236.619, "mem_avail_kib": 26819344, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057237.664, "mem_avail_kib": 26817396, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057238.701, "mem_avail_kib": 26819852, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057239.740, "mem_avail_kib": 26811676, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057240.778, "mem_avail_kib": 26807268, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057241.816, "mem_avail_kib": 26787380, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057242.854, "mem_avail_kib": 26788112, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057243.891, "mem_avail_kib": 26716208, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057244.927, "mem_avail_kib": 26722464, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057245.964, "mem_avail_kib": 26730984, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057247.002, "mem_avail_kib": 26734972, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057248.038, "mem_avail_kib": 26741224, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057249.078, "mem_avail_kib": 26745100, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057250.114, "mem_avail_kib": 26701952, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057251.154, "mem_avail_kib": 26694368, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057252.191, "mem_avail_kib": 26685568, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057253.230, "mem_avail_kib": 26683856, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057254.266, "mem_avail_kib": 26678140, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057255.303, "mem_avail_kib": 26674628, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057256.338, "mem_avail_kib": 26671136, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057257.373, "mem_avail_kib": 26673032, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057258.412, "mem_avail_kib": 26685356, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057259.453, "mem_avail_kib": 26675828, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057260.496, "mem_avail_kib": 26556140, "swap_free_kib": 7065896, "vram_used": [16562462720,15780495360]}
+{"t": 1791057261.534, "mem_avail_kib": 29262176, "swap_free_kib": 7065896, "vram_used": [13813174272,12855922688]}
diff --git a/bench/results/2026-10-03-v100-prompt-attn/README.md b/bench/results/2026-10-03-v100-prompt-attn/README.md
new file mode 100644
index 0000000000..ab8b32624b
--- /dev/null
+++ b/bench/results/2026-10-03-v100-prompt-attn/README.md
@@ -0,0 +1,104 @@
+# V100 (sm_70): a tensor-core QSA prompt attention (2026-10-03)
+
+On sm_70 the prompt path ran the QSA attention on the decode kernel, one query at a time with FP32 FMAs. `prompt_attn_v70_kernel`
+(`src/kernels/cuda/qsa_prompt_attn.cu`) does the two matrix products on Volta's tensor cores (`mma.sync.m8n8k4`).
+`STRATA_PROMPT_ATTN_OLD=1` is the old path, so every number below is the same binary with and without that switch.
+
+## Rig
+
+- Tesla V100-PCIE-32GB (sm_70, PCIe gen3 x16), driver 550.120, one card used; a second V100 and a Quadro RTX 4000 (sm_75) are in the
+ PC and hidden with `CUDA_VISIBLE_DEVICES`. Intel Core i9-7960X (AVX-512), 125 GB RAM, Linux 6.8, CUDA 12.8, GCC 13.
+- Engine: `origin/main` at `99f3dbd` (0.1.35 + 83 commits) with this change, built with
+ `-DCMAKE_CUDA_ARCHITECTURES=70 -DSTRATA_EXPERIMENTAL_SM60=ON`.
+- Model: Unsloth `Qwen3.8-Flash-Next-UD-IQ4_XS` (3 shards), packed with `tools/iq_pack.py --compat-bf16`, MTP head from
+ `tools/mtp_fetch.py`. Not one of setup's models (see the limits). `--max-context 204800`, `--kv int8`, `--spec 2`.
+
+## Prompt speed
+
+One server start per configuration (cold), a 2K warm-up request, then three prompts of random words, each answered greedily
+(temperature 0, 48 tokens). The numbers are the server's own line `strata serve: prompt N tokens = 0 reused + N read in T ms`. One run per
+cell; repeated starts of the same configuration gave the same seconds.
+
+| Prompt tokens | old path | new kernel | gain | read time, old -> new |
+| ---: | ---: | ---: | ---: | ---: |
+| 7,194 | 1,010 tok/s | 1,164 tok/s | +15% | 7.1 s -> 6.2 s |
+| 28,650 | 1,063 tok/s | 1,251 tok/s | +18% | 26.9 s -> 22.9 s |
+| 114,338 | 973 tok/s | 1,123 tok/s | +15% | 117.5 s -> 101.8 s |
+
+Decode is unchanged within the noise (38-51 tok/s, 48 tokens per request): it does not use this kernel.
+
+## Where the time went (nsys, one V100, 28,650-token prompt, GPU kernel time of the whole capture: prefill plus 128-193 decode tokens)
+
+`strata-1gpu-32k-*.nsys-rep`; the "before" capture is 0.1.35 (`d9ab843`), the "after" one is the same base with this change and the BF16 change
+of #540 / the closed #593 (BF16 GEMMs through FP16; that one only removes `magma_sgemmEx`, which is not in this table).
+
+| | before | after |
+| --- | ---: | ---: |
+| QSA attention on the decode kernel (`attn_chunk_kernel<1>`) | 5.78 s (19.2%) | 0.18 s (decode steps only) |
+| `prompt_attn_v70_kernel` | - | 2.16 s (9.0%; 12% of the prompt part) |
+
+## Accuracy
+
+`qsa_prompt_attn_parity` (synthetic, no model; build with `-DSTRATA_PARITY_PROMPT_ATTN=ON`), V100, 32K context, 2,048 queries per chunk, output scale 3.6:
+
+| KV | error vs FP64, old kernel | error vs FP64, new kernel | time per chunk, old -> new |
+| --- | ---: | ---: | ---: |
+| int8 | 2.4e-6 | 5.1e-6 | 38.1 -> 13.4 ms (2.8x) |
+| fp16 | 1.9e-6 | 5.3e-6 | 37.3 -> 28.3 ms (1.3x) |
+
+The 1,500- and 2,100-token contexts also pass (int8: 3.2x and 2.2x). The test's Q4_0 cases are skipped below sm_80, where the dispatcher keeps the
+old kernel (upstream's mode 4 is sm_80+ only).
+
+## Output checks
+
+- **Needle recall** (`tools/needle_bench.py --lengths 8k,32k,128k --depths 10,30,50,70,90`, greedy, thinking off, a fresh server per
+ configuration; prompts of 7.9K, 32.0K and 122.7K tokens): **15 of 15 found with the old path, 15 of 15 with the new kernel.** The read times of
+ the later depths are shorter because the server reuses the checkpoint of the text before the needle.
+- **Greedy output** (48 tokens, the three prompts above): identical for the 28,650- and 114,338-token prompts. For the 7,194-token prompt the
+ answer starts the same and **differs later**: the kernel sums in another order and a greedy decode can follow a near tie elsewhere. A recall test
+ with a code word does not tell small shifts of the output distribution apart, and a teacher-forced top-1 comparison was not run.
+
+## Distribution check (teacher-forced, against the old path and against controls)
+
+The method of `docs/UNSLOTH_Q4.md`: `STRATA_LOGPOS= STRATA_LOGPOS_TOPK=256` on a serve engine (`--short-read 640 --adapt-every 100000`, greedy,
+`--max-context 204800`). Each prompt is a chat: a long first message (8,543 / 32,906 / 99,040 tokens of this repository's docs and code, read by the
+batched prompt path, i.e. by the kernel under test), a short reply, and a last message of ~600 tokens of other repository text that goes through the verify
+windows, where every token is scored: 617 positions per prompt. KL is old || other over the old run's top 256 plus a bucket for the rest.
+
+| comparison | KL mean (8.5K / 33K / 99K) | argmax same | top-10 overlap |
+| --- | --- | --- | --- |
+| old path vs old path (a second run) | 0 / 0 / 0 | 100% | 100% |
+| new kernel vs new kernel (a second run) | 0 / 0 / 0 | 100% | 100% |
+| **old path vs new kernel** | 0.028 / 0.040 / 0.116 | 97.7 / 93.2 / 86.4% | 93.3 / 91.9 / 88.4% |
+| control A: old path, BF16 GEMMs without the FP16 detour (`STRATA_BF16_VIA_F16=0`) | 0.026 / 0.051 / 0.090 | 97.2 / 91.4 / 91.2% | 92.9 / 91.4 / 88.5% |
+| control B: old path, `--prefill 2048` instead of auto (8192) | 0.023 / 0.035 / 0.038 | 97.9 / 94.8 / 95.6% | 92.9 / 92.2 / 89.7% |
+
+Median KL old vs new: 0.0007 / 0.0088 / 0.022; p99: 0.35 / 0.39 / 1.12 (heavy-tailed). The perplexity of the true tokens moves a lot under every
+change (old 365 / 127 / 24; new 346 / 192 / 12; control A 395 / 161 / 18), so it says nothing about quality here.
+
+Reading: the model amplifies small numeric differences, and both controls, which change nothing but the order of the rounding, move the distribution as
+much as the kernel does. At 99K the kernel is somewhat above both controls; this is one document and 617 positions, so a real effect there is neither
+confirmed nor excluded.
+
+**The kernel on the model's own data.** A temporary check (not in this PR) ran the decode kernel beside the new one on the same inputs, for every 8th batch of
+32 queries, in all 12 QSA layers, on the three prompts above (positions up to 98K, ~5 million (query, head) rows of 256): ||new - old|| / ||old|| per row is
+4.4e-7 on average and 4.8e-6 in the worst row; the largest absolute difference is 4e-5 against values up to 31. No trend over the layers or the context
+length. That is FP32 rounding level, so the differences above come from the model's sensitivity, not from the kernel's arithmetic.
+
+## Limits
+
+- sm_70 only (`__CUDA_ARCH__` 700-749). Q4_0 KV (`--kv q4_0`) and the QSA block scores (`block_scores_kernel`, the warp kernel below sm_80) still use their
+ old kernels on Volta.
+- One request, one card. The Unsloth IQ4_XS pack is not a setup model; the GSQ-RCO quants were not run on this rig.
+- The profile above is one capture of one prompt. The distribution check is one document per length; the 99K row is the least certain.
+
+## Reproduce
+
+```sh
+cmake -S . -B build -DSTRATA_ENABLE_CUDA=ON -DSTRATA_BUILD_TESTS=OFF -DSTRATA_PARITY_PROMPT_ATTN=ON \
+ -DCMAKE_CUDA_ARCHITECTURES=70 -DSTRATA_EXPERIMENTAL_SM60=ON
+cmake --build build --target strata qsa_prompt_attn_parity -j
+CUDA_VISIBLE_DEVICES=0 ./build/qsa_prompt_attn_parity 32768 2048 5 # the accuracy table
+STRATA_PROMPT_ATTN_OLD=1 # the old path; without the variable: the new kernel
+python tools/needle_bench.py --url http://127.0.0.1:8081 --lengths 8k,32k,128k --depths 10,30,50,70,90
+```
diff --git a/cmake/hip_backend.cmake b/cmake/hip_backend.cmake
index 255ee381f5..d59bba9a8a 100644
--- a/cmake/hip_backend.cmake
+++ b/cmake/hip_backend.cmake
@@ -11,7 +11,7 @@ endif()
# report ran it (#311), the maintainers have not.
set(_strata_hip_validated gfx1100 gfx1201)
set(_strata_hip_community gfx1101 gfx1200)
-set(_strata_hip_unvalidated gfx1102 gfx1030)
+set(_strata_hip_unvalidated gfx1012 gfx1102 gfx1030 gfx1031)
# CMake hands HIP a ';' list, but a -DCMAKE_HIP_ARCHITECTURES typed by hand (or ROCm's own Windows tooling) may use
# spaces, which foreach(IN LISTS) would otherwise treat as one element.
string(REPLACE " " ";" _strata_hip_norm "${CMAKE_HIP_ARCHITECTURES}")
@@ -44,6 +44,13 @@ string(REPLACE ";" "," STRATA_HIP_ARCHS "${STRATA_HIP_ARCH_LIST}")
enable_language(HIP)
find_package(hip CONFIG REQUIRED)
find_package(hipblas CONFIG REQUIRED)
+# Older distro hipBLAS has no workspace API. The compatibility shim uses
+# rocBLAS directly for that version; newer hipBLAS keeps its existing path.
+set(STRATA_HIP_BLAS_TARGETS roc::hipblas)
+if(hipblas_VERSION VERSION_LESS "1.0")
+ find_package(rocblas CONFIG REQUIRED)
+ list(APPEND STRATA_HIP_BLAS_TARGETS roc::rocblas)
+endif()
find_package(hipblaslt CONFIG QUIET)
if(NOT TARGET hip::host)
@@ -67,6 +74,10 @@ add_library(strata_hip_runtime INTERFACE)
target_include_directories(strata_hip_runtime BEFORE INTERFACE
"${STRATA_HIP_COMPAT_INCLUDE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_compile_definitions(strata_hip_runtime INTERFACE STRATA_USE_HIP=1 "STRATA_HIP_ARCHS=\"${STRATA_HIP_ARCHS}\"")
+option(STRATA_GFX1012_PORTABLE_DOT "Use the portable signed-byte dot control on gfx1012" OFF)
+if(STRATA_GFX1012_PORTABLE_DOT)
+ target_compile_definitions(strata_hip_runtime INTERFACE STRATA_GFX1012_PORTABLE_DOT=1)
+endif()
target_link_libraries(strata_hip_runtime INTERFACE hip::host)
# The shim renames the CUDA runtime to HIP, force-included into every host and device source. On Windows the host
# compiler is ROCm's clang++ too (tools/hip/build_windows.bat: CMake refuses to mix cl.exe with Clang HIP), which takes
diff --git a/data/draft_vocab_fr.bin b/data/draft_vocab_fr.bin
new file mode 100644
index 0000000000..21fff5d0c9
Binary files /dev/null and b/data/draft_vocab_fr.bin differ
diff --git a/docs/AI_SETUP.md b/docs/AI_SETUP.md
index 48fd142923..3eb9dfcf15 100644
--- a/docs/AI_SETUP.md
+++ b/docs/AI_SETUP.md
@@ -14,7 +14,7 @@ Work through the steps in order. Tell the user what you are doing in plain words
user the key. Never put a tunnel or port forward in front of a server without a key.
- Do not change the user's system beyond what setup does (setup installs Python for the user account if needed,
and everything else inside the Strata folder and its `Strata-data` folder). Ask before installing drivers.
-- The model download is ~70 GB (up to 111 GB for Unsloth's 4-bit). Confirm the user is fine with that before you
+- The model download is ~70 GB (94 GB for Unsloth's UD-IQ4_XS, 111 GB for UD-Q4_K_XL). Confirm the user is fine with that before you
start, especially on a metered connection.
- Setup is long-running (an hour or more on a slow connection). Run it in the background or with a long timeout and
poll its output; do not kill it because it is quiet for a while. It is resumable: running the same command again
@@ -44,11 +44,12 @@ Requirements (details: [INSTALL.md](INSTALL.md#what-you-need)):
- **GPU:** NVIDIA RTX 20, 30, 40 or 50 series, or AMD Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT,
RX 9070 / 9070 XT, Radeon AI PRO R9700, RX 6800 / 6900 series; 12 GB of VRAM or more (an NVIDIA card with 8 GB runs,
- slowly). GTX 10 series and older, and integrated GPUs, are not supported.
+ slowly). GTX 10 series and older, and integrated GPUs, are not supported; Pascal / Volta cards (P40, V100) and some
+ older AMD cards have experimental paths the user opts into ([OLDER_GPUS.md](OLDER_GPUS.md)).
- **Driver:** NVIDIA 580 or newer. AMD on Linux: the kernel's amdgpu driver; on Windows: a current AMD Adrenalin
driver. If the driver is missing or too old, tell the user to update it (NVIDIA App / nvidia.com/drivers, or AMD
Software) and restart; do not install drivers yourself unless they ask.
-- **RAM:** 32 GB or more (see step 3). **Disk:** ~80 GB free, ideally on an NVMe SSD. **CPU:** x86-64 with AVX2.
+- **RAM:** 32 GB or more (see step 3). **Disk:** ~80 GB free, ideally on an NVMe SSD. **CPU:** x86-64 with AVX2. Without AVX2 (Xeon E5 v1/v2 and older) setup still installs, as an experimental and slow build it compiles on the PC (10-20 minutes): tell the user that before starting ([INSTALL.md](INSTALL.md#older-cpus-experimental)).
- **OS:** Windows 10/11 or Linux (Ubuntu 22.04/24.04 are fully automatic).
If the PC does not meet them, say which part is missing and stop.
@@ -72,7 +73,7 @@ By the PC's RAM (ask the user whether they mainly want it for code - then the Co
| 32 GB | `--family coder` (size IQ1_M) | for code; with a 24 GB card Q2_0 and IQ2_XS also run (setup picks the low-RAM mode) |
| 48 GB | `--family qwen --model IQ2_XS` | or `Q2_0` (fastest) |
| 64 GB | `--family qwen --model IQ2_XS` (recommended) | `IQ3_XXS` / `IQ3_S` are slower and a bit better |
-| 96 GB+ | `--family qwen --model IQ3_S` | `--family unsloth --model UD-Q4_K_XL` is experimental: NVIDIA only, NVMe SSD, 7-8.5 tokens/s on 64 GB |
+| 96 GB+ | `--family qwen --model IQ3_S` | `--family unsloth --model UD-IQ4_XS` (~4-bit, 94 GB, part of the experts read from the SSD under ~80 GB of RAM); `--model UD-Q4_K_XL` is experimental: NVIDIA only, NVMe SSD, 7-8.5 tokens/s on 64 GB |
`--family swift` (Swift 1.5, a fine-tune that thinks shorter; sizes Q2_0, IQ2_XS, IQ3_XXS) is the alternative to
`qwen`. With `--yes` and no `--model`, setup picks the recommended size for the RAM itself. More: [MODELS.md](MODELS.md).
@@ -92,7 +93,7 @@ The flags (all of them: `START-HERE.bat --help`):
| --- | --- |
| `--yes` | take the recommended answer to every question (no prompts) |
| `--family qwen\|swift\|coder\|unsloth` | the model version |
-| `--model Q2_0\|IQ2_XS\|IQ3_XXS\|IQ3_S\|IQ1_M\|UD-Q4_K_XL` | the size (the Coder is IQ1_M, Unsloth UD-Q4_K_XL) |
+| `--model Q2_0\|IQ2_XS\|IQ3_XXS\|IQ3_S\|IQ1_M\|UD-IQ4_XS\|UD-Q4_K_XL` | the size (the Coder is IQ1_M, Unsloth UD-IQ4_XS or UD-Q4_K_XL) |
| `--context N` | context in tokens; default by VRAM: 32768 under 14 GB, 65536 under 20 GB, else 131072 |
| `--vision yes\|no\|gpu\|cpu` | read pictures; `--yes` leaves images off. AMD cards: `cpu` |
| `--gpu N` / `--gpus 0,1` / `--gpus all` | one card, or several sharing the model (default: the card with the most VRAM) |
@@ -132,7 +133,7 @@ Notes:
## 6. Start the server
Setup prints the start script it wrote (`start script: run-.bat`). The name is the family tag plus the size,
-lower case: `run-iq2_xs`, `run-swift-iq2_xs`, `run-coder-iq1_m`, `run-unsloth-ud-q4_k_xl`.
+lower case: `run-iq2_xs`, `run-swift-iq2_xs`, `run-coder-iq1_m`, `run-unsloth-ud-iq4_xs`.
```
Windows (PowerShell): Start-Process -FilePath ".\run-iq2_xs.bat" (opens its own window)
@@ -168,10 +169,13 @@ line when it is ready; the engine log is `strata-.log` in the Strata fold
- **Any OpenAI-compatible app or agent:** base URL `http://127.0.0.1:8080/v1`, any API key (or the configured one),
any model name.
- **Anthropic-compatible apps:** `http://127.0.0.1:8080/v1/messages`.
+- **Codex CLI and other Responses API apps:** `http://127.0.0.1:8080/v1/responses` (stateless; Codex's
+ `config.toml`: [DETAILS.md](DETAILS.md#the-responses-api-and-codex-cli)).
- **Claude Code:** `ANTHROPIC_BASE_URL=http://127.0.0.1:8080`, `ANTHROPIC_MODEL` set to a Claude model name it knows
(Strata ignores the name), and any `ANTHROPIC_AUTH_TOKEN` (or the configured key).
- **Thinking level:** `"reasoning_effort": "none" | "low" | "medium" | "high"` (default high).
-- Strata answers one request at a time. API details: [DETAILS.md](DETAILS.md#using-it).
+- Strata answers one request at a time; `"parallel": N` in the model's config (or setup `--parallel N`) decodes up to
+ N together ([BATCHING.md](BATCHING.md)). API details: [DETAILS.md](DETAILS.md#using-it).
## 9. When something fails
diff --git a/docs/AMD_HIP.md b/docs/AMD_HIP.md
index e2b63fc9bf..35b0626484 100644
--- a/docs/AMD_HIP.md
+++ b/docs/AMD_HIP.md
@@ -5,7 +5,8 @@ covers the build on Linux (on Windows a ready-made engine, see [Windows](#window
RX 9070 / 9070 XT / Radeon AI PRO R9700 (RDNA4, gfx1201; see [RDNA4](#rdna4-gfx1201)). The RX 7800 XT / 7700 XT
(gfx1101) and the RX 9060 XT (gfx1200) were validated by their owners (see [Community-validated
cards](#community-validated-cards)); the RX 6800 / 6900 series (RDNA2, gfx1030) builds and runs too, reported by a community machine and not yet validated by the maintainers (see [RDNA2](#rdna2-gfx1030)). Setup chooses it by itself on a PC with no NVIDIA card Strata can use (`--backend hip` on a PC with both); the
-install steps for users are in [INSTALL.md](INSTALL.md#amd-cards). Other AMD architectures, wave64, and mixed
+install steps for users are in [INSTALL.md](INSTALL.md#amd-cards). gfx906 (Instinct MI50 / MI60, Radeon VII; wave64) has a separate
+opt-in build, see [gfx906](#gfx906-instinct-mi50--mi60-radeon-vii-wave64-built-from-source). Other AMD architectures and mixed
AMD/NVIDIA execution in one run are not supported.
The backend maps the CUDA-shaped runtime and BLAS calls to HIP/hipBLAS, uses
@@ -46,7 +47,9 @@ the kernel's amdgpu driver (no ROCm install needed):
architectures above; the engine is compiled for each of them (cards of two families, e.g. gfx1100 + gfx1201, need
a system ROCm 7: AMD's wheels hold one family). A split pays only when no single card holds the model's experts
(see RDNA4 below).
-- **Limits for now:** images only through the CPU encoder (`--vision cpu`, 0.1.32), no calibration. The Monitor
+- **Limits for now:** images only through the CPU encoder (`--vision cpu`, 0.1.32). Setup does not offer the tuning
+ (calibration) on AMD yet: its controls are being checked on HIP one at a time (#566). Since 0.1.39 a tuning run by
+ hand (`./setup.sh --calibrate`) is saved for the AMD card it ran on and reused when setup runs again. The Monitor
shows the card's load, VRAM, temperature and power from Linux sysfs (0.1.32).
The rest of setup is the same as on NVIDIA: the model download, the start script, the server.
@@ -345,8 +348,71 @@ run it; the report below is from a community machine: an RX 6900 XT 16 GB (gfx10
[AMD_HIP_PERFORMANCE.md](AMD_HIP_PERFORMANCE.md) cost 8.6 and 13.6 tok/s here (30 with the defaults): keep the
defaults on a 16 GB card.
- **hipBLASLt:** ROCm's hipBLASLt ships no gfx1030 kernels, so there is no table and the plain hipBLAS path runs.
-- **Not validated:** gfx1031 / gfx1032 (the same `dp4a` path, no hardware report), setup's own build path and the
- `gfx103X-all` wheels on gfx1030, images, answer-quality benchmarks.
+- **gfx1031** (RX 6700 XT, #524): setup knows it (the `gfx103X-all` wheels, unvalidated); its reporter runs it daily
+ on one card.
+- **Not validated:** gfx1032 (the same `dp4a` path, no hardware report), setup's own build path and the
+ `gfx103X-all` wheels on gfx1030, images, answer-quality benchmarks. RDNA1 (gfx1012, RX 5500 XT) builds by hand:
+ [OLDER_GPUS.md](OLDER_GPUS.md#amd-building-gfx906-and-gfx1012).
+
+## gfx906 (Instinct MI50 / MI60, Radeon VII): wave64, built from source
+
+gfx906 is wave64 and has no WMMA and no packed byte arithmetic, so the wave32 backend above refuses it. A separate opt-in build compiles the CUDA sources as HIP through a small compat layer
+(`include/strata/platform/hip_compat/`), with a CUDA warp mapped to a logical half of the 64-lane wavefront
+(32-wide shuffles, a ballot of its own half). The hot kernels have wave64 layouts of their own (below). Setup does
+not build it yet: build by hand, and run `serve/server.py` with a config as on any other card.
+
+**ROCm.** AMD's current ROCm releases no longer ship gfx906 libraries. The build and the measurements below used
+HIP 7.14 from the community image [`mixa3607/rocm-gfx906:7.14-complete`](https://hub.docker.com/r/mixa3607/rocm-gfx906)
+(rocBLAS/hipBLAS with gfx906 kernels), on the kernel's amdgpu driver (Ubuntu 24.04, kernel 6.8). hipBLASLt is not
+used.
+
+```sh
+# inside the image, with this checkout at /src and llama.cpp at the pinned commit in third_party/llama.cpp
+apt-get update && apt-get install -y cmake ninja-build git python3 build-essential
+cmake -S . -B build-906 -G Ninja -DSTRATA_HIP_GFX906=ON -DCMAKE_HIP_ARCHITECTURES=gfx906 \
+ -DSTRATA_GGML_DIR=/src/third_party/llama.cpp \
+ -DCMAKE_C_COMPILER=/opt/rocm/llvm/bin/clang -DCMAKE_CXX_COMPILER=/opt/rocm/llvm/bin/clang++
+ninja -C build-906 strata
+```
+
+`STRATA_HIP_GFX906` turns on the CUDA targets and refuses `STRATA_ENABLE_HIP` (one HIP build at a time). Run the
+container with `--device=/dev/kfd --device=/dev/dri --group-add video --ipc=host --ulimit memlock=-1`; on some boards
+`HSA_OVERRIDE_GFX_VERSION=9.0.6` is needed for the runtime to accept the card.
+
+**What differs from the CUDA build** (all under `STRATA_HIP_GFX906`; each A/B switch restores the CUDA layout):
+
+| | gfx906 | switch |
+|---|---|---|
+| grouped native experts | mode 7: signs as `dp4a(g ^ m, u) - dp4a(m, u)` (no byte SIMD), grids and activations in LDS, one weight load per group; the IQ formats only (Unsloth's K-quant / Q5_1 / Q8_0 experts take the CUDA layout) | `STRATA_EXP_MODE=2` (previous AMD layout) |
+| MMVQ | one wavefront per row, 64-lane butterfly, no LDS | `STRATA_MMVQ_WAVE=0` |
+| router top-10 | one wavefront, register argmax | - |
+| verify-window routing (256-expert router) | the window's tokens in one multi-column BF16 projection + one top-k, ~3 ms of a ~55 ms window | `STRATA_ROUTE_PER_TOKEN=1` |
+| hyper-connection read | norm loads in flight, 8 lanes per row for `up` | `STRATA_GR_FAST=0` |
+| `gr_down_multi`, opt-in | split along K (8 rows x 5 slices of 2048), ~3% per window; sums in another order than the single-token read, so off by default | `STRATA_GR_SPLIT=1` |
+| IQ4 table lookup | llama.cpp's `v_perm_b32` sequence (HIP's `__byte_perm` is a scratch array) | - |
+| QSA prompt attention | the FP32 kernel (no tensor cores / WMMA) | - |
+
+Every switch above was checked bitwise against the layout it replaces: the parity tests on synthetic data,
+`native_expert_bench` on real GGUF rows (one mode against another), and the greedy text of a fixed request.
+
+**Measured** on 2x Instinct MI50 16 GB (85 W power limit each, PCIe 3.0 x16 both, peer access between them),
+Xeon E5-2666 v3 (10 cores, AVX2), 32 GB DDR4, SATA SSD; Coder IQ1_M (`--native` pack), 131,072-token context,
+`--kv int8 --kv-resident 32768`, `--layer-split 27`, `--prefill 4096`, MTP `--spec 4 --spec-min-p 0.5`,
+`--pcie-frac 0`, `STRATA_ARENA_MMAP=1`. Measured on the port this build was cut from (builds on the 0.1.30 and 0.1.33 bases, the same
+kernels, plus the layer-split weight trim and the mapped arena, which are separate pull requests):
+
+- all 12,288 experts held in VRAM across the two cards;
+- a 17,043-token agent prompt (Claude Code's first request, tools included) read in 43.5 s (392 tok/s), decode
+ on it 57.7-59.3 tok/s;
+- 25 GB of the 32 GB RAM available while serving;
+- six repeats of that request with a 6,000-token output cap and a 900-second two-client load test run six times
+ (407 requests, 202,940 tokens): no stalls, no errors, no GPU faults.
+
+For comparison on the same machine: llama.cpp (3cf0325, ROCm) on the same Coder IQ1_M measured 26.9 tok/s `tg128`.
+
+**Not done:** setup.py detection and an automatic build, Windows, images (`--vision`), MI60 and Radeon VII (the
+same gfx906 ISA; not run), a single-card run, the tensor-split experiment (the halves of every layer on two cards;
+it works but is not part of this build).
## Tuning table
diff --git a/docs/BATCHING.md b/docs/BATCHING.md
new file mode 100644
index 0000000000..7672dcc586
--- /dev/null
+++ b/docs/BATCHING.md
@@ -0,0 +1,215 @@
+# Several requests at once (batch slots)
+
+By default Strata serves **one request at a time**: the others wait in the server's queue. With `"parallel": N`
+(the engine's `--batch N`, also spelled `--slots N`) the engine keeps up to N conversations open and decodes them
+**together**: every verify window then carries one token of each conversation, so the dense weights, the shared
+expert, the head and every routed expert two conversations share are read once per window for all of them.
+Combined with a layer split across several GPUs and `--batch-groups`, the cards also work on different
+conversations at the same time instead of waiting for each other.
+
+It is opt-in and changes nothing when the options are absent (#465; the engine part is PR #559).
+
+## Turning it on
+
+One GPU: add `"parallel": 2` to the model's config (`strata-.json`) and restart, or run setup with
+`--parallel 2`. Setup recommends it only where it does not cost speed (below); any number you ask for is kept as
+asked, with a note when it is more than setup would recommend.
+
+```
+"parallel": 2
+```
+
+With a layer split, the engine options go into the config's `args`:
+
+```
+"args": [ ..., "--batch", "8", "--batch-groups", "4", "--trim-stage-weights" ],
+"layer_split": "12,24,36"
+```
+
+| Option | What it does |
+| --- | --- |
+| `"parallel": N` / `--batch N` / `--slots N` (2..8) | up to N conversations decoded together; more requests wait for a free slot. Each slot gets its own state (a session carved like the stage's own: GDN recurrence, QSA K/V and indexer, PLE history) on every GPU of the split. |
+| `--batch-groups G` | with a layer split: the N slots in G groups that flow through the GPUs as a pipeline (GPU k runs one group while GPU k+1 runs another). G must divide N. 1 = all slots in one window, GPU after GPU. |
+| `--trim-stage-weights` | with an **explicit** `--layer-split` (e.g. `12,24,36`, not `auto`): every GPU loads only the dense weights of its own layers instead of the whole model's (the same as `STRATA_STAGE_TRIM=1`, PR #639). The VRAM this frees goes to the expert cache. Useful without `--batch` too. |
+
+The engine never refuses a count it cannot run: it says so in its log and runs what it can - at most 8 slots (a
+window holds 8 rows), as many as fit in VRAM, or none (one request at a time) when not two fit. The server reads
+the count the engine reports (`INFO batch_slots=N`), and `GET /v1/status` says it (`concurrency.serving`).
+
+### What a slot costs, and what setup recommends
+
+Every slot's session takes VRAM that the expert cache would otherwise hold: 0.56 GiB at a 32K context with 8-bit
+KV, more with a longer context unless the KV cache streams (`--kv-resident`: then only the attention's 32K window
+stays in VRAM, and each slot's whole KV cache takes pinned RAM - 1.6 GB at 128K). On a card whose experts mostly run
+on the CPU, a batch also reads about as many distinct experts as the requests one by one (different conversations
+route to different experts), so the gain is in **latency** (nobody waits for a whole answer), and a request alone
+runs slower (the smaller expert cache): 11-24 % on a 12 GB card, see the measurements below.
+
+So setup recommends `"parallel"` **only where the experts mostly fit in VRAM**: the expert cache (each card's VRAM
+less ~5 GB, every card of a layer split counted) must still hold at least half of the model's experts beside the
+slots, and the slots may take at most a fifth of it, up to 4 slots. With Q2_0 at 32K that is 3 slots on a 24 GB
+card, 4 from 32 GB or on a split such as 2 x 16 GB; IQ3_S needs 32 GB or a split. Everywhere else (any 12 or 16 GB
+card alone) it stays at one at a time and setup says: "parallel N reduces waiting for several users but costs
+about 10-25% speed per request on this card". `--parallel N` is honoured as asked either way.
+
+## How the server uses the slots
+
+- **One request alone** runs on the usual solo path (verify windows with MTP drafts): the fastest single stream.
+- **When a second request arrives**, the first is stopped (`STOP`) and continues in a batch slot with its prompt
+ plus what it generated so far - the engine's prompt cache holds exactly that, so nothing is read again - and the
+ new request is admitted next to it. A request in a slot decodes **without MTP drafts** (one token per window).
+- **A request left alone in a slot** (the others finished, nobody waits) goes back to the solo path: the slot is
+ stopped, the engine copies its sessions back and decodes with MTP drafts again (at most twice per request; with
+ `--prompt-cache 0` it stays in the slot; `STRATA_PARALLEL_SOLO=0` turns it off). The draft layer's own K/V was
+ built for another conversation then, but measured it accepted as many drafts (140 of 172) as a draft layer that
+ read the conversation (140 of 173).
+- **More requests than slots** wait for a free one (`/metrics` -> `live.slots` shows each slot: idle, reading or
+ decoding, its tokens and tok/s; `live.running` the requests in flight).
+- **Each admission** reads the request's prompt through the usual prompt path (prompt cache and conversation
+ checkpoints included) and produces its first token there; the state is then copied into the slot. Admissions
+ are taken one at a time, and **the slots decode between the prompt's chunks**: after each chunk (`--prefill`,
+ 2048-8192 tokens) they decode for half as long as the chunk took (`STRATA_BATCH_DECODE_SHARE`, default 0.5), so
+ a long prompt slows the others down instead of stopping them. The chunks are the ones one uninterrupted read
+ takes, so the prompt's arithmetic is unchanged.
+- **A long prompt gives way to a short one** (#656's cooperative preemption): when a request with a prompt under
+ half as long is waiting, the server sends `BYIELD`; at its next chunk boundary the long read stops, the part read
+ so far is copied into a slot, the short request is admitted, and the long one then goes on from its slot with the
+ same chunks (at most twice per request).
+- **Each slot is a conversation cache.** A finished slot keeps what it holds (the prompt, the answer, and the
+ checkpoint at the prompt's last turn boundary); the next turn of that conversation goes to that slot and the
+ engine copies its state back (50-60 ms for a short conversation) instead of reading the history again - also for
+ a client that drops the reply's thinking from the history (the checkpoint matches up to the new turn). A new
+ conversation takes an empty slot, else the one used longest ago.
+- Slots are assigned so that consecutive requests land in different pipeline groups (`--batch-groups`).
+- A client that disconnects stops its slot (`BSTOP`); the others go on.
+
+## Exactness
+
+A batch row's arithmetic is the single-token window's, so with greedy decoding **every conversation of a batch
+produces exactly the tokens it produces alone** - verified token by token for 8 concurrent conversations of 150
+tokens, with and without the pipeline (`tools/batch_test.py`), and on one RTX 5070 for 4 conversations, for a long
+prompt read while two others decode, for a prompt that gave way and went on, for a next turn continued from its
+slot (from all it holds, and from its turn checkpoint without the reply's thinking), and for a request stopped in
+its slot and continued on the solo path (`tools/batch_interleave_test.py`). These
+settings make the comparison exact:
+
+- `STRATA_IQ_MT_MIN=1` (the multi-token CPU expert kernels for every group, as for the solo path's own
+ exactness tests: by default an expert's rows round differently alone than in a group, so the output depends on
+ how many rows of a window share an expert - which differs between a batch and a request alone),
+- `--pcie-frac 0`: the PCIe share of the missed experts is chosen per window from the window's misses, so the
+ same expert can run on the GPU in one window and on the CPU in another, which rounds differently, and
+- `--adapt-every 1000000` (the VRAM tier fixed), and `--no-prefill-borrow` while a prompt is read beside decoding
+ slots: their windows then see the expert cache without the slots the prompt borrowed (those experts run on the
+ CPU).
+
+With the default settings the outputs stay coherent but drift apart after some tokens, as two solo runs whose
+windows differ can. Measured on the RTX 5070 (Q2_0, 4 conversations of 200 tokens, `tools/batch_test.py` without
+the settings above): one equal to its solo run, the others apart from token 0, 99 and 174 - the first token already
+differed for one, because the adaptive VRAM tier had moved experts between the solo runs and the batch; all four
+texts read as well as their solo runs.
+
+Sampled requests (temperature, top_p, top_k, min_p, seed) are drawn row by row with the solo window's
+counter-based draw (Philox(seed, position)).
+
+## Limits (for now)
+
+- Batch windows carry no MTP drafts: a conversation in a slot decodes one token per window (the solo path keeps
+ its drafts, which is why a request alone is not put in a slot, and goes back to it when left alone).
+- Repetition / frequency / presence penalties are not applied in batch windows.
+- A prompt shorter than one chunk is read in one piece (the slots wait for it); a read gives way only at a chunk
+ boundary, and not for pictures.
+- Admissions are one at a time: two new long prompts are read one after the other.
+- `--batch-groups` needs every stage on its own GPU; a pipelined slot is not kept as a conversation cache.
+- The slot sessions take VRAM (above) and, with KV streaming, pinned RAM.
+
+## Measured
+
+One RTX 5070 (12 GB), Ryzen 5 7600, 64 GB DDR5, Q2_0, 32K context, through the HTTP server: C different requests
+sent at once (an 800-word essay each, 256 tokens per answer, greedy, thinking off), median of 3 rounds:
+
+| Concurrent | Setting | Total tok/s | vs one at a time | Per request tok/s | First token: median / last of the round |
+| ---: | --- | ---: | ---: | ---: | ---: |
+| 1 | one at a time (default; what setup recommends on this card) | 74.3 | - | 83.6 | 0.4 s / 0.4 s |
+| 1 | `"parallel": 2` | 67.3 | -9 % | 74.8 | 0.4 s / 0.4 s |
+| 1 | `"parallel": 4` | 57.9 | -22 % | 63.8 | 0.4 s / 0.4 s |
+| 2 | one at a time | 71.6 | - | 77.3 | 2.1 s / 4.1 s |
+| 2 | `"parallel": 2` | 61.0 | -15 % | 32.6 | 0.5 s / 0.9 s |
+| 2 | `"parallel": 4` | 54.6 | -24 % | 28.7 | 0.6 s / 0.7 s |
+| 4 | one at a time | 70.7 | - | 79.6 | 6.0 s / 11.2 s |
+| 4 | `"parallel": 2` | 61.2 | -13 % | 32.2 | 4.5 s / 9.3 s |
+| 4 | `"parallel": 4` | 63.1 | -11 % | 16.9 | 1.0 s / 1.8 s |
+
+On this card the slots buy **waiting time, not speed**: the fourth of four requests starts after 1.8 s instead of
+11.2 s, but together they decode 11-24 % slower than one after the other, and a request alone loses 11 % (2 slots)
+or 24 % (4 slots), because the slots' sessions (0.56 GiB each) come out of the expert cache and most experts run
+on the CPU: a batch window over 4 conversations reads 24 CPU experts per layer against ~8 for one, so it costs about
+what the 4 tokens cost one after the other (`strata batch:` in the engine log: 54 ms per 4-row window, ~20 ms per
+1-row window). This is why setup leaves a 12 GB card at one at a time. Cards that hold most experts in VRAM, and a
+layer split, are where the slots also add speed (below).
+
+A 4-GPU layer split (4 x 16 GB, PCIe Gen3), IQ3_S, `--batch 8 --batch-groups 4 --trim-stage-weights`, through
+the HTTP server, 400 tokens per answer, temperature 0.7 (PR #559):
+
+| Concurrent requests | Per request | Total |
+| ---: | ---: | ---: |
+| 1 | 123 tok/s (solo path) | 120 tok/s |
+| 2 | 57 tok/s | 113 tok/s |
+| 4 | 51 tok/s | 205 tok/s |
+| 8 | 45 tok/s | 360 tok/s |
+
+With the patches below on engine 0.1.38 and parking on, through the service: 8 requests at temperature 0 -> 369
+tok/s, at 0.7 -> 358 tok/s.
+
+`--trim-stage-weights` alone raised the share of experts held in VRAM on that machine from 76-85 % to 84-100 %
+per card.
+
+## Together with conversation parking
+
+`--conversation-cache-mib N --conversation-cache-slots K` (DETAILS.md) works with the layer split too: a request
+whose conversation was parked is restored on every stage before its admission, so an agent and its sub-agents, or
+several chats that alternate, come back without reading their history again. Measured on the same 4-GPU split,
+two long conversations alternating through the HTTP server: the first turns took 3.9 s and 5.7 s to the first token
+(their prompts read), the follow-ups 0.53 s and 0.46 s.
+
+## Testing
+
+Four scripts drive a built engine or a running server; each exits non-zero on a failure. `serve/test_parallel.py`
+tests the server's side with a scripted engine (no GPU).
+
+| Script | What it checks |
+| --- | --- |
+| `tools/batch_test.py` | the same prompts alone (`GEN`) and together in the batch slots (`BGEN`): every slot's greedy tokens equal its solo tokens; prints the aggregate rate. `--batch-groups` in `--extra` tests the pipeline, `--keys "temperature=0.7"` the sampled rows. |
+| `tools/batch_interleave_test.py` | a long prompt read while two slots decode, a prompt that gives way (`BYIELD`) and goes on, and a next turn continued from its slot: each equal to its solo tokens. |
+| `tools/parking_test.py` | a follow-up to a conversation decodes the same tokens whether its state stayed live or came back from the parking cache (with a layer split: every stage's image). |
+| `tools/early_close_test.py` | a client that stops reading a streamed answer early (alone, and with a second request running) does not leave its tokens to the next request (server). |
+
+For exact comparisons pass `--pcie-frac 0 --adapt-every 1000000` (and the scripts set `STRATA_IQ_MT_MIN=1`):
+
+```
+python3 tools/batch_test.py --exe engine/strata --config strata-.json --batch 8 --n 8 \
+ --extra "--layer-split 12,24,36 --trim-stage-weights --batch-groups 4 --pcie-frac 0 --adapt-every 1000000"
+python3 tools/batch_interleave_test.py --exe engine/strata --config strata-.json \
+ --extra "--pcie-frac 0 --adapt-every 1000000 --no-prefill-borrow"
+python3 tools/parking_test.py --exe engine/strata --config strata-.json \
+ --extra "--layer-split 12,24,36 --conversation-cache-mib 8192 --conversation-cache-slots 4 --pcie-frac 0"
+STRATA_KEY= python3 tools/early_close_test.py http://127.0.0.1:8080
+```
+
+## Engine protocol (`--serve`)
+
+On top of `GEN` / `GENI`:
+
+| Line | Direction | Meaning |
+| --- | --- | --- |
+| `BGEN [keys] ` | in | read the prompt (as `GEN 1`), then continue in `` |
+| `BGENI [keys] ` | in | the same with images |
+| `BADM <1/0>` | out | after the admission's `DONE`: 1 = it continues in the slot, 0 = it ended |
+| `BT ` | out | a token of that slot |
+| `BDONE ` | out | the slot is free again (it keeps its conversation) |
+| `BSTOP ` | in | end that slot at its next window |
+| `BYIELD ` | in | the prompt being read gives way at its next chunk boundary; its part read waits in `` (the admission's own, or a free slot for a solo request) |
+| `YIELDED ` | out | before the `DONE cancel` of a read that gave way: the request is sent again later and goes on from there |
+| `INFO ... batch_slots=N` | out | the slots the engine runs (only with `--batch`) |
+
+`tools/batch_test.py` drives the engine directly: the same prompts alone, then together, compared token by token,
+and the aggregate rate.
diff --git a/docs/COMMUNITY_BENCHMARKS.md b/docs/COMMUNITY_BENCHMARKS.md
index ff3c312c55..e6903faf40 100644
--- a/docs/COMMUNITY_BENCHMARKS.md
+++ b/docs/COMMUNITY_BENCHMARKS.md
@@ -15,6 +15,9 @@ and their limits. Report what you actually measured and label estimates separate
- [2026-09-30: RTX 5090, Core Ultra 9 285K, 64 GB RAM](../bench/results/2026-09-30-community-rtx-5090/README.md):
Strata 0.1.29, original Flash-Next IQ2_XS, 131,072-token context; three runs
each at 4,096, 32,768, and 128,000 prompt tokens, plus six recall checks.
+- [2026-10-03: 2x AMD Instinct MI50 16 GB (gfx906), Xeon E5-2666 v3, 32 GB RAM](../bench/results/2026-10-03-community-2x-mi50/README.md):
+ the gfx906 build (#638) with #639 and #640, Coder IQ1_M, 131,072-token context, layer split across both cards;
+ three runs each at 4,096, 32,768, and 128,000 prompt tokens, plus six recall checks.
## What to record
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index e6f33e4025..d0e7e71fe4 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -79,7 +79,9 @@ The current canonical multi-GPU documents are:
Concrete reference-host hardware, tuning values, benchmark numbers, and production validation records intentionally live in the separate public recipe repository: [`rhgo1749/qwen3.8-flash-next-strata-gpu-per-lane-recipe`](https://github.com/rhgo1749/qwen3.8-flash-next-strata-gpu-per-lane-recipe).
-The production baseline remains independent GPU lanes with a shared host expert arena. The current promoted engine generation is **Strata 0.1.38**, integrated from upstream `99f3dbd0b21d1401b3769e0c0d963913607f380b`. The 0.1.38 sync preserves the Phase 1/2 serving-control contract and the Phase 3 leader/follower shared-arena lifecycle while absorbing upstream 0.1.35-0.1.38 prompt/decode, reliability, telemetry/security, expert-profile persistence, unbuffered loading, and optional peer-tier work. Ordinary lanes explicitly strip inherited engine-internal multi-GPU flags and shared `--expert-profile-save` writers so only a future explicit Super-Lane/profile-ownership contract can re-enable them. The bounded compatibility gate passed the full Python serving suite (256 tests, 7 skipped), a CUDA 13.4 sm_120 Release `strata` build, focused shared-arena/profile source CTests, and a live three-lane shared-arena generation smoke. A matched single-lane compatibility A/B against the still-installed 0.1.31 production binary showed higher long-prompt prefill on 0.1.38 at roughly +9.5% for ~15K tokens and +7.3% for ~30K tokens; this is a bounded sync check, not a replacement for the retained full 0.1.30/0.1.31 campaigns. Upstream layer-split/peer execution remains complementary to the fork's independent request/session lanes. Hardware-specific measurements and caveats live in the public recipe repository.
+The production baseline remains independent GPU lanes with a shared host expert arena. The current promoted engine generation is **Strata 0.1.38**, integrated from upstream `99f3dbd0b21d1401b3769e0c0d963913607f380b`. The next sync candidate is **Strata 0.1.39**, merged on `sync/upstream-0.1.39` at `3727c6bdfb12ba2ffc07402b68ae86c0ed2ac27f` from upstream `6f32ec070f23ced9f50e704d854d775da52591ab`; it is validated but not promoted until main moves. The candidate preserves the Phase 1/2 serving-control contract and the Phase 3 leader/follower shared-arena lifecycle while consuming upstream 0.1.39 batching, Responses API, conversation-cache telemetry, decode/kernel, CPU-pool, expert-cache and low-RAM/file-tier work. Ordinary lanes explicitly strip inherited engine-internal multi-GPU flags plus `parallel` / `--batch` / `--slots` / `--batch-groups` / `--trim-stage-weights`, and shared `--expert-profile-save` writers, so only future explicit contracts can re-enable nested execution. The 0.1.39 bounded gate passed the full Python serving suite (**333 tests, 7 skipped**), fresh CUDA 13.4 sm_120 Release builds with tests both off and on, focused `pinned_shared_test`, `expert_profile_save_test`, and `file_expert_source_test` CTests, a live single-lane model smoke, and a live three-lane shared-arena concurrent-generation smoke; follower lanes logged that the shared population was ready and skipped the repeated source expert load. The earlier 0.1.38 matched long-prompt compatibility A/B against the installed 0.1.31 binary remains historical evidence at roughly +9.5% for ~15K tokens and +7.3% for ~30K tokens; it is not relabeled as 0.1.39 performance evidence. Upstream layer-split/peer/batch execution remains complementary to the fork's independent request/session lanes. Hardware-specific measurements and caveats live in the public recipe repository.
+
+The 2026-10-05 reference-host topology recheck changes one challenger conclusion without changing the promoted topology. The fixed upstream-native three-GPU layer-split pipeline (`--layer-split 18,34 --batch 3 --batch-groups 3 --trim-stage-weights`, shared expert arena) measured **147.84 ± 2.26 tok/s** at M=2 versus **143.19 ± 3.06** for two independent lanes (**+3.25%**) and **209.66 ± 4.02 tok/s** at M=3 versus **192.16 ± 4.11** for three lanes (**+9.11%**). The retained same-binary single-request layer-split control is **120.62 ± 1.24 tok/s** versus **73.63 ± 1.67** for one lane, but M=1 was not rerun under the exact batch3/groups3/trim config. Cold-prefill still crosses by workload length: ~15K x3 strongly favors lanes (**5901.34 vs 3289.19 tok/s**), while ~110K x3 narrowly favors layer split (**6028.09 vs 5822.71 tok/s**). Every retained PP request reported `cache_n=0`; the nonce text is workload/length matched rather than byte-identical across topology arms. The ordinary FIFO path is now treated only as a serial control. This evidence reopens static layer split / Super-Lane as a serious topology candidate but does not by itself change the production default. See `docs/strata-0.1.39-performance-crossover-20261005.md`.
## Template for a decision-bearing Issue
diff --git a/docs/DETAILS.md b/docs/DETAILS.md
index 9d2cc7041a..d9484e6528 100644
--- a/docs/DETAILS.md
+++ b/docs/DETAILS.md
@@ -107,6 +107,11 @@ English/code subset from before (40,525 ids, ~110 MiB less VRAM, English answers
almost no drafts). `--draft-vocab cyrillic` takes the English/code subset plus the whole Cyrillic script (58,963
ids): the shipped subsets hold 142 of the vocabulary's 18,580 Cyrillic tokens, so Ukrainian or Russian answers got
1.4 tokens a round; with it 2.1, and 83 -> 109 tokens/s (RTX 5090, the NVFP4 fork), English unchanged.
+`--draft-vocab fr` (0.1.39, #597) takes the English/code subset plus the 5,686 tokens that cover 99% of a French
+Wikipedia corpus (46,211 ids, `tools/draft_vocab.py --corpus`): 23.6% of French text's tokens were outside the
+English/code subset, 0.7% are outside this one. Drafts accepted in French answers 0.51 -> 0.60 (IQ3_XXS, RTX 5070,
+8 prompts x 2 passes; English 0.61 -> 0.63 and code 0.77 -> 0.78, no loss), and 141 -> 158 tok/s in French on an
+RTX 5090 (IQ3_S, the reporter's measurement).
`tools/draft_vocab.py` builds and inspects subsets. When the start stops with "the draft head does not fit" (a
12 GB card with a long context, #474), the engine says how much the head needs, how much VRAM is free and which
smaller subset fits, and the server's start error repeats it; setup suggests `--draft-vocab en` on cards under
@@ -145,6 +150,13 @@ other ~18 GB), a 32 GB PC with a 12-16 GB GPU the Coder; IQ3_XXS on a 32 GB PC s
with `--resident-experts` started with `--gpus` switches to `--mmap-experts` with a note, and the engine runs that
pair as `--mmap-experts` with a warning instead of refusing it.
+**A mapped arena for small RAM (Linux, opt-in, 0.1.39, PR #640):** `STRATA_ARENA_MMAP=1` maps a native pack's expert
+arena read-only from the pack's `experts.bin` instead of reading it into locked RAM, for a PC whose GPUs hold most
+experts but whose RAM is small (2x 16 GB GPUs with 32 GB of RAM: ~1 GB -> 25 GB available while serving). The first
+start writes `experts.bin` (when the drive has room for it), later starts map it; the pages of the experts a GPU holds
+are handed back to the OS. Run it with `--pcie-frac 0` (the GPUs get no mapped alias). Without the variable nothing
+changes.
+
**Low-RAM mode without `experts.bin` (engine 0.1.31):** for the native packs (IQ2_XS, IQ3_XXS, IQ3_S, the Coder, Swift,
Q2_0 packed by `tools/iq_pack.py`; not the canonical Q2_0 pack setup makes for AVX-512 CPUs) the mapped mode no longer
needs the pack's `experts.bin`: when the pack has none, the engine
@@ -173,6 +185,19 @@ been warmed). The server log has the same per request (`expert tiers: GPU ... hi
blobs ... MB read`), and `GET /metrics` lists `ram_blobs`, `file_blobs` and `file_mb` for each recent request (with
engine 0.1.31 or newer). It also lists each request's speculative drafts, `drafts_offered` and `drafts_accepted`
(`null` when the engine did not report them), and their sums since the server started in `totals` (#457).
+Its `hit_rate` is the VRAM share of the experts looked up while answering: experts the GPU reads over PCIe
+(`--pcie-frac`) are not in it, so a higher `--pcie-frac` raises it even when decoding gets slower. `pcie_share`
+(engine 0.1.39 or newer, #588) is their share of all routed experts, and the server log and the Monitor tab show it
+beside the hit rate.
+
+**Where a decode window's time goes (profiling, #610):** start the server with `STRATA_DECODE_TIMING=1` (and
+`STRATA_VERIFY_PROFILE=1` for the GPU's side) in the environment. After each request the engine log then has one
+`strata decode timing:` line - windows, tokens per window, and per window the verify time split into the wait for the
+GPU, the CPU expert pool (plan, activation quantization, jobs) and the stage, plus commit and draft - and one
+`strata decode GPU stages (ms/window):` line with the GPU time of each stage (GDN and QSA layers, the VRAM expert
+hits, the router, the head, ...). The GPU profile times every stage with events, so it slows the decode a little:
+use it to compare, not to measure speed. This works with every pack; `--gpu-stages` (a one-token replay of
+per-layer graphs) refuses a native (IQ) pack, which has no such graphs.
Time to first token is prompt length / prompt speed: with Q2_0 about 4 s at 4K, 25 s at 32K, under 2 minutes at 128K
and 4.5 minutes at 262K (engine 0.1.13 made long prompts about twice as fast, below).
@@ -208,7 +233,9 @@ card gains depends on its PCIe link (the experts stream over it), so they are st
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. (Since 0.1.14 the expert profile ranks
all 24,576 experts; before, the cache stopped at 8,000, about 10-14 GB. `tools/make_profile.py` builds a profile from
-your own prompts: run the engine once with `--dump-routing trace.bin`, see the tool's help.)
+your own prompts: run the engine once with `--dump-routing trace.bin`, see the tool's help. The shipped profile already
+ranks every pair, so a trace only changes the order with `--reorder` (0.1.39, #587): your trace's pairs first, then the
+base's, then the rest.)
## Which model?
@@ -276,8 +303,8 @@ Everything else is installed for you the first time.
| --- | --- |
| GPU | NVIDIA **RTX 20, 30, 40 or 50 series**, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070 and an RTX 3090; RTX 20 (Turing, since 0.1.27) was tested by a contributor on an RTX 2070. Or AMD **Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700, RX 6800 / 6900 series**: [AMD_HIP.md](AMD_HIP.md). |
| 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. |
+| CPU | x86-64 with AVX2 (any Intel/AMD desktop CPU from the last ~8 years). AVX-512 (Ryzen 7000/9000) is a bit faster. Older CPUs without AVX2 are experimental and slow: [Older CPUs](INSTALL.md#older-cpus-experimental). |
+| 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 pack for the fast CPU kernel (34 GB of experts in its layout, plus the dense weights). 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: in this folder `.venv/`, `engine/` and `third_party/`; the model files (`models/`,
@@ -414,6 +441,18 @@ the OS file cache, so loading again takes seconds while that RAM is not needed e
16 GB with Q2_0 in the low-RAM mode: unloading takes ~0.3 s, and a request to an unloaded model answered after
4.6 s (text) or 14.7 s (a picture, image encoder on the CPU).
+**Giving part of the VRAM back while it keeps serving (#533, opt-in, one NVIDIA GPU).** With `"vram_elastic": true`
+in the config (the engine flag `--vram-elastic`), the expert cache is allocated in 512 MiB segments
+(`"vram_segment_mib"`), and `POST /v1/vram` with `{"reserve_mib": 8000}` shrinks it between requests until that much
+VRAM is free for another program; `{"reserve_mib": null}` grows it back towards its full size, keeping the reserve
+the engine started with (`--vram-reserve-mib`), and `{"reserve_mib": 0}` takes all of it back. A request that is
+running finishes first. The experts of the segments given back are computed on the CPU, like any expert outside the
+cache, so answers keep coming, slower; growing back puts the same experts in the same slots. Nothing resizes on its
+own. Not with a layer split, the helper caches, `--peer-device` or the resident low-RAM mode. Measured on an RTX 5070
+12 GB with Q2_0 (4.8 GiB cache): `{"reserve_mib": 6000}` took 78 ms and freed 4.3 GiB (the cache keeps 0.5 GiB for
+the prompt path), decode 44 -> 33 tok/s; growing back took 92 ms and the answers were token for token the ones before
+the shrink. Without the flag nothing changes (the same answers as without it).
+
**Keep what the expert cache learned across restarts (opt-in, engine 0.1.36, #477):** a start fills the GPU's expert
cache from the shipped profile, and the adaptive tier (`--adapt-every`) then moves in the experts your requests use.
With `"expert_profile_save": "expert-profile-learned.bin"` in `strata-.json` the engine saves that as a
@@ -435,6 +474,7 @@ The server listens on `http://127.0.0.1:8080` (change with `--port` in setup, or
| --- | --- |
| OpenAI Chat Completions (stream and non-stream, tools) | `POST /v1/chat/completions` |
| Anthropic Messages (stream and non-stream, tools) | `POST /v1/messages` |
+| OpenAI Responses (stream and non-stream, tools; stateless, [below](#the-responses-api-and-codex-cli)) | `POST /v1/responses` |
| Model list / health | `GET /v1/models`, `GET /models`, `GET /health` |
| Model properties | `GET /props` (also accepts `?model=`) |
| What the model is doing right now | `GET /status`, `GET /slots` (single slot, busy or idle) |
@@ -473,6 +513,18 @@ print(r.choices[0].message.content)
part of the thinking the client sees and counts as output tokens. `"reasoning_budget_tokens": N` in
`strata-.json` sets it for every request; a request's own value wins, and `0` means no budget. Off by default;
Anthropic's `"thinking": {"budget_tokens": N}` still only chooses the level, as above.
+- **A reply stuck on one token is ended (0.1.39, #606).** When a reply repeats the same token 256 times in a row, the
+ server ends it there with `finish_reason` `"length"` and says so in its window: a model in a loop, or a broken
+ state that answers one token forever (#606 saw 36,689 tokens of `!`). `"repeat_stop_tokens": N` in
+ `strata-.json` sets the run length; `0` turns it off (for a request that really wants one token many times).
+- **Changing the effort without re-reading the prompt (opt-in, 0.1.39, #458).** The effort's instruction is the
+ first thing in the prompt, so a request that only changes the effort (an agent's "think harder" switch, `none` for
+ a quick tool step) reads the whole conversation again. `"effort_position": "end"` in `strata-.json` renders
+ every request's prompt start as the default effort's and puts a `low` / `medium` effort in a short system turn
+ right before the answer (no thinking: the empty thinking block, as always); the engine (0.1.39+, which the server
+ checks) keeps its checkpoint in front of that turn, so the next request reuses the conversation whatever its
+ effort. The default (`"start"`) prompt is unchanged. The model sees a level that is not the default in another
+ place than it was trained with; how well it follows it there is not measured yet.
- **Anthropic requests that don't ask for thinking (opt-in, 0.1.32, #278).** By default a `/v1/messages` request
with no `"thinking"`, effort or budget thinks as the model's template does. `"anthropic_thinking": "on_request"` in
`strata-.json` renders such a request without thinking - Anthropic's own rule, and what Claude Code's short
@@ -520,6 +572,7 @@ print(r.choices[0].message.content)
- **Claude Code** (Strata 0.1.17 or newer): set `ANTHROPIC_BASE_URL=http://127.0.0.1:8080` and
`ANTHROPIC_MODEL` to a Claude model name it knows (it refuses names it doesn't; Strata ignores the name), plus any
`ANTHROPIC_AUTH_TOKEN` (or your `api_key`, if you set one).
+- **Codex CLI** (0.1.39): see [the Responses API](#the-responses-api-and-codex-cli) below.
- **Context.** Chosen in setup (8K-262K). Requests longer than that are refused, never silently cut. A request whose
`max_tokens` would run past the context is refused too (400); agents that always ask for their full output cap
can instead get it shortened to the room left: add `"fit_max_tokens": true` to `strata-.json` (or pass
@@ -527,6 +580,14 @@ print(r.choices[0].message.content)
- **Model aliases** (0.1.32). `"aliases": ["qwen", "local-model"]` in `strata-.json` lists the model under
those names too in `/v1/models` (each with its own `id`, and in the model's `aliases`), like llama-server's
`--alias`; a request naming one is answered under that name. Any other name is still served, as before.
+- **Model settings in the web page (0.1.39, #564).** The About tab's Model settings card shows and changes a few of
+ the keys above in `strata-.json`: the `sampling` defaults (temperature, top_p, top_k, min_p),
+ `reasoning_budget_tokens`, `fit_max_tokens`, `anthropic_thinking`, `effort_position`, `aliases`, `idle_unload_s`,
+ `lazy_load`, `engine_silence_s`, `api_monitor`, `open_browser` and `--vram-reserve-mib`. An empty field removes the
+ key (its default). Every other key of the file stays as it is, the earlier file is kept as
+ `strata-.json.bak`, and the model uses the change from its next start. Only Strata's own page can save
+ (JSON, the API key when one is set, as for the Chat settings); the network, key, MCP and program keys are not
+ editable there.
- **From other devices on your network.** The server listens on your PC only (`127.0.0.1`) unless you say otherwise:
run setup with `START-HERE.bat --setup --host 0.0.0.0 --api-key some-long-secret` (or add `"host": "0.0.0.0"` and
`"api_key": "..."` to `strata-.json`). The server window then prints this PC's addresses
@@ -581,13 +642,20 @@ in a bounded 8 GiB host-RAM cache. This preserves controller/worker histories wh
their requests alternate; it does not execute requests concurrently. No client session
ID is required: only exact token/image prefixes with matching steering mode are reused.
The default budget is 0 (disabled); `--prompt-cache 0` also disables parking.
-The initial shared-core integration supports a single session GPU: combining
-enabled parking with `--layer-split` is rejected before model loading. Ordinary
-upstream layer-split checkpoints remain available with parking disabled. FP16,
+With `--layer-split`, a parked conversation holds one image per stage (each later stage's
+session and its share of the conversation checkpoints), every image is validated before
+anything is overwritten, and each is restored on its own GPU. Verified on a 4-GPU split:
+a 9,276-token conversation restored in 51 ms, and its follow-up decodes exactly the tokens
+it decodes when the conversation never left (`tools/parking_test.py`). FP16,
INT8, Q4_0 and identity-layout K8V4 snapshots are supported; the K8V4 draft ring
remains INT8, as in upstream. Windows/HIP and multi-GPU runtime coverage must be
reported separately from Linux/CUDA evidence.
+The web page's Monitor tab has a **Conversation cache** card (0.1.39, #596): the parked conversations against the
+slots and the RAM budget, how many were parked, restored and evicted, and the last switch (read from the engine's
+log), and for every setup how many prompt tokens the cache gave back - in the last request and since the start.
+`/metrics` has the same under `"conversation_cache"`.
+
Snapshots contain running state, checkpoints, used K/V pages, and draft-layer K/V.
They add host RAM, not another model or VRAM allocation. The byte budget also counts
an incoming snapshot during a switch. After a restore, unchanged K/V pages can be
@@ -615,8 +683,9 @@ sizes and K/V bytes reused during capture. `STRATA_SNAPSHOT_FULL_CAPTURE=1` disa
retention for diagnostic comparisons. Snapshots are not
persisted across restarts.
-**Current limits (v1):** one request at a time, and one conversation cached at a time (switching between two chats
-re-reads the other one unless the opt-in cache above is enabled); images only when set up with them (below); no video. **Temperature / top_p / top_k / min_p /
+**Current limits (v1):** one request at a time unless `"parallel": N` is set (opt-in batch slots, up to N requests
+decoded together: [BATCHING.md](BATCHING.md)), and one conversation cached at a time (switching between two chats
+re-reads the other one unless the opt-in cache above is enabled, or each conversation keeps its own batch slot); images only when set up with them (below); no video. **Temperature / top_p / top_k / min_p /
seed** are honored per request (OpenAI and Anthropic fields); with the default adaptive expert tier a sampled result
is not reproducible run to run - for seed-reproducible output add `--adapt-every 100000` (static residency) to the
engine arguments. The run config's optional `sampling` block sets the defaults for requests that leave the fields out
@@ -631,6 +700,65 @@ without penalties, so more of its guesses are now rejected. Requests without pen
---
+### The Responses API and Codex CLI
+
+`POST /v1/responses` (0.1.39, #451) speaks OpenAI's newer Responses API, which Codex CLI uses (it no longer
+speaks Chat Completions). It runs on the same path as `/v1/chat/completions`, so the thinking levels, the thinking
+budget, the conversation cache and the same API key, Host and Origin checks apply.
+
+It is **stateless**: nothing is stored, so the client sends the whole conversation in `input` every time (Codex does,
+with `store: false`). `previous_response_id`, `conversation`, `background` and the retrieve/delete/cancel endpoints
+are refused with an error that says so.
+
+| Request | What Strata does |
+| --- | --- |
+| `input` as a string, or as items | `message` items (`user`, `assistant`, `system`, `developer`; text and images), `reasoning`, `function_call`, `function_call_output`, `custom_tool_call(_output)` |
+| `instructions` | The system message (with leading `developer` messages; later ones become user messages, as on the chat path) |
+| `tools` | `function` tools, `namespace` tools (the model sees `namespace.name`; calls come back with `namespace` and `name`), `custom` tools (one free-form `input` string). Hosted tools (`web_search`, `file_search`, ...) are left out: the model cannot run them |
+| `tool_choice` | `"none"` hides the tools; anything else lets the model choose (it cannot be forced) |
+| `reasoning.effort` | `none`/`minimal`, `low`, `medium`, `high`/`xhigh`; without it the model's default (high) |
+| `max_output_tokens` | The output cap (thinking included). Running out ends the response `incomplete` (`max_output_tokens`) |
+| `text.format` | `json_schema` and `json_object` use the [JSON response formats](#json-response-formats) (checked, not constrained) |
+| `temperature`, `top_p`, `reasoning_budget_tokens`, ... | As on the chat path |
+
+The model's thinking comes back as a `reasoning` output item with `reasoning_text` content (streamed as
+`response.reasoning_text.delta`). This model writes no separate summaries, so `summary` is empty. With
+`"include": ["reasoning.encrypted_content"]` the item also carries `encrypted_content`: an opaque string (base64, not
+encrypted; the client already holds the text). Send the reasoning items back with the rest of the conversation, as
+Codex does: their thinking goes back into the prompt, so it matches what the model wrote and the conversation cache
+is reused.
+
+With `"stream": true` the events are the official ones, in order: `response.created`, `response.in_progress`, then
+for each output item `response.output_item.added`, its deltas (`response.reasoning_text.delta`,
+`response.output_text.delta`, `response.function_call_arguments.delta`), its `...done` events and
+`response.output_item.done`, and at the end `response.completed`, `response.incomplete` or `response.failed`.
+While a long prompt is read, a `response.in_progress` event goes out every 15 s, which Codex counts as activity.
+Errors before the answer starts have the Responses form (`{"error": {"message", "type", "param", "code"}}`); after
+it started they arrive as `response.failed`.
+
+**Codex CLI.** In `~/.codex/config.toml` (Windows: `%USERPROFILE%\.codex\config.toml`):
+
+```toml
+model = "strata" # any name; Strata answers with its model
+model_provider = "strata"
+model_context_window = 32768 # the context you chose in setup: Codex compacts before it gets there
+show_raw_agent_reasoning = true # show the model's thinking (it writes no summaries)
+# model_reasoning_effort = "medium" # none, low, medium or high; default: the model's (high)
+
+[model_providers.strata]
+name = "Strata (local)"
+base_url = "http://127.0.0.1:8080/v1"
+wire_api = "responses"
+stream_idle_timeout_ms = 600000 # a first, long prompt can take minutes to read
+# env_key = "STRATA_API_KEY" # only if the server has an api_key: the variable holding it
+```
+
+Then run `codex` (or `codex exec "..."`) as usual. Codex warns `Model metadata for ... not found` for a local model
+name; that is expected. Measured with Codex CLI 0.160.0 and Q2_0 on an RTX 5070: its first prompt (instructions and
+tool descriptions) was 9,443 tokens, read in 10 s; in a tool loop, each later turn reused about 96% of the prompt from
+the cache and read only the new part in 1-2 s. On Windows, Codex's sandbox rejected every shell command in that test
+until it was started with `-c 'windows.sandbox="unelevated"'` (a Codex setting, not Strata's).
+
## Tools from MCP servers
The chat page can give the model tools from [MCP](https://modelcontextprotocol.io) servers, as LM Studio and Claude
@@ -748,6 +876,17 @@ helper (`strata-vision`, from llama.cpp's `mtmd` library) and adds it to your st
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.
+**More image tokens (0.1.39, #625):** `--vision-tokens N` at setup (`START-HERE.bat --setup --vision cpu
+--vision-tokens 768`) sets the most tokens a picture becomes - `"max_tokens"` in the `"vision"` section of
+`strata-.json`, which you can also edit by hand. More tokens keep more detail (small text, charts, screenshots)
+and take longer to encode, on the CPU most of all; a setup run again keeps the value.
+
+**A Q8_0 encoder (#625):** `"mmproj"` in the `"vision"` section can point to another mmproj file of this model, for
+example a Q8_0 one (llama.cpp's `convert_hf_to_gguf.py --mmproj --outtype q8_0` makes one): the encoder's library
+reads quantized weights, the file is half the size, and on the CPU it can encode faster than BF16. Setup downloads
+the BF16 file, and a setup run again keeps a file of your own that still exists. We have not measured its accuracy
+against BF16 yet; numbers are welcome in #625.
+
**A spare GPU for the encoder (0.1.33, #408):** with a card the engine doesn't use, add `"cuda_device": 2` (numbered
like `nvidia-smi`) to the `"vision"` section of `strata-.json`: the encoder then runs on that card alone. Lower
`--vram-reserve-mib` in `"args"` to 700 as well, so the engine's cards keep that VRAM for the expert cache. The
@@ -872,6 +1011,7 @@ the document, +0.4% on the chat. Details: `bench/results/2026-09-27-esp/`.
| Pictures are slow (10-30 s) | The encoder runs on the CPU: run setup again with `--vision gpu` (needs ~1.4 GB of VRAM). |
| A request never finishes: "reading the prompt", GPU "100%" at low power | The GPU ran out of VRAM (engines before 0.1.9 could end with ~30 MiB free at large contexts). Run `START-HERE.bat` once to get engine 0.1.9 or newer; the log then says `... MiB of VRAM free with everything loaded` (a few hundred) and names the `--vram-reserve-mib` to add if it is low. |
| Generation stops mid-answer, GPU "100%", one CPU core busy | Fixed in engine 0.1.12 (issue #29, a race in the CPU expert pool on big-VRAM cards). Since then a request that stops moving ends with an error instead of hanging (after 2 minutes; 1 minute from 0.1.13): the log says `no progress for ... s ... (issue #29)` with where it stopped, and the next request starts the engine again. If you see that line, please open an issue with it. Engine 0.1.13 adds a stall report under it (what every expert-pool thread and the GPU handshake were doing, memory and page faults) and, on Windows, a `strata-stall-.dmp` file with every thread's stack: attach both. (`STRATA_WATCHDOG_S` sets the time in seconds; 0 turns it off.) Engine 0.1.14 fixes the stall those reports found (issue #31: with the IQ packs the host could wait forever inside the NVIDIA driver while copying experts in a verify window; the experts are now copied by a GPU kernel, `--pcie-mode dma` restores the old way). |
+| `no progress for 60 s ... reading the prompt` on Linux, and the stall report says `threads waiting on the disk (state D): 16 ...` | The engine waits for the drive, not a deadlock: the n-gram table is read at random (`--ple-io direct`), which a rotational disk cannot keep up with (#605). The engine warns at start when the table is on one; `--ple-io ram` (Linux, needs RAM for the table) or the model on an SSD fixes it. Setup adds `--ple-io ram` itself on a rotational disk when the RAM holds the table (0.1.39). |
| `the engine said nothing for ... s during the request` or `... did not finish the request after it was stopped (STOP)` | Issue #481: the engine and the server lost step (the engine waits for its next command, the server for the request's end; GPU at 0 %, nothing in the log). The server ends the engine after 300 s without a line from it during a request (while a prompt is read: each chunk may take three times the previous one's time, the first one up to its tokens at 50 tok/s more), the request ends with an error and the next request starts the engine again. `"engine_silence_s": 600` in `strata-.json` sets the time (0 = wait forever, as before). If you see it, please add the end of the engine log to #481. |
| `out of memory: cudaFuncSetAttribute` in the log (IQ3_XXS, long prompt) | Fixed in engine 0.1.15: CUDA loaded a kernel's code when it was first needed, and mid-prompt there was no VRAM left for it. Run `START-HERE.bat` (Windows) or `./setup.sh` (Linux) once to update. |
| Anything else | The engine log is `strata-.log` in this folder. |
@@ -887,12 +1027,26 @@ the document, +0.4% on the chat. Details: `bench/results/2026-09-27-esp/`.
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.
+- **SSD:** the 28.8 GB n-gram table, a few rows per token, read unbuffered past the OS cache (`--ple-io direct`, the
+ default, made for SSDs; on a rotational disk `--ple-io ram` keeps the table in RAM, #605).
- **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. When the reply repeats the context (code edits, quoted text), **prompt lookup** (engine
0.1.7) drafts up to 5 tokens from the earlier copy, but only where its measured acceptance and cost say it pays:
code edits 6-11% faster, other text unchanged. The drafts are checked like the MTP's, so the output is the same.
-- **Prompts** are processed in 2,048-token chunks with the experts streamed to the GPU over PCIe.
+- **Prompts** are processed in chunks of up to 8,192 tokens (`--prefill auto`; 32,768 opt-in) with the experts
+ streamed to the GPU over PCIe. Since engine 0.1.39b (#583) the streamed ring is sized in bytes for the pack (a
+ native pack with bigger blobs gets fewer ring slots and keeps more cache slots) and `--prefill auto` picks the
+ largest chunk that keeps that ring full. It only gives up ring slots where 0.1.39's rule held the chunk at 4,096
+ tokens or less; from 6,144 on it keeps 0.1.39's ring and only looks for a bigger chunk beside it (shrinking the
+ ring there measured slower: the Coder -12%), and a prompt that fits 0.1.39's chunk keeps 0.1.39's ring (one
+ chunk: a smaller ring only slowed it). RTX 5070, 32K prompts against `STRATA_RING_BYTES=0`, 2-3 interleaved
+ rounds: IQ3_XXS +18.5% at a 1,500-slot cache and +9% in another memory state, the Coder +3%, IQ3_S unchanged; 4K
+ prompts unchanged (-0.7% to +1.1%, bit-identical). Q2_0 (a fixed `--prefill 2048`) is not affected. It changes
+ the bits of a long prompt against 0.1.39 (the experts go through a different
+ mix of cached and streamed groups). Measured quality, teacher-forced over the next 2,001 tokens of a long document
+ against the FP16 prompt path (IQ3_XXS, `--prefill auto`): 8K prompt KL 0.042 (0.1.39: 0.054), top-1 agreement
+ 93.5% (92.2%); 32K prompt KL 0.020 (0.019), top-1 95.3% (95.0%) - the same band as before. `STRATA_RING_BYTES=0`
+ restores 0.1.39's ring, loan and chunk choice.
The full story, with measurements, bottlenecks and what comes next: **[docs/paper/Strata-Paper.pdf](paper/Strata-Paper.pdf)**.
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index 7af3a3e8bf..4ab28fb12c 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -5,9 +5,9 @@ AMD graphics card. The short version is in the [README](../README.md#install); a
this for you with [AI_SETUP.md](AI_SETUP.md).
> **On this page:** [What you need](#what-you-need) · [Windows](#windows) · [Linux](#linux) ·
-> [AMD cards](#amd-cards) · [Several cards](#two-or-three-cards) · [Docker](#docker-linux) · [Updating](#updating) ·
-> [Where things are stored](#where-things-are-stored) · [Setup's questions](#setups-questions) ·
-> [Tuning](#tuning-for-your-pc) · [All options](#options-without-questions)
+> [AMD cards](#amd-cards) · [Several cards](#two-or-three-cards) · [Docker](#docker-linux) ·
+> [Older CPUs](#older-cpus-experimental) · [Updating](#updating) · [Where things are stored](#where-things-are-stored) ·
+> [Setup's questions](#setups-questions) · [Tuning](#tuning-for-your-pc) · [All options](#options-without-questions)
## What you need
@@ -15,7 +15,7 @@ this for you with [AI_SETUP.md](AI_SETUP.md).
| --- | --- |
| **GPU** | **NVIDIA** RTX 20, 30, 40 or 50 series, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070 and an RTX 3090; RTX 20 (Turing, since 0.1.27) was tested by a contributor on an RTX 2070. **AMD** Radeon RX 7900 XT / XTX, RX 9070 / 9070 XT and Radeon AI PRO R9700 (validated), RX 7800 XT / 7700 XT and RX 9060 XT (validated by their owners), RX 6800 / 6900 series (community-reported), with 12 GB of VRAM or more. See [AMD cards](#amd-cards). |
| **RAM** | Enough for the size you pick ([which model](MODELS.md#pick-by-ram)); **64 GB** runs every size. A big GPU makes up for less RAM - the [low-RAM mode](MODELS.md#a-big-graphics-card-and-little-ram). |
-| **CPU** | x86-64 with AVX2 (any Intel/AMD desktop CPU from the last ~8 years). AVX-512 (Ryzen 7000/9000) is a bit faster. |
+| **CPU** | x86-64 with AVX2 (any Intel/AMD desktop CPU from the last ~8 years). AVX-512 (Ryzen 7000/9000) is a bit faster. Older CPUs without AVX2 are experimental and slow: [Older CPUs](#older-cpus-experimental). |
| **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. On Linux with an AMD card, ROCm takes ~10 GB more when setup installs it. An NVMe SSD is strongly recommended: it makes the first start much faster. |
| **OS** | Windows 10/11, or Linux (Ubuntu 22.04/24.04 get everything installed automatically). |
| **Driver** | **NVIDIA:** a current driver, version 580 or newer ([nvidia.com/drivers](https://www.nvidia.com/drivers) or the NVIDIA App). **AMD:** on Linux the kernel's amdgpu driver (no ROCm install needed); on Windows a current AMD Software: Adrenalin Edition driver ([amd.com/support](https://www.amd.com/en/support)). |
@@ -121,6 +121,35 @@ The same idea, in a container (NVIDIA cards).
to a network. The image has a `HEALTHCHECK` on `/health`, so `docker ps` shows the container
healthy once the model is loaded, and `GET /v1/status` says what it is running.
+## Older CPUs (experimental)
+
+Strata's ready-made engine needs AVX2 (Intel Haswell 2013, AMD Zen 2017 or newer). Since 0.1.39 an older CPU - AVX
+only (Sandy Bridge / Ivy Bridge, Xeon E5 v1/v2, AMD Bulldozer) or SSE4.2 only (Nehalem / Westmere, Xeon X5600) - runs
+as an **experimental** build that setup compiles on that PC. Run setup as usual: it says the CPU has no AVX2, warns
+that this is experimental and slow, and compiles the engine (10-20 minutes, once; Windows needs the Visual Studio Build
+Tools and the CUDA Toolkit, which setup offers to install). It does not stop. The ready-made engine still refuses such
+a CPU, and says so.
+
+What differs in that build (`STRATA_ISA_FLOOR=avx` or `none`, chosen from the CPU):
+
+- ggml-cpu is compiled for AVX or SSE4.2 instead of AVX2, and the CPU's share of the experts runs on its kernels.
+ Strata's own AVX2 / AVX-512 kernels stay in the program but are used only on a CPU that has them.
+- Only the i-quant models' packs run (every model setup installs on such a CPU); the AVX-512 Q2_0 pack does not.
+- Speed: the GPU part is unchanged and the CPU part is slower, so a GPU with more VRAM (more experts cached there)
+ helps most. With the older paths forced on a Ryzen 5 7600 + RTX 5070 and the expert cache held at 1500 slots, greedy
+ decode went from 26 to 11-17 tok/s with the AVX build and to 3.5-3.8 tok/s with the SSE4.2 build (Coder and IQ3_XXS,
+ 10 prompts each, one run). An old Xeon with DDR3 will be slower than these.
+- Untested on a real old CPU by us. It was checked here by forcing the older paths on a Ryzen 5 7600
+ (`STRATA_FORCE_ISA=avx` or `sse`): the answers were sane and passed the engine's checks, but not the same token for
+ token as the normal build (ggml-cpu rounds the CPU experts differently, as the AVX2 kernel does from the AVX-512
+ one). Contributors ran earlier versions of the same approach on a Xeon E5-2680 (2.87 tok/s with a GTX 1080 Ti)
+ and on Xeon E5-2687W / X5690 machines.
+- AMD cards: Linux only (setup compiles the AMD engine there anyway); NVIDIA on Windows and Linux.
+- Two-socket Xeons: memory on the other socket is slow; keep the RAM on one CPU if you can.
+
+By hand: `cmake ... -DSTRATA_ISA_FLOOR=avx` (or `none`) builds it; `STRATA_ISA_FLOOR=avx` set for setup builds it on
+any PC (to try it). Problems and results on real hardware are welcome as GitHub issues.
+
## Updating
**`UPDATE.bat`** (Linux: `./update.sh`) updates Strata without starting the model - for when the GPU is busy with
@@ -145,12 +174,19 @@ says so and starts the engine you had.
kept, only their names. Another browser or a private window starts empty; clearing the site's data deletes them.
- **How the model starts:** `strata-.json` in the Strata folder (context, GPUs, host, API key, ...), written
by setup; next to it `run-.bat` / `.sh`, the log `strata-.log` and, when you use "Use for other
- apps too", `strata-.shared-settings.json`.
+ apps too", `strata-.shared-settings.json`. Running setup again for the same model (another context, say)
+ rewrites the keys setup writes (`exe`, `args`, `port`, `gpu`, `host`, `api_key`, `vision`, ...) and keeps the
+ ones you added (`sampling`, `mcp_servers`, `mcp`, `cors_origins`, ...); the earlier file is kept as
+ `strata-.json.bak` (0.1.39). Engine options you added to `"args"` by hand are not carried over: setup names
+ them, and you add them again.
- **The model files** (`models/`, `packs/`, `mtp/`, 70-120 GB): in **`Strata-data` next to the Strata folder**, or
wherever `--data-dir` put them.
- **Where that data folder is:** `%APPDATA%\Strata\settings.json` on Windows, `~/.config/strata/settings.json` on
Linux ([details](DETAILS.md#before-you-start)).
- **The program itself:** `.venv/`, `engine/` and `third_party/` in the Strata folder.
+- **Where github.com cannot be reached:** a build from source downloads llama.cpp's source (ggml, gguf-py) from
+ GitHub at the commit `LLAMA_CPP_COMMIT` in `setup.py` names. Put that commit's tree in `third_party/llama.cpp`
+ yourself (from a mirror or another PC) and setup uses it instead (#585).
## Setup's questions
@@ -187,6 +223,8 @@ START-HERE.bat --port 8081 another port
START-HERE.bat --gpu 1 another GPU (setup picks the one with the most VRAM)
START-HERE.bat --gpus 0,2 several GPUs sharing the model
START-HERE.bat --vram-reserve-mib 2048 leave 2 GB of VRAM free for other programs (remembered)
+START-HERE.bat --no-browser do not open the chat page when the model is ready (remembered;
+ --browser undoes it)
START-HERE.bat --setup --backend hip the AMD engine on a PC that also has an NVIDIA card
START-HERE.bat --setup --host 0.0.0.0 --api-key reachable from other devices, with a key
START-HERE.bat --calibrate tune the engine for this PC (about 5-10 minutes), then start
diff --git a/docs/INTEL.md b/docs/INTEL.md
new file mode 100644
index 0000000000..38b331eed3
--- /dev/null
+++ b/docs/INTEL.md
@@ -0,0 +1,652 @@
+# Strata on an Intel Arc
+
+Strata's engine is CUDA. On an Intel Arc it runs as **Strata's own engine, ported to SYCL** (`sycl/`, the
+section "The engine itself on Intel" below), behind the same Strata server: OpenAI and Anthropic APIs, streaming,
+tool calls, MCP and the web app all unchanged. llama.cpp's SYCL backend is the comparison point in the tables (it
+runs the same GGUF at about a third of the speed).
+
+Everything Intel-specific lives in `sycl/`; no shared file of upstream's is changed, so upstream merges stay clean.
+`sycl/setup_intel.py` and `sycl/serve/server_intel.py` wrap upstream's `setup.py` and `serve/server.py` from
+outside.
+
+Written for and measured on an **Arc Pro B70 (32 GB)** running the Coder (IQ1_M) on Ubuntu 24.04, in a
+PCIe 3.0 x8 slot (the card trains at Gen3 x8 there; it can do Gen5 x16).
+
+## What you get (2026-10-01)
+
+| | NVIDIA (Strata engine) | Intel Arc, Strata SYCL port | Intel Arc, llama.cpp |
+|---|---|---|---|
+| models | all four | the Coder IQ1_M; the original IQ2_XS too (experts beyond VRAM in the host mirror: 58-64 tok/s) | the Coder (fits VRAM) |
+| model files | the same GGUFs | the same GGUFs + a native pack | the same GGUFs |
+| where the model lives | experts in RAM, hot ones on the card | every expert in VRAM (`--stream-experts`: no host copy), shard 2's lookup table read from the SSD by row | all of shard 1 on the card, shard 2 paged from disk |
+| RAM needed | 32-64 GB | little (23 GB is fine) | little (23 GB is fine) |
+| decode, Coder IQ1_M | 44-51 tok/s on an RTX 5070 | **78.2 tok/s** (19-token prompt, 256 greedy tokens), **75.7** after a 2,184-token prompt, 69 after 40K, 66.6 after 128K, 55.9 after 256K; 48-72 through the API | 23-25 tok/s |
+| prompt reading | ~1,870 tok/s | **790 tok/s** at 2,184 tokens, 1,117 at 40K, 888 at 128K, 757 at 256K | ~150 tok/s (424 at 105K) |
+| speculative decoding (MTP) | yes | yes (the base checkpoint's draft layer; 70-85% of drafts accepted on code) | no |
+| images | yes | not yet | not yet |
+| context | up to 262K | 256K measured, 56 tok/s decode there (`--kv-resident`: the KV in pinned host memory, the attended window in VRAM) | 131K measured ceiling |
+
+Decode speed with speculative decoding depends on the text: code drafts well, prose less so (see "Speed depends
+on the text"). The SYCL numbers are greedy runs of the engine (as in "How to run it by hand" below, 256 new
+tokens) on engine 0.1.31-sycl (2026-10-01; 0.1.32-0.1.38-sycl reproduce them exactly);
+"Decode round 2" below lists what each change bought.
+
+## Setup
+
+Build the engine and its runtime image first ("How to build it" below), then:
+
+ python3 sycl/setup_intel.py [setup.py's options, e.g. --model IQ2_XS --context 32768 --port 8085]
+
+This is upstream's `setup.py`, run with the Intel steps swapped in (it imports setup.py and replaces those steps;
+setup.py itself is unchanged). The model choice, download, pack, tokenizer, MTP draft layer and the context and KV
+questions are setup's own. What changes:
+
+- **GPU check:** the Arc is found in sysfs (vendor 8086 under `xe` or `i915`) and offered through setup's AMD
+ path. That is the path that builds locally and has no images.
+- **Engine step:** it uses the SYCL build (`build-sycl-aot/strata`, run in the `strata-sycl-dev` image by
+ `sycl/serve/strata-sycl.sh`) instead of compiling CUDA or HIP.
+- **RAM rule:** this does not apply. The CUDA engine keeps every expert in RAM; the port streams them from the
+ GGUF into VRAM (`--stream-experts`), so RAM only decides the KV streaming. `--check` lists what fits by VRAM.
+- **The config:** it uses the container's paths and `"backend": "sycl"`. The VRAM reserve is 1,024 MiB up to
+ 32K and 2,048 MiB with 4,096-token prompt chunks above that. KV streaming (`--kv-resident 32768`) is on from
+ 64K up when the RAM holds the KV. A `model_switcher` or `sampling` block from an earlier config is kept.
+ `run-.sh` starts `sycl/serve/server_intel.py`.
+
+If a future `setup.py` drops a step this relies on, it stops with a message instead of writing a wrong config.
+The models, packs and checkout must sit under the folder `strata-sycl.sh` mounts at `/work` (the one above the
+checkout, or `STRATA_SYCL_ROOT`).
+
+Then `run-.sh` (or `sycl/setup_intel.py` again) starts the model. The first start of a 30 GB model
+takes about two minutes. `--port N` and `--host 0.0.0.0` work as in upstream's setup.
+
+## Things that matter on this GPU
+
+- **`SYCL_CACHE_PERSISTENT` must be 0.** The persistent JIT cache segfaults on Xe2 during the first
+ compile. The start script sets it; if you run llama-server by hand, do too.
+- **The whole model goes on the card** (`--n-gpu-layers 999`), except `per_layer_token_embd.weight`,
+ the single 28.8 GB tensor of shard 2. `--override-tensor per_layer_token_embd=CPU` keeps it in host
+ memory, mmapped and paged from the SSD by row, which is how the model's authors serve it. Host RSS
+ stays around 2 GB.
+- **Thinking.** The model reasons before it answers. Strata's web app has the setting; for an API
+ client, put `{"reasoning_effort": "none"}` in `strata-.shared-settings.json` next to the
+ config, or send `chat_template_kwargs: {"enable_thinking": false}` per request. Without one of
+ those a short `max_tokens` is spent entirely inside the think block and the answer looks empty.
+- **`/health` says 503 while loading**; the server polls `/props` instead.
+
+## How much context fits
+
+The architecture keeps this small: only every fourth layer is full attention (12 of 48); the other
+36 are gated-delta-net layers with a fixed-size recurrent state that does not grow with context.
+Per token, the 12 attention layers keep 2 KV heads x 256 x (K + V) at q8_0, plus the
+sparse-attention indexer's keys. **Measured: about 20 KiB per token** (VRAM grows 0.6 GB per 32k
+of context with everything else unchanged).
+
+| context | VRAM in use, model loaded | headroom on 32 GB | status |
+|---|---|---|---|
+| 32,768 | 28.4 GB | 3.5 GB | measured, the default |
+| 65,536 | 29.0 GB | 2.9 GB | measured, loads |
+| 98,304 | 29.6 GB | 2.3 GB | measured, loads |
+| 131,072 | 30.3 GB | 1.5 GB | **measured, the practical ceiling**: a 104,798-token prompt (79% of the window) read in 247 s at 424 tok/s, then answered |
+| 163,840 | ~31.0 GB | ~0.9 GB | not attempted: under the 1.2 GB safety margin |
+| 262,144 | ~32.6 GB | none | **does not fit - asking for it took the host down** |
+
+**Do not ask for more than fits.** On this driver a GPU allocation past VRAM does not fail: the xe
+driver evicts buffers into host RAM, the kernel runs out of memory, and the machine livelocks until
+its hardware watchdog resets it. A 262K request did exactly that on 2026-09-29, and llama.cpp's own
+"failed to fit params" check fired too late to prevent it. Compute the KV size first and leave
+1.5 GB free.
+
+Prompt reading gets faster with size, because the work batches better: ~150 tok/s on a 2.7k-token
+prompt, 424 tok/s on a 105k-token one. So a full 128k window costs about five minutes to read,
+not fifteen.
+
+## The engine itself on Intel: the SYCL port (`sycl/`)
+
+Everything above runs the model through llama.cpp. This is the other half: Strata's own engine - the 50 CUDA
+kernels and the host code that drives them (streams, events, graph capture, pinned memory) - built for the
+Arc with oneAPI. It is a migration of the tree, not a new backend: the engine has no backend seam to slot
+into.
+
+**Layout.** `sycl/src` and `sycl/include` mirror the tree and hold only the files the port changes; the
+SYCL build (`sycl/CMakeLists.txt`) takes every other source from the original location. No upstream file
+is edited. `sycl/include/dpct/` is the vendored SYCLomatic helper library, so the port builds without the
+migration tool.
+
+**How it was made, so it can be redone.**
+
+1. `sycl/tools/Dockerfile` - the dev image: the llama.cpp SYCL image plus SYCLomatic (`dpct` 2025.3),
+ ninja, and the CUDA 12.8 headers that `sycl/tools/get-cuda-headers.sh` pulls out of NVIDIA's pip wheels
+ (dpct parses CUDA; it needs the headers, not the toolkit).
+2. `sycl/tools/migrate.sh` - writes a compilation database for the 86 CUDA-touching translation units and
+ runs dpct over them. 85 migrate; dpct reports no line it could not migrate, and about 1,400 advisory
+ notes.
+3. `sycl/tools/fixups.py` - what dpct got wrong or could not do, as an idempotent script with a reason per
+ item. The ones that mattered:
+ - CUDA's null stream means the default stream; dpct turned it into a null `sycl::queue*`. Every
+ stream cast now goes through `strata::q_of()` (`sycl/include/strata/sycl_queue.hpp`).
+ - `__ldg((const float*) p)` came out as `*p`, reading one byte of a float scale (two sites, s_gemv).
+ - `__fadd_rn(a, b ? c : d)` lost its parentheses (two sites).
+ - the ggml lookup tables were threaded through kernel parameters with the wrong table per template;
+ they are plain `static const` arrays read from device code, as ggml-sycl does.
+ - helper headers renamed since dpct 2025.3 (`entangle`, `chunked_partition`), graph introspection and
+ `cudaGraphUpload` (no SYCL equivalents), `%globaltimer` (the stage profiler reads zeros).
+4. `sycl/tools/build.sh` - configure + build with icpx inside the image. Two compiler flags are load-bearing:
+ `-fp-model=precise` (icpx defaults to a fast FP model) and `-cl-fp32-correctly-rounded-divide-sqrt`
+ for the device compiler - the Arc's fp32 divide is not correctly rounded by default (OpenCL allows
+ 2.5 ulp), and Strata's quantizers are byte-exact against ggml through `amax / 127`. Measured: without
+ it `quantize_act_parity` has 303k mismatches, with it none.
+
+**Where it stands (2026-09-29).** The whole tree builds and links: the `strata` binary and 22 kernel
+parity tests. On the B70, 19 of the 22 pass byte-for-byte or within their tolerances; the other three
+need model fixtures (`iq_parity`, `ple_parity`) or the tensor-core kernel below (`qsa_prompt_attn`).
+
+| parity test | result |
+|---|---|
+| bf16_gemv, cvec, dequant_s2, elementwise, gdn, gr, kv_q4, kv_q8, kv_stream, qsa, quantize_act, rope, router_top10, s2_gemv, s2_gemv_q8, s_gemv, s_gemv_q8k, sampler, shared_expert | pass |
+| iq_parity, ple_parity | need fixtures (a `logs/iq_fixture` directory, a Q2_0 shard) |
+| qsa_prompt_attn_parity | needs the tensor-core kernel (below) |
+| kv_hybrid_parity (0.1.27, hybrid K8V4 KV) | appends, gathers and attention pass; its last step is that same tensor-core kernel |
+
+**The engine end to end (2026-09-29, later the same day).** It generates. Prompt "Write a Python function
+that returns the n-th Fibonacci number", greedy, 64 tokens:
+
+> `` The user wants a Python function that returns the n-th Fibonacci number. Let me consider the
+> options: 1. **Recursive approach** - Simple but O(2^n) time complexity, very slow for large n. 2.
+> **Iterative approach** - O(n) time, O(1) ...
+
+Measured on the same card, same 2,185-token prompt, 64 greedy tokens (AOT build, `--spec 2`,
+`--no-prefill-borrow`), against llama.cpp's SYCL build serving the same GGUF:
+
+| | llama.cpp SYCL | Strata SYCL port |
+|---|---|---|
+| prompt reading | 138-319 tok/s | **560 tok/s** (693 at 2,000 tokens, 180 at 300) |
+| decode, suffix drafter only (`--spec 2`) | 24.2-26.0 tok/s | 20.2-20.8 tok/s |
+| decode with the MTP draft layer (`--spec 4 --mtp`) | - | **42.7 tok/s** at the 2,185-token context (81% accepted, 2.9 tokens/round); 45.3 on a short prompt |
+
+Prompt reading is where Strata's design pays (oneMKL GEMM over dequantised experts, the whole model on
+the card). Decode is at ~80%: a speculative round costs ~53 ms whatever its size and the suffix drafter
+is accepted 9% of the time, so nearly every round yields one token; `--spec 4` (16.9 tok/s) was worse
+than `--spec 2` (20.8) for that reason. The kernels themselves run at 130-160 GB/s of weights on a card
+that streams 600 GB/s (`sycl/probe/bw.cpp`, incompressible data) - the same class llama.cpp reaches -
+and five variants of the hot Q6_K matvec (lanes per row, rows per warp, unroll, 16-byte loads, software
+pipelining; `mmvq_bench`) all landed in that band. The draft source was the lever: the base
+Qwen3.8-Flash-Next checkpoint's MTP layer (`tools/mtp_fetch.py fetch`, `mtp_pack.py --experts q2_0`,
+`mtp_rt.py`; 4.9 GB downloaded, 809 MiB of VRAM) drafts for the Coder fine-tune at 87% acceptance:
+3.4 tokens per 53 ms round, 45 tok/s, the same greedy tokens. The `mtp.cpp` path ran on the first try
+under the same flags.
+
+Without AOT the runtime JIT-compiles every kernel on first use, ~47 s the first time a process runs;
+`SYCL_CACHE_PERSISTENT=1 SYCL_CACHE_DIR=` keeps that across runs (15 MB).
+
+How to run it by hand (a greedy test run, the way every number in this section was measured; inside the
+`strata-sycl-dev` image, AOT build in `build-sycl-aot/`):
+
+```
+STRATA_VERIFY_DEVICE_PLAN=1 STRATA_VERIFY_NO_HOST=1 \
+build-sycl/strata --pack --native --ple-gguf \
+ --expert-profile data/expert-profile-coder.bin --expert-cache auto --stream-experts \
+ --prefill auto --spec 4 --spec-min-p 0.5 --max-context 8192 --tokens --max-new 64 --greedy
+```
+
+- `--stream-experts` (this port): no resident host copy of the experts; every one of the 12,288 goes from
+ the GGUF into the VRAM cache through a small staging ring (`GgufExpertSource`). Upstream needs 32 GB of
+ RAM for this model; with the flag the engine runs on 23 GiB.
+- `STRATA_VERIFY_DEVICE_PLAN=1`: the GPU plans each layer itself (upstream's E-6; off by default there).
+- `STRATA_VERIFY_NO_HOST=1` (this port): the host waits for the whole window graph instead of per-layer
+ rings. Only valid with every expert resident, which is the case on a 32 GB card.
+- `--no-prefill-borrow`: the prompt path must not lend expert slots (a lent expert is served from the host
+ behind a flag the GPU does not see reliably here; long prompts hung without it).
+
+What the port had to get right beyond compiling (each is an entry in `sycl/tools/fixups.py` or a flag):
+
+- **Host-mapped flags.** Strata's decode is a GPU/CPU handshake through host-mapped memory. `volatile`
+ device loads do not bypass the caches on Intel; system-scope atomics do, and they are the only thing
+ measured to work (`sycl/probe/doorbell.cpp`, six variants). Host-to-device visibility *during* a kernel
+ stays unreliable on this platform, which is why the all-resident path waits for the window instead.
+- **Bounded spins.** A device spin that never sees its flag is not a hang of one process: the xe driver
+ times the queue out and resets the GT node by node - a window graph has 2,400 - and the card stays
+ wedged until a reboot (twice). Every spin is capped (`kSpinMax`).
+- **32-lane sub-groups.** The kernels are written for warps; dpct pinned 134 of 289 launches, the rest
+ would run at Xe2's default 16 (`-fsycl-default-sub-group-size=32`).
+- **Synchronous copies.** `cudaMemcpy` blocks; dpct's default-queue `memcpy` did not wait. With the
+ streaming ring that filled the expert cache from overwritten buffers: non-deterministic residuals, NaN by
+ layer 5. Found with the per-layer residual ladder (`STRATA_VERIFY_DEBUG=1` prints R after every layer).
+- `native_expert_parity` hand-ported: the GPU native expert kernel matches ggml's float reference on real
+ IQ1_M rows (rel 1.1e-2, the same class as the CPU path).
+
+Profiling: `sycl/tools/Dockerfile.unitrace` builds the dev image with Intel's unitrace; `unitrace -d` around
+the engine plus `sycl/rank_kernels.py` gives device time per kernel. `mmvq_bench` and `native_expert_parity
+NATIVE_BENCH=1` time the two hot kernel families in isolation (warm the clocks first: a 5 ms run measures
+the ramp, not the kernel).
+
+**After the 0.1.25-0.1.27 merge (2026-09-30, same card, same flags, AOT).** Fibonacci prompt (19 tokens),
+64 greedy tokens: decode 46.5 tok/s (85% of drafts accepted). The 2,184-token prompt: 566 tok/s prompt,
+38.3 tok/s decode (77% accepted on that text); the draft layer's prompt pass is now upstream's batched one
+(38.6 ms for 2,184 tokens, from 105 ms for 19 before it). Parity: 19 of 22, byte-exact `quantize_act` back.
+
+**Speed work, 2026-09-30 (after the 0.1.27 merge).** What moved the numbers, measured on the B70 with the
+same prompts as above (outputs identical before and after each change):
+
+| change | effect |
+|---|---|
+| every window graph and the drafter's graphs captured at load (`STRATA_WARM_GRAPHS=0`: as before) | the first request no longer pays 250-290 ms of captures |
+| the commit graph left running while the drafter's round runs on its own queue (`Verifier::commit(wait=false)` + `commit_finish`) | decode 38.3 -> 43.0 tok/s at the 2,184-token context, 46.5 -> 49.8 short |
+| upstream 0.1.29 merged (GDN recurrence pipelining, the block-scores read, sampler) | 2,184-token prompt 765 -> 792 tok/s, decode at that context 43.1 -> 45.2 |
+| the draft layer's batched prompt pass takes prompts under 64 rows (it fell back to per-6-token graphs) | its prompt cost on 19 tokens 106 -> 16 ms |
+| one device module per kernel (`-fsycl-device-code-split=per_kernel`) | the first launch in the prompt path (the embedding gather) 245 -> 1 ms |
+| the expert dequant writes each thread's run of FP16 values as one vector store (it wrote them one 2-byte store at a time) | dequant per expert 0.085 -> 0.030 ms; prompt 496 -> 575 tok/s at 2,184 tokens, 720 -> 841 at 8,000 |
+| SWAR sign compare/subtract in the expert dots, a local-memory resident-plan kernel, a split-K fused down kernel (bit-identical to the single-token one) | parity-clean, no measurable decode change; kept |
+| the PLE row reader's default queue depth 16 -> 64 blocking O_DIRECT threads (`STRATA_IO_THREADS`; 128 and 256 change nothing: the drive tops out near 85k IOPS on the 27k random 4 KB reads a 2,184-token prompt needs) | the rows of that prompt 466 -> 330 ms |
+| a short first prompt chunk (`STRATA_PREFILL_FIRST`, default 256 tokens) so the GPU starts while the remaining rows are still being read | 2,184 tokens: 711 -> 765 tok/s, 8,000: 857 -> 986; time to first token about 150 ms less. The chunk boundary moves rounding in the expert GEMMs, so a long greedy continuation can diverge late (token 45 of 64 on the test prompt) |
+
+Draft policy sweep (2,184-token prompt, 128 tokens): windows of 6 (`--spec 4`) at 41.6-43.5 tok/s; `--spec 2`
+33.6, `--spec 6` 35-37; `--spec-min-p` 0.3-0.7 within noise. Profiles (unitrace, `strata-sycl-dev:metrics` with
+Intel's metrics libraries and `dev.xe.observation_paranoid=0`): the expert dot kernels are ALU-bound (77% XVE
+active), the dense projections memory-latency bound (92-128 GB/s at 84-93% occupancy), a decode round is 80-85%
+kernel time and ~5 us of launch gap per node over 2,400-2,600 nodes.
+
+**XMX.** oneMKL's FP16 GEMMs already run on the XMX units (30-60 TFLOP/s in `xmx_gemm_bench`); the prompt path is
+bound by the dequant that feeds them, not by the products. Two joint_matrix kernels were written and are correct
+but lose to the existing paths on this card, so both stay opt-in: `xmx_gemm_iq` (a fused dequant + GEMM straight
+from the quantized rows, 4-5x slower than dequant + oneMKL) and `qsa_prompt_attn_xmx` (the port of the mma.sync
+prompt attention; `qsa_prompt_attn_parity` passes at 1e-6 of scale, 3x slower than the FP32 fallback per chunk;
+`STRATA_PROMPT_ATTN_XMX=1`). Where the prompt time goes now (2,184 / 8,000 tokens): dequant 28% / 27%, GEMMs
+22% / 20%, the per-layer host grouping 9.5% / 9%, attention 7% / 14%, embeddings + PLE rows 13% / 5%.
+
+A trap worth knowing: with a native pack `--prefill-until N` does not feed the rest of the prompt through the
+token loop (it is skipped for native packs), so the tokens after N are dropped and the model free-runs. Compare
+output tokens between paths, never only timings.
+
+**An 80,000-token prompt (2026-09-30).** Where the time goes changes completely at this scale:
+
+- **The VRAM plan.** `--expert-cache auto` fills the card down to `--vram-reserve-mib` *before* the KV state
+ (about 1 GB in INT8 at 81,920 cells) and the prompt chunk buffers (about 2 GB at `--prefill 8192`) exist. With
+ the 1,536 MiB default that put 30.95 of the B70's 32.6 GB in use, the driver started migrating buffers and the
+ run never finished. `--max-context 81920 --kv int8 --vram-reserve-mib 3072 --prefill 4096` peaks at 28.8 GB.
+ (80k ids also exceed Linux's 128 KB single-argument limit: `--tokens-file`.)
+- **Streamed experts.** The reserve costs cache slots (10,348 of 12,288 at 3 GB), and the prompt path streams the
+ missing experts from the GGUF for every chunk. Two port bugs sat on this path and are fixed: the stream plan
+ held `GgufExpertSource::blob()` pointers across ~1,900 reads of a 512-slot ring (so blobs were overwritten
+ before they were copied: the 75 s / 1,065 tok/s measured first was computed partly with the wrong experts), and
+ the stream-all walk hangs on this card in its first large chunk (the copy engine stops on a barrier). The stager
+ threads now read the blobs themselves, and the port uses the per-layer routed-only walk (`STRATA_PREFILL_RING=8`
+ upstream, the port's default; `STRATA_PREFILL_STREAM_ALL=1` restores the other for debugging).
+- **Correct result: 80,000 tokens in 101 s = 790 tok/s.** GPU time: expert down GEMM 26.0 s, attention 15.4 s,
+ dequant 12.7 s, QSA block selection 9.3 s (0.2 s at 8k: it scans every block of the context per query), gate/up
+ GEMM 8.9 s, gather 6.1 s, the per-layer grouping sync 5.2 s, GDN recurrence 4.8 s. The PLE rows are free at this
+ scale (97.6% row-cache hits).
+- **Decode after such a prompt, and the fix.** Without lending cache slots to the prompt path (`--no-prefill-borrow`,
+ which the port used from the start) the reserve evicts ~1,900 experts for good and decode after the prompt runs at
+ 2 tok/s. Borrowing lends ~940 slots to the prompt path and refills them in about a second afterwards: 80,000
+ tokens in 75 s = **1,062 tok/s, decode 35-40 tok/s** after it, at `--vram-reserve-mib 2048 --prefill 4096`.
+ It costs ~1 s on short prompts (2,184 tokens: 610 vs 792 tok/s), so the port borrows by default only above a
+ 32K context (`--prefill-borrow` / `--no-prefill-borrow` decide explicitly).
+
+**Long contexts (2026-10-01, engine 0.1.32-sycl, setup's flags: `--kv-resident 32768 --vram-reserve-mib 2048
+--prefill 4096`, the prompt path borrowing cache slots - the default above 32K).** Same text repeated to length,
+then 256 greedy tokens:
+
+| context | KV | prompt | decode after | peak VRAM | experts in the host mirror |
+|---|---|---|---|---|---|
+| 128K | int8 | 888 tok/s (144 s) | **66.6 tok/s** | 29.9 GB | none |
+| 128K | q4_0 | 848 tok/s (151 s) | 62.8 tok/s | 29.7 GB | none |
+| 128K | k8v4, KV in VRAM | **1,006 tok/s** (127 s) | **66.8 tok/s** | 30.7 GB | 103 (0.2 GB) |
+| 256K | int8 | 757 tok/s (338 s) | **55.9 tok/s** | 30.2 GB | none |
+| 256K | q4_0 | 737 tok/s (347 s) | 51.7 tok/s | 30.0 GB | none |
+| 256K | k8v4, KV in VRAM | 823 tok/s (311 s) | 52.2 tok/s | 30.9 GB | 865 (1.7 GB) |
+
+Borrowing keeps every expert in VRAM (the prompt buffers live in lent cache slots, refilled after the prompt).
+Without it (`--no-prefill-borrow`) the buffers keep their own VRAM and ~1,000-2,300 experts move to the pinned host
+mirror: the prompt is 2-5% faster, decode after it 10-23% slower (0.1.31 without borrowing: 128K int8 934 / 59.5,
+q4_0 887 / 53.4, k8v4 1,026 / 54.4; 256K int8 784 / 48.7, q4_0 763 / 43.2, k8v4 838 / 42.3 tok/s).
+
+KV streaming (`--kv-resident`) keeps the whole KV in pinned host memory and only the attended window in VRAM, so
+the KV pushes no experts out; setup.py turns it on from 64K up and keeps INT8 (the faster of the
+two at every size here). Without it the KV pushes experts out and decode after the prompt fell to 4-9 tok/s at
+128K-256K (2026-09-30). `--kv k8v4` does not support streaming yet: setup keeps its KV in VRAM, which pushes up to
+more experts out; those come from the pinned host mirror over PCIe (planned item 2), so decode holds at 42 tok/s at
+256K (it was 3.8 before the mirror). k8v4 reads prompts fastest (no streaming copies) and works through the FP32
+attention fallback (its dedicated prompt kernel is the XMX one below).
+
+**XMX prompt attention v2.** 64-cell chunks, vector-packed K^T and V, hi+lo Q in one accumulator per scale group,
+one accumulator update per chunk: 1.4-1.5x faster than v1 and correct in all modes (`qsa_prompt_attn_parity`,
+and `kv_hybrid_parity` passes completely with it), but still ~2x slower than the FP32 fallback (13.4 vs 5.5 ms per
+chunk, INT8, 32K context). This attention is gather-bound: each query position selects its own ~2,000 cells, so
+the K/V fetch dominates and only 12 of the 16 matrix rows are real heads. Opt-in: `STRATA_PROMPT_ATTN_XMX=1`
+(64-cell chunks, 120 KB of local memory) or `=32`.
+
+**XMX v2 in the full matrix, and why a grouped kernel is not next (2026-09-30).** With `STRATA_PROMPT_ATTN_XMX=1`
+(stage timing on, ~8-10% overhead) the long prompts run 23-33% slower than with the FP32 attention: 128K int8 666 vs
+960 tok/s, k8v4 596 vs 983; 256K k8v4 506 vs 752, int8 536 vs 718 (q4_0 does not use the XMX kernel). The obvious
+fix, gathering the union of neighbouring positions' cells once, depends on how much their selections overlap.
+Measured on the last chunk of an 80K prompt (first QSA layer, `STRATA_DUMP_SEL=`): the union of 8 consecutive
+positions is 3.3x one position's 2,051 cells (16: 5.1x), and only 12% of a selection is shared by all 8. Grouping
+would cut the K/V gather to ~40% but multiply the arithmetic by 3-5x: at best 5-8 s of an 80K prompt. Not built.
+
+**Serving the port (2026-09-30).** `serve/server.py --engine strata` runs the SYCL engine unchanged through
+`sycl/serve/strata-sycl.sh`, an `exe` that starts the binary inside the oneAPI runtime image with the serve pipes
+attached (paths in the config's `args` are the container's, the data root mounted at `/work`). A config:
+
+```json
+{"engine": "strata", "exe": "/sycl/serve/strata-sycl.sh",
+ "args": ["--pack", "/work/pack", "--native", "", "--ple-gguf", "",
+ "--expert-profile", "data/expert-profile-coder.bin", "--expert-cache", "auto", "--stream-experts",
+ "--prefill", "auto", "--spec", "4", "--spec-min-p", "0.5", "--mtp", "/work/mtp/rt",
+ "--max-context", "32768", "--kv", "int8", "--vram-reserve-mib", "1024"],
+ "sampling": {"temperature": 0.6, "top_p": 0.95, "top_k": 20, "repetition_penalty": 1.05}, ...}
+```
+
+Measured through the OpenAI API with those sampling defaults: 36 tok/s decode on a first turn, 33 on a follow-up
+(which reuses the conversation's cached prompt), against 24-26 for llama.cpp on the same card. After the decode work
+(2026-10-01 build): 48-72 tok/s on a ~190-token answer, prompt included (the first request after a start is the slower
+one); 55-59 tok/s over 5,000-18,000-token answers on the 2026-09-30 build. The reserve matters:
+with `--stream-experts` there is no host copy of the experts, so any expert left out of VRAM is read from the SSD
+and computed on the CPU whenever it is routed. At 1,536 MiB the cache came up 128 experts short and decode fell to
+5-10 tok/s; 1,024 MiB fits all 12,288 with 2 GB of VRAM still free.
+
+**Logprobs (2026-10-02).** `/v1/chat/completions` takes OpenAI's `"logprobs": true` and `"top_logprobs": 0..20`,
+streamed or not, through `sycl/serve/server_intel.py`; the reply carries `choices[0].logprobs.content[]` (`token`,
+`logprob`, `bytes`, `top_logprobs`), and with thinking on the thinking tokens under `reasoning_content`. The engine
+(the port only) gets `logprobs=K` on its GEN line and writes an `LP logprob id:logprob ...` line after each `T`
+line, from the verify window's head logits - the distribution the token was taken from, before temperature,
+penalties and sampling. Upstream's server ignores the lines, and an engine never asked writes none. Each token
+costs one 1 MB logits row copy when asked. Measured (IQ2_XS, "capital of France"): "Paris" at -0.0019 (p 0.998), the
+same answer and no measurable time without it. Not on `/v1/completions` or the Anthropic endpoint yet.
+
+**The Monitor tab on Intel.** `sycl/serve/server_intel.py` is `serve/server.py` with two additions made at run
+time:
+
+- **GPU readings.** When NVML has no card, it plugs `sycl/serve/xe_telemetry.py` into `serve/telemetry.py`'s
+ `gpu_reader`. That reads the Arc through sysfs (temperature, power and its cap, the PCIe link the card
+ trained at). Load and VRAM come from `/run/gpustat.json`: xe reports them per client in `/proc/*/fdinfo`,
+ which only root can read, so a small root sampler writes them (`sycl/tools/gpustat.py`; install it with its
+ `gpustat.service` as its header says).
+- **A Model menu.** A run config may name a `model_switcher` (an RPC taking `{"mode": m}`) for a host that swaps
+ models on one card. The web app's header then gets a Model menu (`sycl/serve/web/switcher.js`, injected into
+ the page).
+
+**Speed depends on the text.** Decode with speculative decoding tracks how often the draft layer guesses right.
+The served engine at 32K context: 45-51 tok/s on the test prompt (continuing a Fibonacci function, 77-85%
+of drafts accepted), 30-39 tok/s on a chat answer with prose (55-72%); sampling and the repetition penalty cost
+nothing measurable. The engine used to abort at exit in serve mode (a queue wait in a destructor after the
+runtime's teardown began); it now exits directly once its requests are done.
+
+**Planned (2026-09-30), in order.** Ranked by payoff on this card; the first three were taken first.
+
+1. Expert dot products on XMX in integer mode: a decode window (up to 6 tokens) fits one INT8 DPAS (1-8 rows), the
+ i-quant grids decode to small integers, the activations are already INT8. Today: scalar dp4a, ALU-bound (77%).
+ **Parked**: three `joint_matrix` versions (opt-in `STRATA_EXPERT_XMX=1`, do not enable) were 1.4x and 2-3x slower
+ than dp4a, and the third (B filled in place with `joint_matrix_apply`) hung the GPU. At 1-6 rows the grid decode
+ and the packed-B layout cost more than the DPAS saves.
+2. Experts missing from VRAM read from pinned host memory over PCIe instead of the SSD (the 256K decode collapse).
+ **Done**: at start-up every expert without a VRAM slot is read into a pinned host mirror (`STRATA_MIRROR_MIB`, by
+ default free RAM less 4 GiB), and the device-built verify plan points the expert kernels straight at it. Before,
+ that plan dropped non-resident experts, so output with misses was wrong. Forced test (`--expert-cache 8000`,
+ 1,879 experts / 3.6 GiB out of VRAM): decode 2.6 -> 40.9 tok/s (full cache 43.3), prompt 451 -> 640 tok/s
+ (776), output tokens identical to the full-cache run. About 3.6 GiB of VRAM can go to context for ~6% of decode.
+3. KV streaming (`--kv-resident`) from 64K up: only the attended window of the KV in VRAM. **Done**: 256K decodes at
+ 31 tok/s (from 4-5).
+4. QSA block selection on XMX: every query against every pooled block, a dense product that grows with the context.
+5. The hot decode kernels re-tuned for Xe2's native 16-wide sub-groups (twice the registers per thread).
+ **Tried, no gain**: SIMD16 builds of the multi-column mmvq and the wide Q6_K kernel (`STRATA_MMVQ_SG`: 16 all,
+ 1 IQ4_XS only, 2 short outputs only; default 32). Alone (`mmvq_sg_bench`) SIMD16 is up to 1.45x faster on IQ4_XS
+ and 1.3x on the 640-row shared-expert projections, 10-25% slower on the large K-quant ones; in the engine no
+ setting beats SIMD32 beyond run-to-run noise (SIMD32 itself lands at 39 or 45 tok/s). Output tokens identical.
+ The same bench shows the real headroom: the dense decode kernels stream 100-280 GB/s of a 608 GB/s card
+ (Q6_K, the most common type, ~146 GB/s at 2-4 columns).
+ **Found and fixed: misaligned loads.** A Q6_K block is 210 bytes, so every block's `ql`/`qh` runs start only
+ 2-byte aligned, and the B70 splits a misaligned 16-byte load into pieces. The same weights repacked at a
+ 224-byte stride ran 2.3-4.7x faster (`q6k_align_bench`). Without changing the layout, the wide Q6_K kernel now
+ does two aligned 16-byte loads and a shift (`load16_a2`; both stay inside the block) and takes every Q6_K
+ shape and window width (it used to be gated to n_out >= 4096, 1-4 columns): 2560->10240 at 1/2/4/6 columns
+ 160/145/141/101 -> 493/432/331/273 GB/s, the 248K-row head 150 -> 407 GB/s at 1 column. Output tokens
+ identical. **Coder decode 44.9 -> 54 tok/s** (2k prompt, greedy). `STRATA_MMVQ_A2=0` restores the old path.
+ Still misaligned: IQ4_XS (136 B, 8-aligned, ~100 GB/s), IQ4_NL (18 B), Q8_0 (34 B); Q4_K/Q5_K are aligned
+ but use 4-byte loads in the multi kernel (230-280 GB/s).
+
+**Decode round 2 (2026-09-30), from a unitrace of 256 decoded tokens.** Each step measured on its own, 19-token prompt,
+256 tokens, two runs each, output tokens identical throughout:
+
+| | decode |
+|---|---|
+| after the Q6_K alignment fix | 53.9 tok/s |
+| + `resident_plan` grouping in parallel (thread 0 alone took 73 us per layer, ~3.5 ms a round) | 57.1 |
+| + the GR down kernel reads the activations directly (the 60 KB SLM tile per group capped occupancy) | 58.7 |
+| + wide 16-byte-load kernels for Q4_K, Q5_K, IQ4_XS (codebook in registers) | 62.6 |
+| + wide kernels for Q8_0 (MTP dense, shexp down of layer 0) and IQ4_NL (shexp down); Q6_K's activation loads aligned | 65.6 |
+| + the GR down projection sliced by columns (each group stages one 128-column slice of xn, 4 lanes per row) + a fixed-order reduce | 70.6 |
+| + IQ4_NL expert dots (the down projection in 39 of 48 layers): codebook in registers, weights as aligned 4-byte loads, bitwise the same ints (2026-10-01) | 76.1 |
+| + GR norm per (token, stream), bitwise the same; aligned weight loads in the IQ3_XXS/IQ3_S/IQ2_S gate/up dots | **77.9** |
+
+The last step changes the output: identical for 146 tokens, then a near-tie after a comma goes the other way (the new
+kernels sum in a different order). Q8_0 kernels 4-6x (189 -> 31 us at 2560 x 10240, 2 columns), IQ4_NL 640 -> 2560
+28 -> 6.6 us. The aligned-load helper only loads its second chunk when the address is unaligned, so it never reads a
+16-byte chunk without a needed byte and cannot cross a page at the end of an allocation. `STRATA_MMVQ_WIDE_32=0`
+restores the old Q8_0/IQ4_NL kernels. The sliced GR down kernel (GR read 108.6 -> 76.5 us at 6 tokens, sums equal to
+~1e-7) flips that near-tie back: its output is the original one. `STRATA_GR_DOWN_SLICED=0` restores the direct kernel.
+
+Kernel level: IQ4_XS 107 -> 323 GB/s at 2 columns (61 -> 212 at 6), Q4_K/Q5_K 1.3-1.6x; the GR read 135 -> 110 us at
+6 tokens (bitwise equal). Switches to the old paths: `STRATA_PLAN_PARALLEL=0`, `STRATA_GR_DOWN_DIRECT=0`,
+`STRATA_MMVQ_WIDE_K=0`. (`iq_parity` reports 10 "missing fixture" failures with and without the change: the oracle
+fixtures are not in the port's tree.)
+
+**Two-speed runs, explained (2026-09-30).** Identical greedy runs decode at either ~45 or ~39 tok/s. A per-gather
+trace of the PLE reader (`STRATA_PLE_TRACE=1`) shows the slow runs pay one 226 ms PLE read stall in the first
+decode round, after the window graphs are captured; every other read and round matches the fast runs. Prompt time
+plus decode time is the same in both modes (4.95-5.17 s): the stall lands either in the prompt's PLE wait or in the
+first decode round, so it is a once-per-process cost, not lost throughput. Ruled out: NVMe APST, the I/O scheduler
+(`none`), CPU starvation (93% idle during decode), I/O thread count. Compare runs on time to first token + decode.
+6. INT8 prompt GEMMs: experts dequantized to INT8, oneMKL/oneDNN INT8 on XMX (half the dequant bytes, 2x rate).
+7. Fewer graph nodes per decode round (~2,500 at ~5 us): norm+rope, scores+top-k, gate+quantize fused.
+8. Wider speculation (two draft branches per verify window): the kernels are latency-bound, so it is nearly free.
+9. A model bigger than VRAM: the original Qwen3.8-Flash-Next IQ2_XS (35.5 GB of experts; the same path suits Q2_0
+ and Swift 1.5) on the B70 with 23 GB of RAM. ~24 GB of experts in VRAM, the rest (~10-12 GB) in the pinned host
+ mirror the device plan reads over PCIe (item 2). The port has the IQ2_XS/IQ3_XXS/Q2_0 expert kernels. Open: whether
+ a 10+ GB pinned mirror fits beside everything else in 23 GB, and decode with that share of experts on a Gen3 x8
+ link (3.6 GB mirrored measured 40.9 tok/s; expect less). Needs the 68 GB download.
+ **Done (2026-10-01)**: shard 1 downloaded (39.2 GB; shard 2 is byte-identical to the Coder's, so a hard link),
+ a native pack (`tools/iq_pack.py`, 6 s), the original model's expert profile, the same MTP draft layer. 18,329 of
+ 24,576 experts in VRAM (24.6 GiB), 6,247 in the pinned host mirror (8.4 GiB, 9 s to fill), host RAM never below
+ 12 GB free. **Decode 50.8 tok/s** on the 19-token prompt and **60.6** after 2,184 tokens, prompt 549 tok/s;
+ coherent, correct answers on both. Q2_0 and Swift 1.5 (similar size) should behave the same; IQ3_XXS/IQ3_S
+ (43-50 GB of experts) would need 19-26 GB mirrored, more than 23 GB of RAM allows.
+
+**Keeping up with upstream.** A merge of upstream `main` into `b70` leaves the copies in `sycl/` behind
+wherever upstream touched a file they mirror. They are refreshed by re-migration, not by hand (done for
+0.1.25-0.1.27, 2026-09-30):
+
+1. Merge upstream into `b70`. No shared file should conflict: the Intel code is all in `sycl/`. Then check that
+ `sycl/setup_intel.py --check` and `sycl/serve/server_intel.py --engine mock` still run against the new
+ setup.py and server.py.
+2. Migrate the *old* upstream tree (a `git archive` of the pre-merge commit) with `migrate.sh` into
+ `sycl-base`, and the merged tree into `sycl-new`; about 3 minutes each in the dev image.
+3. `sycl/tools/normalize.sh ` on both: dpct's file names, the unchanged files, the
+ message serials, then `fixups.py`. Two runs of dpct on the same source now differ only in the kernel
+ name hashes it generates.
+4. For every file present in both: `git merge-file sycl/F sycl-base/F sycl-new/F`. Files upstream did not
+ change are left alone. Copies dpct never produced (`verify.cpp`, `mtp.cpp`, `remote_experts.cpp`
+ include no CUDA header directly) take upstream's diff by hand; new parity tests are copied in and
+ listed in `sycl/CMakeLists.txt`.
+5. A fixup whose pattern upstream changed shows up as a compile error (the PTX gate became an `#elif`
+ under upstream's `__HIPCC__` guard); extend the fixup, re-run it, rebuild.
+
+**The 0.1.31 merge (2026-10-01).** Upstream 0.1.29 -> 0.1.31 (132 commits) by the steps above: 45 migrated files
+changed for real (27 more only in dpct's kernel-name hashes), 38 conflicts resolved, verify.cpp/mtp.cpp/
+conversation_state.cpp ported by hand. A symbol audit (the port's feature identifiers counted before and after) is
+part of the procedure now: it caught a dropped mirror hook and four doorbell waits that had lost their spin bound
+(upstream renamed the loops; `fixups.py` covers both spellings). What the merge needed beyond conflicts:
+
+- **The expert kernels.** Upstream sends the i-quant experts to new multi kernels (one warp per row, 8 rows a
+ group); the port's grid is sized for its own kernels (8 lanes a row, 32 rows a group), so only a quarter of each
+ expert's rows were written and the output was end-of-text tokens. The port's kernels stay the default (they also
+ serve upstream's new Q4_K/Q5_K/Q5_1/Q8_0 experts); `STRATA_EXPERT_SPLIT=1` runs upstream's, with their grid.
+- **Prompt-slot borrowing hung** in the first chunk of any prompt with it on (40K, 128K; GPU busy) - fixed after
+ the 0.1.32 merge, see "Prompt-slot borrowing: the hang" below.
+- **Upstream's new tests found three dpct mistranslations from the first migration.** dpct writes `__fadd_rn(a, b)`
+ as `a + b` without parentheses, so `__fadd_rn(sum, c ? x : y)` became `sum + c ? x : y` (replaced, not added): in
+ the native QSA indexer's per-token pooled key (the decode path: blocks completed while generating got wrong
+ pooled keys; prompt blocks come from the batched kernel and were right), in the native QSA scores (an opt-in
+ path) and in the s2 activation rounding (every value 0; the Q2_0 s2 pack, unused here). Fixed, and `fixups.py`
+ now parenthesises. `qsa_parity`'s batch-vs-sequential test passes; decode output after long prompts changed.
+- **Upstream's new kernels against the port's on the B70** (decode tok/s, Coder 19 / 2,184-token prompts, IQ2_XS
+ 19): port defaults 78.1 / 75.7 / 58.6; `STRATA_EXPERT_SPLIT=1` 65.6-69.5 / 67.2 / 49.1 (-11 to -16%);
+ `STRATA_GR_V3=1` 73.2 / 72.4 (-4 to -6%). Both stay opt-in.
+- **Lanes per row of the port's expert kernels** (`STRATA_GU_LANES` / `STRATA_DOWN_LANES`, 4/8/16/32 at run time):
+ gate/up 8 or 16 x down 4 or 8 all decode in 37.7-38.6 ms per verify round (2 runs each, Coder, both prompts); tok/s
+ differences between them are draft acceptance (each split rounds differently). The default (8 / 8) stays.
+- **Speed after the merge:** Coder 78.2 / 75.7 tok/s (as before), IQ2_XS 58.6 / 64.2 (from 50.8 / 60.6).
+- **Still failing:** (`iq_multi_parity` on IQ2_XS: fixed later, see "Two model-specific bugs"), `s2_expert_grouped_parity` (the s2 path), `kv_hybrid_parity`'s last step
+ (needs the unported tensor-core prompt kernel). `ple_parity` and `native_expert_parity` need model files.
+
+**The 0.1.32 merge (2026-10-01).** Upstream 0.1.31 -> 0.1.32 (89 commits) by the same steps; the hash-only
+differences are now canonicalized before `git merge-file` (the port's kernel-name hashes are kept), which left 10
+files with real conflicts. What it needed:
+
+- **Upstream's async commit** (`set_commit_async` / `wait_commit`) maps onto the port's own deferred commit
+ (`commit(n, err, false)` + `commit_finish`); `wait_commit` is `commit_finish`.
+- **Upstream's new hyper-connection read variants** (split / staged, chosen per card by a bit-for-bit self-test at
+ start) are not used by the port, which keeps its sliced down / split norm read; the self-test segfaulted on the
+ B70, so on SYCL it runs only with `STRATA_HC_CHECK=1` (open). The port's split-norm kernel was renamed
+ (`gr_norm_split_port_kernel`): upstream now has one of the same name.
+- Two kernel names collided after hash canonicalization (renamed), and fused_gr's per-block shared-memory query is
+ a fixup now.
+- **Result:** every output identical to 0.1.31 (Coder 19 / 2,184-token prompts and IQ2_XS, 256 greedy tokens),
+ same speeds (Coder 77.7-78.1 / 75.7 tok/s, IQ2_XS 58.5), 40K prompt 1,201 tok/s then 65.1 tok/s decode.
+ `kv_hybrid_parity` and `qsa_prompt_attn_parity` pass (the tests now turn on the XMX prompt attention they
+ check); `iq_multi_parity` (IQ2_XS) and `s2_expert_grouped_parity` as before.
+
+**The 0.1.33 merge (2026-10-01).** Upstream 0.1.32 -> 0.1.33 (17 commits): no shared file conflicted (the Intel
+code is all in `sycl/`); six engine files changed upstream and were merged by the same re-migration (only those six:
+the other files dpct produced differently were left alone). Two conflicts: `--resident-cpu-experts` (upstream's new
+`resident_cpu_explicit` beside the port's `--stream-experts`) and the prompt attention's compute-capability check
+(the port keeps its XMX dispatch). `sycl/setup_intel.py` and `sycl/serve/server_intel.py` ran unchanged against the
+new setup.py and server.py. Every output identical (Coder, IQ2_XS, the 40K prompt with borrowing); `gr_parity` and
+`qsa_prompt_attn_parity` pass.
+
+**Prompt-slot borrowing: the hang (fixed 2026-10-01).** From 0.1.31 on, a prompt that borrowed cache slots stopped
+in its first full chunk with the GPU at 100% and the host waiting on the compute queue. It was never a borrowing
+bug: borrowing is just the only way the Coder streams experts during a prompt (every expert is resident otherwise),
+and the streamed path's stager deadlocked. Its threads read experts into a ring of 16 page-locked buffers and, to
+reuse one, waited on the copy queue's event for the DMA that last read it (the CUDA form, `cudaEventSynchronize`).
+Under the Level Zero v2 adapter that event, once a host thread had waited on it, no longer released the other
+queue's barrier that also listed it, and the GPU waited forever - only once a layer streamed more than 16 experts
+(the ring wrapped). The v1 adapter (`SYCL_UR_USE_LEVEL_ZERO_V2=0`) and a 256-buffer ring both ran; so did a sync
+after every phase (`STRATA_PREFILL_SYNC=1`, kept as a debug switch). The fix: the copy queue writes a sequence
+number into page-locked host memory after each DMA and the stager polls it - no host thread waits on a queue's
+event. Output is bit-identical to the run without borrowing; borrowing is the default above 32K again.
+
+**Two model-specific bugs found with Swift 1.5 (fixed 2026-10-01).** UkisAI's Swift 1.5 IQ2_XS (setup's `swift`
+family, the same GSQ-RCO layout) decoded token 0 forever: NaN logits from layer 13 on.
+
+- **`--stream-experts` read the wrong shard.** Since upstream 0.1.31, `ExpertLayout::gguf_file` is per layer AND role
+ (`3 * layer + role`; a shard boundary can fall inside a layer). The port's `GgufExpertSource` still indexed it by
+ layer, so a layer in shard 2 was read from shard 1 at shard 2's offsets: garbage IQ1_M scales, infinities after the
+ fp16 dequantization. The original model keeps every expert in shard 1 and never noticed; Swift's layers 13-47 are
+ in shard 2. `STRATA_DBG_NAN=1` now also reports the experts' fp16 GEMM inputs (activations, dequantized gate/up).
+- **IQ2_XS products were garbage** (the long-failing `iq_multi_parity` IQ2_XS case): ggml's `vec_dot_iq2_xs` reads
+ its four 16-bit codes through a `uint16_t*` to an `int2`, type punning the SYCL device compiler does not honour;
+ the dequantizer, which reads the codes directly, was exact. The codes are extracted by shifts now (the dot and the
+ expert kernels' `Split<17>`); the test checks against a CPU decode from ggml's tables too and passes. No model here
+ uses IQ2_XS tensors, Swift included - this was not Swift's bug, only found on the way.
+
+Swift 1.5 IQ2_XS after both: correct answers, 51-118 words of thinking on short questions, 48 tok/s raw decode.
+
+**Load time (2026-10-02).** Starting a model is mostly the expert cache's fill from the GGUF (`--stream-experts`):
+the Coder's 23.4 GiB of experts took 64-76 s, the IQ2_XS's 24.9 GiB 91 s, because the fill read each expert's
+three slices, copied the blob and waited for the copy before the next read. It is a pipeline now: the slots are
+admitted in profile order first (the same placement), the reads go in file order by up to 8 threads into
+page-locked batches of 64, and a batch's copies run while the next one is read. Cold page cache: 76.2 s -> 18.8 s
+(0.33 -> 1.34 GB/s); a whole Coder start from the engine's launch to its first token 82 s -> 26 s, the IQ2_XS
+120 s -> 41 s (its 8.2 GB host mirror is ~9 s of the rest). Output identical. `STRATA_FILL_SERIAL=1` is the old fill.
+
+**The stream-all prompt walk is back (2026-10-03).** It copies every expert the VRAM cache does not hold, layer by
+layer ahead of the compute, instead of only the routed ones. It hung on the B70 until the stager and PLE fixes of
+2026-10-02 and runs since. It now runs by default when the VRAM holds more than 90% of the (layer, expert) pairs;
+past that it would copy several times the routed experts (the IQ2_XS keeps a quarter of them in the RAM mirror: a
+2,184-token prompt fell from ~560 to 254 tok/s with it). `STRATA_PREFILL_STREAM_ALL=1` / `=0` force it on or off.
+Coder, outputs identical either way:
+
+| prompt | routed-only | stream-all |
+|---|---|---|
+| 40K int8 | 1,106 tok/s | 1,160 |
+| 128K int8, KV streaming | 897 | 920 |
+| 128K k8v4 | 1,006 | 1,041 |
+| 256K int8, KV streaming | 760 | 780 |
+
+Measured alongside: the 2,184-token prompt reads 784-799 tok/s on both the 0.1.35+PR and the 0.1.38 build (three
+runs each), so the 825 seen once on 0.1.35 was session variance, not a regression. The prompt path at 40K (Coder,
+GPU timeline): expert dequant 15.7%, expert GEMMs 23.4%, host grouping 6.5%, gather 5.7%, combine 2.4%, prompt
+attention 19.2%, QSA select 6.8%, DeltaNet recurrence 5.8%, hyper-connection reads 4.6%. Upstream's sm_90 cluster
+kernels (greedy sampler, QSA top-k) would save next to nothing here: the sampler reads ~6 MB a round against a 13 ms
+token. Q4_0 tensor-core prompt attention (#452) is not worth porting: the port's XMX prompt attention is 2-3x slower
+than the plain kernel it uses for every KV format.
+
+**The 0.1.38 merge (2026-10-03).** Upstream 0.1.35 -> 0.1.38 (83 commits) merged the seven PRs the port had taken
+early, so their header forks in `sycl/include` are gone; the merge base for the 3-way merge was the port's own "main +
+PRs" migration. New in the port with it: the DeltaNet output norm without its dead FP32 store, two prompt-path
+fixes (the fused layout's buffers, the streamed walk's resident lookup), `STRATA_GR_DOWN_MAX4=1` (opt-in; neutral on
+the B70: 2,184-token prompt 793 vs 788 tok/s, decode 76.2 vs 76.3). Stubbed in the port: `--peer-device` (a second
+GPU as an expert-cache tier: upstream's code is CUDA calls; `open` refuses with a message) and the fused int8 prompt
+kernels (#136, part of the MMQ library the port does not build). Off on SYCL: the sm_90 thread-block-cluster greedy
+sampler and QSA top-k (no SYCL counterpart; the callers take the plain kernels). `STRATA_GDN_KEYHEAD=1` stays opt-in
+(still 8% slower here: 727 vs 788 tok/s). `qsa_prompt_attn_parity` now also tests upstream's Q4_0 tensor-core mode
+(#452), which the port does not have: those cases report "refused" (the int8 and fp16 XMX cases pass); a `--kv
+q4_0` prompt takes the older kernel, as before. Outputs identical (Coder, IQ2_XS, 40K); 128K int8 KV streaming
+892 / 66.8 tok/s.
+
+**0.1.35 and seven open upstream PRs (2026-10-02).** Upstream main 0.1.33 -> 0.1.35 merged as before, then seven open
+PRs ported into `sycl/` ahead of upstream (one dpct run of main + all of them, 3-way merged into only the files they
+touch; `sycl/tools/merge_upstream.py`). Their header changes live in `sycl/include` until upstream merges them.
+
+| PR | what | on the B70 |
+|---|---|---|
+| #385 (sergqwer) | stager: a buffer's first job of a generation waits for the previous DMA from it | race fix; same output |
+| #463 (constantindjonkam) | decode waits for an adaptive swap before reading the residency table | determinism fix |
+| #453 (architectds) | the drafter's batched K/V on the KV-streaming ring | 128K int8: 888 -> 895 tok/s prompt, 66.6 -> 67.0 decode |
+| #374 (sergqwer) | the first chunk's PLE rows read beside layer 0 | part of the 2,184-token prompt's 784 -> 825 tok/s |
+| #363 (BlueKingMuch) | the PCIe expert call: group stride, fused SwiGLU + q8_1 | IQ2_XS decode +1.7%; re-done on the port's lane kernels |
+| #407 (sergqwer) | `--adapt-tuned` (opt-in) | neutral here; stays opt-in |
+| #413 (BlueKingMuch) | DeltaNet recurrence per key head | bit-identical but 8% slower prompt here: off (`STRATA_GDN_KEYHEAD=1`) |
+
+#374 needed two port fixes: its next-chunk read assumed every chunk is the full chunk length (the port's first chunk
+is 256 tokens: the second chunk's rows were read from the wrong place), and its host wait on the PLE upload's event,
+now inside the layer loop, deadlocked the prompt past ~4K tokens under the Level Zero v2 adapter (the stager's bug
+again): the upload is marked by a polled sequence number now. #413's gate is an NVIDIA SM-count rule and its parity
+test an SM-holding NVIDIA bench (not built). Outputs identical to 0.1.33 (Coder 19 / 2,184 tokens, IQ2_XS, 40K).
+
+**Not ported yet (2026-10-01).**
+
+- Three kernels carry inline PTX (`mma.sync` tensor-core matrix ops, `ldmatrix`, `cp.async`):
+ `qsa_prompt_attn`, `qsa_select`'s block scores, `native_qsa_score`. The SYCL build takes the "older card"
+ fallback the CUDA build uses below sm_80. `qsa_prompt_attn` also has an XMX version (`joint_matrix`, opt-in
+ `STRATA_PROMPT_ATTN_XMX=1`): correct, but slower than the fallback (see "XMX prompt attention v2").
+- The ggml MMQ prefill path (`moe_mmq.cu`) needs llama.cpp's ggml-cuda sources; not built.
+- AOT device code is what runs: `AOT=bmg-g31 BUILD_DIR=.../build-sycl-aot` (the JIT build costs ~47 s of
+ compiling on the first window).
+
+## Not done
+
+- Images, on both Intel engines. Strata's vision path encodes with `strata-vision` into embeddings the CUDA
+ engine reads; neither the SYCL port nor the llama.cpp config wires it yet.
+- Speculative decoding on the llama.cpp path (the GGUF carries no draft layer llama.cpp can use). The SYCL
+ port has it (MTP draft layer, `--mtp`).
+
+## Measured, 2026-10-01, Arc Pro B70, Coder IQ1_M, 32K context, INT8 KV: the SYCL port (engine 0.1.31-sycl)
+
+| | |
+|---|---|
+| decode | 78.2 tok/s on a 19-token prompt, 75.7 after a 2,184-token prompt (256 greedy tokens, MTP + suffix drafts) |
+| prompt | 780 tok/s on 2,184 tokens; 934 tok/s at 128K, 784 at 256K |
+| through the API | 48-72 tok/s on a ~190-token chat answer, prompt included (the first request after a start is the slower one) |
+| VRAM | all 12,288 experts resident, ~1.9 GB free with everything loaded (`--vram-reserve-mib 1024`) |
+| RAM | no host copy of the experts (`--stream-experts`) |
+
+## Measured, 2026-09-29, Arc Pro B70, Coder IQ1_M, 32K context, q8_0 KV: llama.cpp
+
+| | |
+|---|---|
+| load | ~100 s |
+| VRAM | 28.4 of 32 GB |
+| decode | 23-25 tok/s; GPU 92% busy at 165 W, CPU idle |
+| prefill | 149 tok/s on a 2,701-token prompt; 424 tok/s on a 104,798-token prompt at 131k context |
+| quality | correct code on every test; matches the NVIDIA path token for token in spirit, not measured |
diff --git a/docs/INTEL_ARC.md b/docs/INTEL_ARC.md
new file mode 100644
index 0000000000..7b6acfeb63
--- /dev/null
+++ b/docs/INTEL_ARC.md
@@ -0,0 +1,126 @@
+# Intel Arc (experimental)
+
+Strata 0.1.39 includes an **experimental Intel Arc engine**: Strata's own engine ported to SYCL (Intel oneAPI).
+maxfridbe wrote it in [#423](https://github.com/Niko1221/Strata/pull/423), with fixes from the people testing it.
+The code is in `sycl/`, and the port's own notes, measurements and maintenance procedure are in
+[docs/INTEL.md](INTEL.md). This page covers what you need, how to build it, and what has been tested.
+
+**Experimental means:** the Strata maintainers have no Intel GPU. We compile the port and run its kernel tests, but
+we have not run it on an Arc. Every result on Arc hardware below comes from the community. The NVIDIA and AMD
+engines are unchanged: the Intel build is a separate CMake target, off by default.
+
+There is **no ready-made Intel engine** in the release zips. You build it from source on Linux.
+
+## What has been run, and by whom
+
+| | Hardware | Result | Reported in |
+|---|---|---|---|
+| Port author | Arc Pro B70 32 GB, Ubuntu 24.04 | Coder IQ1_M: 70-78 tok/s decode, ~790 tok/s prompt; IQ2_XS: 51-64 tok/s; 256K context measured | #423, [INTEL.md](INTEL.md) |
+| Community | 2x Arc Pro B70, `--layer-split` | Flash-Next IQ3_XXS 66 tok/s decode, 394 tok/s prompt (with the `stage_room` fix that is now in 0.1.39) | #423 |
+| Community | Arc Pro B50 16 GB | Coder IQ1_M ~23 tok/s, IQ2_XS ~25-27 tok/s, up to 128K | #423 |
+| Community | Arc B580 12 GB, WSL2 | IQ2_XS ~21 tok/s, Coder ~15 tok/s; **device loss also seen** | #423 |
+| Strata maintainers | no Arc | compile check and kernel tests on a CPU device only (below) | this release |
+
+The 0.1.39 port re-migrates 0.1.38's port onto the 0.1.39 engine sources (the #606 NaN fix, the #649 verify
+trace, the new prompt paths). It compiles and its kernel tests run, but **nobody has run the 0.1.39 port on an Arc
+yet**. The numbers in the table were measured on earlier versions.
+
+## What was tested here (0.1.39)
+
+- **Compiles:** Ubuntu 24.04 (WSL2), Intel oneAPI DPC++ 2026.1.1 + oneMKL 2026.1. The whole `sycl/` project
+ builds with 0 errors: the `strata` engine plus all 157 targets (the kernel parity tests and benches). The build is
+ SPIR-V (JIT). The AOT build (`STRATA_SYCL_AOT`) was not built here, because it needs `ocloc`.
+- **Kernel parity tests on a CPU** (`ONEAPI_DEVICE_SELECTOR=opencl:cpu`, Intel's OpenCL CPU runtime, AMD Ryzen 5 7600):
+ 14 of 25 pass. That is the same set that PR #423's own 0.1.38 port passes on that device: the failures are tight
+ float tolerances on the CPU's math (rel 3e-6 against a 1e-6 limit), model fixtures that are not present, and two
+ tests that time out on a CPU. This is a check that the kernels compile and compute, not a test of an Arc.
+- **Setup:** `--backend sycl` warns, then hands over to `sycl/setup_intel.py` on Linux (unit tests:
+ `tools/test_setup_sycl.py`).
+- **Unchanged:** the CUDA engine's greedy output, checked byte-identical against the gated 0.1.39 build (Q2_0 and Coder).
+ The HIP build is not affected (the option is off by default).
+
+**Not tested by anyone yet:** Windows (no native build path; see below), Arc on WSL2 for the 0.1.39 port, the Alchemist
+A-series, integrated Arc GPUs (the B390 / Panther Lake in #515; the shared-memory planning does not exist yet), and
+images (not wired on Intel).
+
+## What you need
+
+- **Linux**, e.g. Ubuntu 24.04, with Intel's GPU driver (the `xe` or `i915` kernel driver plus the compute runtime /
+ Level Zero; on Ubuntu, `intel-opencl-icd libze1 libze-intel-gpu1`, or Intel's
+ [client GPU guide](https://dgpu-docs.intel.com/driver/client/overview.html)).
+- **Intel oneAPI**: the DPC++ compiler (`icpx`, 2025.3 or newer; 2026.1 is what was built here) and **oneMKL**. About 5 GB.
+- `cmake` 3.24+, `ninja`, `git` (the build fetches ggml unless you point `STRATA_GGML_DIR` at a llama.cpp checkout),
+ Python 3.
+- For `setup --backend sycl` today: **Docker**. `sycl/setup_intel.py` runs the engine in the `strata-sycl-dev`
+ image built from `sycl/tools/Dockerfile`. Note that the Dockerfile starts from a community llama.cpp SYCL image
+ (`ghcr.io/snailium/...`), not an Intel or Strata image.
+- VRAM: the port keeps the experts on the card (`--stream-experts`). A 32 GB card holds the Coder IQ1_M or IQ2_XS.
+ Smaller cards mirror part of the experts in RAM and are slower.
+
+## Build (Linux)
+
+Install oneAPI from Intel's apt repository (this is what was used here):
+
+```sh
+wget -qO- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB \
+ | sudo gpg --dearmor -o /usr/share/keyrings/oneapi-archive-keyring.gpg
+echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" \
+ | sudo tee /etc/apt/sources.list.d/oneAPI.list
+sudo apt update
+sudo apt install intel-oneapi-compiler-dpcpp-cpp intel-oneapi-mkl-devel ninja-build cmake git
+```
+
+Then build the engine. Either command works: the first goes through the top-level CMake option, the second
+configures the `sycl/` project directly.
+
+```sh
+source /opt/intel/oneapi/setvars.sh
+cmake -S . -B build-sycl -G Ninja -DCMAKE_C_COMPILER=icx -DCMAKE_CXX_COMPILER=icpx -DSTRATA_ENABLE_SYCL=ON
+# or: cmake -S sycl -B build-sycl -G Ninja -DCMAKE_C_COMPILER=icx -DCMAKE_CXX_COMPILER=icpx
+cmake --build build-sycl --target strata
+```
+
+Options:
+
+- `-DSTRATA_SYCL_AOT=bmg-g31` (Arc Pro B70) or `bmg-g21` (B580 / B570 / Pro B60) compiles the GPU code ahead of
+ time. This needs `ocloc` (Intel's `intel-ocloc` package). Without it, the first start JIT-compiles every kernel,
+ which takes about 47 s.
+- `-DSTRATA_SYCL_PARITY=OFF` skips the kernel tests (on by default). Run them with
+ `ctest --test-dir build-sycl` on the card.
+
+`setup_intel.py` looks for `build-sycl-aot/strata` or `build-sycl/strata` in the checkout. With the
+top-level option the engine is at `build-sycl/sycl/strata`, so either use `-S sycl` or set `STRATA_SYCL_BIN`.
+
+## Setup and running
+
+```sh
+./setup.sh --backend sycl [setup's usual options, e.g. --model IQ2_XS --context 32768]
+```
+
+This prints the experimental warning and continues with `sycl/setup_intel.py`. That script finds the Arc in sysfs,
+uses the SYCL engine you built, and writes the config and `run-.sh`. It still downloads and packs the model
+the usual way. To run the engine by hand (no Docker), see "How to run it by hand" in [INTEL.md](INTEL.md).
+
+Things that matter on an Arc (details in INTEL.md):
+
+- **Do not ask for more VRAM than the card has.** On the `xe` driver, an allocation past VRAM can push buffers into
+ RAM until the machine runs out of memory and stalls. Leave about 1.5 GB free.
+- `SYCL_CACHE_PERSISTENT=0`: the persistent JIT cache crashed on Xe2 during the first compile.
+- Two cards: `ONEAPI_DEVICE_SELECTOR=level_zero:*` (the image pins `level_zero:0`; `strata-sycl.sh` now passes the
+ variable through) and `--layer-split`.
+- `STRATA_VERIFY_NO_HOST=1` (set by `strata-sycl.sh`) is only valid when every expert is in VRAM. On smaller cards
+ that path is the one that has hung, and #667 found the likely reason: the GPU does not see the CPU's flag
+ stores without a system fence.
+
+## Windows
+
+There is no Windows path yet. `setup --backend sycl` on Windows stops and points here. oneAPI exists for Windows,
+but `sycl/CMakeLists.txt` uses GCC-style flags (`-mavx512f`, `-fp-model=precise`, `-qmkl`) and the runner is a
+bash/Docker script, so a native Windows build would need work. Nobody has tried it. WSL2 with an Arc has been
+used by one tester (the B580 row above), but setup cannot detect the card there, because it reads `/sys/class/drm`,
+which WSL2 does not have.
+
+## Reporting a problem
+
+Open an issue with: the card, the driver version, `sycl-ls` output, the oneAPI version, the model and flags, and the
+engine's stderr. Results from real cards are what move this from experimental to supported.
diff --git a/docs/MCP_SERVER.md b/docs/MCP_SERVER.md
index b72c659c24..4fc053697f 100644
--- a/docs/MCP_SERVER.md
+++ b/docs/MCP_SERVER.md
@@ -111,7 +111,7 @@ The command is `python /tools/strata_mcp.py`. The transport is st
| --- | --- | --- |
| `strata_status` | Whether a model is running: the model, engine version, context, busy or idle, VRAM and RAM in use. What is installed: models, start scripts, engine version, data folder, downloads. An install in progress. This PC's hardware: OS, CPU, RAM, GPUs, page file, free disk. The model and context setup recommends for this PC. | `port`, `hardware` (true/false) |
| `strata_models` | Every family and size setup offers, with download size, RAM needed, whether it fits this PC, and whether it is installed. Also the context lengths and the recommendation. | - |
-| `strata_install` | Runs setup without questions, in the background (see below). Without `confirm: true` it only returns the plan. A second call shows the progress. `cancel: true` stops the install. | `family` (qwen, swift, coder, unsloth), `model` (Q2_0, IQ2_XS, IQ3_XXS, IQ3_S, IQ1_M, UD-Q4_K_XL), `context` (8192 ... 524288), `vision` (no, yes, gpu, cpu), `kv` (int8, q4_0, k8v4), `gpu` (a number) or `gpus` ("0,2", "all"), `backend` (auto, cuda, hip), `low_ram`, `port`, `data_dir`, `confirm`, `cancel` |
+| `strata_install` | Runs setup without questions, in the background (see below). Without `confirm: true` it only returns the plan. A second call shows the progress. `cancel: true` stops the install. | `family` (qwen, swift, coder, unsloth), `model` (Q2_0, IQ2_XS, IQ3_XXS, IQ3_S, IQ1_M, UD-IQ4_XS, UD-Q4_K_XL), `context` (8192 ... 524288), `vision` (no, yes, gpu, cpu), `kv` (int8, q4_0, k8v4), `gpu` (a number) or `gpus` ("0,2", "all"), `backend` (auto, cuda, hip), `low_ram`, `port`, `data_dir`, `confirm`, `cancel` |
| `strata_start` | Starts an installed model in the background and waits until it answers. If it is not ready after `wait_seconds` (default 50), it returns "loading". | `model` (an id from `strata_status`, e.g. `q2_0`, `coder-iq1_m`; default: the most recently used), `port`, `gpu`, `wait_seconds` (0-600) |
| `strata_stop` | Unloads the model through the server's own API, then ends the server it started. | `port`, `force` |
| `strata_logs` | The last lines of the server log, the engine log or the setup log. | `source` (server, engine, setup), `model`, `lines` (1-500) |
diff --git a/docs/MODELS.md b/docs/MODELS.md
index 95a954d146..1ffc87f5aa 100644
--- a/docs/MODELS.md
+++ b/docs/MODELS.md
@@ -14,7 +14,7 @@ The installer recommends one for your PC; this page explains the choice. Back to
| **32 GB** | **Coder** | it fits 32 GB; best at code, weaker at everything else ([why](#coder)). With a 24 GB card, Q2_0 and IQ2_XS run too ([low-RAM mode](#a-big-graphics-card-and-little-ram)) and are the better pick for general use |
| **48 GB** | **IQ2_XS** (or Q2_0, the fastest) | the larger sizes do not fit |
| **64 GB** | **IQ2_XS** (recommended), or IQ3_XXS / IQ3_S | every size fits (IQ3_S with little else open) |
-| **96 GB or more** | **IQ3_S**, or [Unsloth's 4-bit](#unsloth-ud-q4_k_xl-experimental) (experimental) | room for the largest sizes |
+| **96 GB or more** | **IQ3_S**, or [Unsloth's UD-IQ4_XS](#unsloth-ud-iq4_xs) (~4-bit) | room for the largest sizes |
Not sure? Take **IQ2_XS**. The **Coder** is the one that fits a 32 GB PC, but it keeps only 256 of the 512 experts,
chosen on code data, so it is weaker outside code and in languages other than English. For general use, or whenever
@@ -71,8 +71,9 @@ fetches the MTP draft layer (~6 GB, +1 GB with images).
Shard 1 is the part of the model that gets loaded when it starts: its experts go into your **RAM**, the rest onto
your graphics card (the second shard, a 29 GB lookup table, stays on the SSD). So it fits when your **RAM is at least
-shard 1 + about 10 GB** for Windows and your other programs. With 64 GB of RAM every size fits (IQ3_S with little
-else open); with 48 GB, Q2_0 and IQ2_XS. A bigger graphics card makes it faster, but it doesn't lower the RAM needed
+the experts + about 10 GB** for Windows and your other programs - the experts are most of shard 1: 34 GB for Q2_0,
+35.5 for IQ2_XS, 43 for IQ3_XXS, 50 for IQ3_S, 23 for the Coder (the dense weights in shard 1 go to the graphics
+card). With 64 GB of RAM every size fits (IQ3_S with little else open); with 48 GB, Q2_0 and IQ2_XS. A bigger graphics card makes it faster, but it doesn't lower the RAM needed
- except in the low-RAM mode below.
### A big graphics card and little RAM
@@ -116,9 +117,22 @@ More: [details](DETAILS.md#or-swift-15-a-fine-tune-that-thinks-shorter).
START-HERE.bat --setup --family swift --model IQ2_XS
```
+### Unsloth UD-IQ4_XS
+
+**Unsloth's UD-IQ4_XS** (~4-bit) is the fourth version in setup's menu (`--family unsloth`, its first size), a
+regular choice from 0.1.39: a 94 GB download with 59.5 GB of experts (IQ3_S and IQ4_NL), between IQ3_S and UD-Q4_K_XL
+in quality. Strata keeps your RAM minus 24 GB of the experts in RAM and reads the rest from the SSD while it answers:
+on a 64 GB PC part of them come from the SSD (slower; an NVMe SSD helps), from ~80 GB of RAM all of them stay in RAM.
+It needs 48 GB of RAM or more and engine 0.1.38 or newer; images are an option, as with the other models.
+Details: [UD-IQ4_XS](UNSLOTH_Q4.md#ud-iq4_xs-setup-from-0139-621).
+
+```
+START-HERE.bat --setup --family unsloth --model UD-IQ4_XS
+```
+
### Unsloth UD-Q4_K_XL (experimental)
-**Unsloth's 4-bit UD-Q4_K_XL** (experimental) is the fourth version in setup's menu (`--family unsloth`): the closest
+**Unsloth's 4-bit UD-Q4_K_XL** (experimental) is the second size of the same family (`--family unsloth`): the closest
to the full model, but a 111 GB download whose 77 GB of experts do not fit in RAM. Strata keeps your RAM minus 24 GB
of them in RAM and reads the rest from the SSD while it answers: 7-8.5 tokens/s on a 64 GB PC with a 12 GB GPU, several
times slower than the sizes above, and long prompts are slow. It needs 48 GB of RAM or more, an NVMe SSD and one
diff --git a/docs/MULTI_GPU.md b/docs/MULTI_GPU.md
index 6e0b0d9687..1270125b3a 100644
--- a/docs/MULTI_GPU.md
+++ b/docs/MULTI_GPU.md
@@ -56,6 +56,11 @@ Or edit an existing config (`strata-*.json`), then restart:
"layer_split": "auto"
```
+`"layer_split"` is `"auto"` (placed by each card's free VRAM) or the **first layer of each later card**: one rising
+number per card after the first, not a count of layers per card. With 4 cards and a 48-layer model, `"24,36,42"`
+(or `[24, 36, 42]`) puts layers 0-23 on the first card, 24-35 on the second, 36-41 on the third and 42-47 on the last.
+The server checks it before the start and says what is wrong (0.1.39, #644).
+
**Skip the split when the first card holds everything** (opt-in, 0.1.31): `"split_skip_if_fits": true` in the config
(engine flag `--split-skip-if-fits`, with `--layer-split auto`) runs on the first card alone when it holds every
profiled expert plus the context's KV, the draft layer and the reserve, and says so in the log; otherwise the split
@@ -71,10 +76,32 @@ VRAM keep its own prompt buffers - the same output as 0.1.31, measured on an R97
differs from the default's (stable and coherent); `STRATA_SPLIT_OWN=auto` does that only where the buffers are at
most 12% of each card's VRAM.
+**The idle card can help one-chunk prompts (opt-in, `STRATA_PREFILL_HELP=1`).** A prompt that fits one chunk runs the stages one after the other, so while
+one card reads its layers the other idles. With it on, each stage hands a share of its streamed experts to the idle card: it
+streams them over its own PCIe link into its own (lent) prompt buffers, computes their rows on the MMQ path and sends
+them back - `--peer-device`'s peer streaming, without P2P (the activations and the rows go through mapped host memory,
+read by copy kernels, so they do not queue behind the expert blobs on either card's copy engine). The share falls with
+the prompt (0.41 of the streamed experts at 1.5K tokens, 0.32 at 3K) and is off from ~3.3K tokens, where the stages
+overlap anyway and no share paid. Measured on 2x RTX 3090 (UD-Q4_K_XL, no P2P), prompt tok/s without / with: 1.1K 496 /
+723, 1.5K 674 / 833, 2K 878 / 1,128, 2.5K 1,017 / 1,296, 3K 1,260 / 1,440; 4K and 8K unchanged, decode unchanged. It
+costs no VRAM (the idle card's own prompt buffers) and ~110 KB of mapped host memory per token of the largest chunk it
+helped (~360 MB at 3.3K tokens). The rows it computes round like a different MMQ grouping, so the output is not
+bit-identical to the default's (it is repeatable: same prompt, same output), which is why it is **opt-in**:
+`STRATA_PREFILL_HELP=1` turns it on. Native packs on the MMQ prompt path only (not with the fused prompt kernels,
+`STRATA_PF_FUSED=1`, nor with `--peer-device`). With it on, `STRATA_PREFILL_HELP_FRAC=f` fixes the share.
+
The engine flags behind it: `--layer-split K1[,K2..]|auto` and `--split-device D1[,D2..]` (the later stages'
devices; default the next visible ones). `--layer-split K --split-device 0` runs both stages on one card sharing
everything - the bit-exact check of the hand-off, not a speed mode.
+**Each card loads only its own layers' dense weights** (0.1.39, PR #639) with explicit split points (`--layer-split
+27`, not `auto`): every card used to keep a full copy (~3.4 GB for the Coder) though its stage reads only its own
+layers, and the VRAM it frees goes to that card's expert cache (2x MI50 16 GB, Coder: 8,819 -> 10,626 experts in
+VRAM, decode 39.2 -> 41.7 tok/s). Opt-in for now, on AMD and NVIDIA alike: `STRATA_STAGE_TRIM=1` (please report how
+it goes).
+A card holding more experts can change which experts run on the GPU, so the output can differ slightly from a run
+without it.
+
**auto** tries every placement (all of them for two or three cards; proportional to the free VRAM beyond that) and
keeps the one whose caches would hold the most of the expert profile, hottest pairs weighted most; ties go to the
placement that leaves the fullest card the most room. The startup log prints the choice:
@@ -140,3 +167,8 @@ The Coder on an RTX 5080 + RTX 3090 (Ryzen 9 9950X3D), 32K context; details in
- Leave out a much slower card when two already hold the model. An RTX 2080 Ti as a third card made the 5080 +
3090 pair slower (68 / 90 tok/s decode): every extra card costs its own round per window.
- More cards pay off when the model's routed experts do not fit the faster ones.
+
+## Several conversations at once
+
+With a layer split, `--batch N --batch-groups G --trim-stage-weights` decodes several conversations together and
+pipelines them through the cards: see [BATCHING.md](BATCHING.md).
diff --git a/docs/NVIDIA_V100.md b/docs/NVIDIA_V100.md
new file mode 100644
index 0000000000..7e8b0c45fe
--- /dev/null
+++ b/docs/NVIDIA_V100.md
@@ -0,0 +1,71 @@
+# NVIDIA Tesla V100 / Titan V (Volta, sm_70): the community build
+
+Strata's ready-made engine and the support matrix start at compute capability 7.5 (RTX 20). A V100 (7.0) runs the same
+engine compiled for it, through the experimental build that issue #236 added. It is **community-tested, not part of the
+supported cards**: no ready-made engine is published for it and the numbers below come from one machine.
+
+## Build
+
+CUDA 13 dropped Volta, so the build needs a **CUDA 12.x** toolkit (12.8 was used) and a host GCC that toolkit accepts.
+
+```sh
+cmake -S . -B build -DSTRATA_ENABLE_CUDA=ON -DSTRATA_BUILD_TESTS=OFF \
+ -DCMAKE_CUDA_ARCHITECTURES=70 -DSTRATA_EXPERIMENTAL_SM60=ON
+cmake --build build --target strata -j
+```
+
+Setup does the same by itself for a model on a V100 (the experimental CUDA 12 engine, in `engine-cuda12/`: ready-made
+on Windows, compiled with a CUDA 12.x toolkit on Linux; [OLDER_GPUS.md](OLDER_GPUS.md)). For a PC with a V100 and a newer card, a build for both architectures (`-DCMAKE_CUDA_ARCHITECTURES="70;86"`) is the
+natural choice (a community report in issue #509 ran an RTX 3080 beside a V100 that way; it was not repeated here). On the
+measuring PC a Quadro RTX 4000 (sm_75) sits beside the V100s and was hidden with `CUDA_VISIBLE_DEVICES` because the sm_70-only
+build has no code for it.
+
+## What runs on Volta
+
+The flag only lowers the floor (CMake and the runtime device check) and replaces two intrinsics Volta lacks. It does not
+select "old" kernels everywhere: which kernel runs is decided per architecture when the engine is built.
+
+| Part of the prompt path | On sm_70 |
+| --- | --- |
+| MoE experts (ggml MMQ) | `dp4a` kernels (Volta has no int8 tensor cores) |
+| Dense projections (dequantized weights) | FP16 tensor-core GEMMs (cuBLAS / CUTLASS `s884`) |
+| BF16 projections (hyper-connection, router, indexer, ...) | converted to FP16 and run on the FP16 tensor cores (#655, #540; `STRATA_BF16_TC=0`: cuBLAS BF16, an FP32 SIMT kernel on Volta) |
+| QSA attention for decode and verify windows (and prompts with `STRATA_PROMPT_ATTN_OLD=1`) | #540's kernel (fewer shuffles, bit-exact; `STRATA_ATTN_PRE75=0`: the one other cards run) |
+| QSA prompt attention, int8 / FP16 / K8V4 KV | `prompt_attn_v70_kernel` on `mma.m8n8k4` (`STRATA_PROMPT_ATTN_OLD=1`: the decode kernel, one query at a time) |
+| QSA prompt attention, Q4_0 KV | the decode kernel |
+| QSA block scores | the warp kernel (the tensor-core scorer needs sm_80) |
+
+## Measured
+
+One V100-PCIE-32GB, i9-7960X, Unsloth `UD-IQ4_XS` (not a setup model), int8 KV, MTP `--spec 2`; prompt read speed of random-word prompts, tokens/s,
+with and without the Volta attention kernel (`STRATA_PROMPT_ATTN_OLD=1`):
+
+| Prompt tokens | old attention path | `prompt_attn_v70_kernel` |
+| --- | ---: | ---: |
+| 7,194 | 1,010 | 1,164 |
+| 28,650 | 1,063 | 1,251 |
+| 114,338 | 973 | 1,123 |
+
+Decode was 38-51 tok/s in every run and is not affected. The profile, the parity numbers, the needle test and the limits are in
+[bench/results/2026-10-03-v100-prompt-attn](../bench/results/2026-10-03-v100-prompt-attn/README.md).
+
+What the kernel does and does not guarantee: against an FP64 reference its error is about 5e-6 (output scale 3.6; the FP32 kernel it replaces: 2e-6); 15 of 15
+needles were found; greedy output was identical on the 28,650- and 114,338-token prompts and differed late in the answer on the 7,194-token one (another
+summation order).
+
+## Checking and benchmarking the Volta kernels
+
+```sh
+cmake -S . -B build -DSTRATA_PARITY_PROMPT_ATTN=ON ... && cmake --build build --target qsa_prompt_attn_parity
+CUDA_VISIBLE_DEVICES=0 ./build/qsa_prompt_attn_parity 32768 2048 5 # synthetic, no model; exit 0 = PASS
+```
+
+It compares the tensor-core attention with the FP32 kernel and an FP64 host reference and times both. The Q4_0 cases are skipped below
+sm_80.
+
+## Limits
+
+- A layer split over two V100 was not measured with this kernel. The cards work in turn on a single request, so a split adds expert-cache room, not prompt speed.
+- sm_60 (Pascal) is covered by the same flag but was not measured here.
+- Volta's tensor cores take FP16 only: a model's BF16 weights are converted, and a weight outside FP16's range would saturate (none did in
+ the check above).
diff --git a/docs/OLDER_GPUS.md b/docs/OLDER_GPUS.md
new file mode 100644
index 0000000000..85479834d4
--- /dev/null
+++ b/docs/OLDER_GPUS.md
@@ -0,0 +1,111 @@
+# Older GPUs (experimental)
+
+Strata's supported cards are NVIDIA RTX 20 / 30 / 40 / 50 and the AMD cards in [AMD_HIP.md](AMD_HIP.md). The cards
+below run through **opt-in, experimental** paths that community members wrote and measured on their own machines. The
+maintainers have none of these cards: each path is compile-checked and unit-tested here, and the ready-made engines
+and their output stay exactly as they were. Numbers are the reporters' own, on one machine each.
+
+## Support matrix
+
+### NVIDIA
+
+| Cards | Compute capability | How it runs | What is different on it | Reported |
+| --- | --- | --- | --- | --- |
+| Tesla P100 | 6.0 | the CUDA 12 engine | `__dp4a` emulated (bit-exact); BF16 projections through fp32 | not measured |
+| Tesla P40 / P4, GTX 10 series | 6.1 | the CUDA 12 engine | BF16 projections through fp32 (cuBLAS has no BF16 GEMM there, #395) | P40, IQ3_S, engine 0.1.30: prompt 217-374 tok/s, decode 30-33 tok/s (#395) |
+| Tesla V100, Titan V | 7.0 | the CUDA 12 engine | BF16 projections on the FP16 tensor cores (#655, #540); the prompt attention on `mma.m8n8k4` (#600); a leaner attention kernel (#540) | V100-PCIE-32GB, UD-IQ4_XS: prompt 1,123-1,251 tok/s (#600); V100 32GB, IQ2_XS: prompt +22% from #540 |
+| RTX 20 (Turing) | 7.5 | **supported**, the ready-made engine | opt-in: `STRATA_BF16_TC=1` runs the BF16 projections on the FP16 tensor cores | RTX 2080 Ti, Q2_0: prompt +15-18% (#655) |
+
+A card needs enough VRAM to be useful: 12 GB or more is recommended, as for every card (a 2 GB GT 1030 is compute
+capability 6.1 too, and cannot hold any of the model).
+
+### AMD
+
+| Cards | Architecture | How it runs | Reported |
+| --- | --- | --- | --- |
+| RX 6800 / 6900 series | gfx1030 | setup (`--backend hip`), unvalidated | [AMD_HIP.md](AMD_HIP.md#rdna2-gfx1030) |
+| RX 6700 XT | gfx1031 | setup (`--backend hip`), unvalidated (#524) | used daily by its reporter, one card |
+| RX 5500 XT (RDNA1) | gfx1012 | built by hand: `-DCMAKE_HIP_ARCHITECTURES=gfx1012` (HIP 5.7 or 7) | 8 GB card, IQ3_S, 8K prompt: 15.3 tok/s decode (#442) |
+| Instinct MI50 / MI60, Radeon VII | gfx906 (wave64) | built by hand: `-DSTRATA_HIP_GFX906=ON` | 2x MI50, Coder IQ1_M, 128K context: decode 50.1 / 47.8 / 45.7 tok/s at 4K / 32K / 128K prompt tokens, prompt ~520 tok/s (#677) |
+
+## NVIDIA: the CUDA 12 engine
+
+CUDA 13 dropped Pascal and Volta: it cannot compile for them. Setup therefore keeps a **second engine**, built with
+CUDA 12.9 and `-DSTRATA_EXPERIMENTAL_SM60=ON`, in its own folder (`engine-cuda12\`, beside `engine\`). One engine runs
+per model, so the choice is made per model, by the oldest card that model runs on:
+
+- **Every card the model uses is RTX 20 or newer:** the ready-made CUDA 13 engine, as always.
+- **A card is Pascal or Volta:** the CUDA 12 engine. Setup says so (`CUDA 12: sm_70 is older than CUDA 13 supports
+ ...`). On Windows it downloads `strata-windows-x64-cuda12.zip` with NVIDIA's CUDA 12 libraries (from pip, like the
+ CUDA 13 ones); on Linux, or with `--build`, it compiles the engine with a CUDA 12.x toolkit.
+
+### Opting in
+
+An older card is used only when you choose it; a PC with a newer card keeps recommending the newer one.
+
+| You | Setup |
+| --- | --- |
+| have only Pascal / Volta NVIDIA cards (and no AMD card it can use) | uses them, with the CUDA 12 engine |
+| name the card: `START-HERE.bat --setup --gpu 1`, or `--gpus 0,1` with a newer card | uses it; the model gets the CUDA 12 engine |
+| `--cuda 12` (or `STRATA_CUDA=12`) | the CUDA 12 engine for this model, on any card |
+| `--cuda 13` | the CUDA 13 engine even with an older card (a warning: it has no code for that card) |
+| `STRATA_EXPERIMENTAL_SM60=1` | the older cards are listed as usable (the setting from #295 still works) |
+
+The choice is kept in the model's config (`"cuda": 12`): its starts and `UPDATE.bat` keep it, and other models keep
+their own engine. Setting the model up again chooses again (by its cards; add `--cuda 12` to keep a forced choice). A Pascal / Volta card added to a model at a start (`--gpus`) moves that model to the
+CUDA 12 engine.
+
+`--cuda 12` is also the way to run Strata with an NVIDIA driver older than 580: CUDA 12 needs 528 or newer on Windows
+(527.41, NVIDIA's minor-version compatibility) and 525 on Linux; setup's "driver too old" stop says so. Such old
+drivers were not tested here.
+
+### Mixed cards
+
+A model that shares a V100 with an RTX 30 / 40 card runs both on the CUDA 12 engine (it has code for sm_60 to sm_89,
+plus PTX). An RTX 50 card (sm_120) in a CUDA 12 engine is a warning, not a stop: CUDA 12.8 and newer compile for it,
+but engines built with 12.8 crashed on long prompts there (#220, #224). Keep the RTX 50 card on its own model (`--gpu
+N`), where it runs the CUDA 13 engine.
+
+### Building it yourself
+
+```sh
+# Linux (Windows: the same with the toolkit's nvcc.exe)
+cmake -S . -B build-cuda12 -DSTRATA_ENABLE_CUDA=ON -DSTRATA_BUILD_TESTS=OFF -DSTRATA_EXPERIMENTAL_SM60=ON \
+ -DCMAKE_CUDA_ARCHITECTURES="61;70" -DCMAKE_CUDA_COMPILER=/usr/local/cuda-12.9/bin/nvcc
+cmake --build build-cuda12 --target strata -j
+```
+
+Setup does the same when it compiles: it looks for the newest CUDA 12.x toolkit (`STRATA_NVCC=` picks
+one, #601; on glibc 2.43 use 12.8, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md)).
+
+### What the flag changes, and what it does not
+
+`-DSTRATA_EXPERIMENTAL_SM60=ON` lowers the runtime floor to compute capability 6.0 and compiles the older cards' code
+paths into that build only: the Volta prompt attention (#600), #540's attention kernel (used below sm_75), and the
+Pascal BF16 path. The ready-made CUDA 13 engine has none of them, so its kernels and its output are unchanged. The
+FP16 path for BF16 projections is in every build but runs by default only below sm_75; RTX 20 owners can try it with
+`STRATA_BF16_TC=1` (`=0` turns it off on a V100). It is not bitwise the same as cuBLAS's BF16 kernel (FP16 tensor-core
+sums round differently): #540 measured a mean KL of 8.4e-3 on the next-token distribution of 24 code prompts on a V100
+(the same top-1 in 23), the size of other summation-order changes; #655 a worst relative difference of 3.5e-5 per
+product on an RTX 2080 Ti.
+
+A/B switches: `STRATA_BF16_TC=0|1`, `STRATA_PROMPT_ATTN_OLD=1` (the decode kernel for prompts), `STRATA_ATTN_PRE75=0`
+(#540's kernel off). [NVIDIA_V100.md](NVIDIA_V100.md) has the V100 build, its measurements and the parity test.
+
+## AMD: building gfx906 and gfx1012
+
+Setup does not build these; build by hand and run `serve/server.py` with a config, as on any other card.
+
+- **gfx906** (MI50 / MI60 / Radeon VII, wave64): a separate opt-in build, `-DSTRATA_HIP_GFX906=ON
+ -DCMAKE_HIP_ARCHITECTURES=gfx906` (not `STRATA_ENABLE_HIP`). Current ROCm no longer ships gfx906 libraries; the
+ reporter used a community ROCm 7.14 image. Recipe, kernels and measurements: [AMD_HIP.md](AMD_HIP.md#gfx906-instinct-mi50--mi60-radeon-vii-wave64-built-from-source).
+- **gfx1012** (RX 5500 XT): the wave32 backend, `-DSTRATA_ENABLE_HIP=ON -DCMAKE_HIP_ARCHITECTURES=gfx1012`. HIP 5.7
+ (Ubuntu's packages) works: older hipBLAS (0.x) is used through rocBLAS, the legacy HIP names and the missing
+ `__syncwarp` are version-gated, and RDNA1's missing signed dot4 uses llama.cpp's SDWA sequence
+ (`-DSTRATA_GFX1012_PORTABLE_DOT=ON`: the portable one).
+
+## Reports welcome
+
+These paths stay experimental until more people run them. A report with the card, the driver / ROCm version, the
+model and the engine log (`strata-.log`) in an issue helps; [COMMUNITY_BENCHMARKS.md](COMMUNITY_BENCHMARKS.md)
+has the format for measurements.
diff --git a/docs/SECOND_GPU.md b/docs/SECOND_GPU.md
index 179b9b3fa1..8dd82b25ff 100644
--- a/docs/SECOND_GPU.md
+++ b/docs/SECOND_GPU.md
@@ -84,6 +84,44 @@ The existing warning about the CUDA0 expert-cache GPU hit path still applies:
its outputs diverge from cache-off runs. Treat performance as experimental
until the generated tokens have been validated.
+## Optional helper decode optimization
+
+`--remote-expert-opt` (`--serve` only) optimizes the CUDA1-3 helper caches
+above. The engine's default is off; since 0.1.39b setup adds it to a config on
+two or more GPUs (`--gpus`, or "use both" at start). It acts only when a helper
+cache is configured; a layer split runs exactly as before. To leave it out:
+setup's `--no-remote-expert-opt`, or `"remote_expert_opt": false` in the
+model's `strata-*.json` (kept when setup runs again). Measured by the PR's
+author: dual RTX 4090 +63% mixed / +132% code decode over the plain helper
+path; RTX 5090 + 4090 +28% / +63%. The primary cache avoids admitting experts already held
+by a helper, and helpers replace cold experts with frequently routed CPU
+misses using their existing same-layer slots. Each helper reduces its expert
+outputs to a weighted partial sum on its GPU before returning one vector per
+token. Tokens with no CPU expert work skip CPU activation quantization.
+
+For an existing server configuration with CUDA0 as the primary and CUDA1 as a
+helper, use these engine arguments alongside the model and profile arguments:
+
+```text
+--expert-cache auto --expert-cache-device1 auto --remote-expert-opt
+```
+
+`--expert-cache-device1`, `--expert-cache-device2` and
+`--expert-cache-device3` now also accept `auto`: fill each helper from its
+assigned ranking using actual aligned expert bytes and free VRAM, retaining
+the existing 512 MiB allowance. Explicit numeric budgets still work. This is
+startup capacity sizing, not throughput balancing; with several helpers a
+card's truncated candidate tail is not redistributed to another card.
+
+The optimization uses the existing host scheduling and pinned-host transport,
+not P2P or tensor parallelism. Each layer still waits for its participating
+helpers. It changes floating-point summation order, so enabled output is not
+claimed to be bitwise identical. Only two-card CUDA operation has been measured;
+three/four cards and HIP have not been validated. Without the switch, the
+existing decode path remains in use. This does not optimize the separate
+`--peer-device` path below or change its existing incompatibility with helper
+caches.
+
## Peer tier (`--peer-device`)
`--peer-device N` puts a second adaptive expert cache on CUDA device N. It
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index 2d040b09db..7287409e45 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -25,6 +25,14 @@ Strata is already running. Look for its window. Or another program uses the port
**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.
+**Linux: the engine does not compile (`unsupported GNU version`, or `exception specification is incompatible` for
+`cospi`/`sinpi`/`rsqrt`).** Two known mismatches between the CUDA toolkit and a new Linux (#601):
+- gcc newer than 14 (Ubuntu 26.04's default 15): CUDA 12.x and 13.0 refuse it. Install g++-14 and run
+ `CXX=g++-14 CUDAHOSTCXX=g++-14 ./setup.sh`.
+- glibc 2.43 with CUDA 12.9: the toolkit's math headers clash with glibc's, already in CMake's first test compile.
+ Use CUDA 12.8 or 13.x instead. Setup takes the newest toolkit it finds; `STRATA_NVCC=/usr/local/cuda-12.8/bin/nvcc
+ ./setup.sh` makes it use that one (only that one).
+
**The first start takes minutes.**
It is reading 34-55 GB into RAM; the second start is faster while the files are in the OS cache. Started from Task
Scheduler, it can be 24x slower: see [Running it at startup](DETAILS.md#running-it-at-startup-task-scheduler).
diff --git a/docs/UNSLOTH_Q4.md b/docs/UNSLOTH_Q4.md
index 12d4d1997f..e275dec7cc 100644
--- a/docs/UNSLOTH_Q4.md
+++ b/docs/UNSLOTH_Q4.md
@@ -221,10 +221,37 @@ engine with `--short-read` covering the positions to compare; llama.cpp's side w
| `STRATA_PARTIAL_PIN=1` | Registers the hottest part of the RAM budget (up to `STRATA_PARTIAL_PIN_GIB`, default 24) with the GPU driver, so the GPU computes a share of the misses over PCIe (`--pcie-frac`). Measured no faster on this PC, and it changes the numerics of those experts (GPU kernels instead of the CPU's), so off. |
| `STRATA_FETCH_THREADS=N` | Threads that read the experts from the GGUF while it answers (default 8; 16 was no faster). The prompt path has its own: `STRATA_STAGER_THREADS` (default 32 here) and `STRATA_STAGER_RING` (128). |
+## UD-IQ4_XS (setup from 0.1.39, #621)
+
+Unsloth's `UD-IQ4_XS` at the same revision is in setup too, as a regular choice (not experimental; the Unsloth
+family's first size and its default): `START-HERE.bat --setup --family unsloth --model UD-IQ4_XS` (engine 0.1.38 or
+newer). It sits between IQ3_S and UD-Q4_K_XL: its routed experts are IQ3_S gate/up (IQ4_XS
+in one layer) with IQ4_NL downs (Q8_0 in five layers), 59.5 GB of them; the dense side (Q8_0 projections, the IQ4_NL
+PLE table, a Q6_K head) is UD-Q4_K_XL's. Three shards, 93.7 GB:
+
+| File | Bytes | SHA-256 |
+| --- | ---: | --- |
+| `Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf` | 10,946,624 | `5ce89370720f8bf90890f439361282104c1aa1482d4013bb9a50923e758e71a4` |
+| `Qwen3.8-Flash-Next-UD-IQ4_XS-00002-of-00003.gguf` | 49,835,229,856 | `577a38a2392b40ca2193cea502e1d92f60b8cd370675d308e0ec21885d9daaa7` |
+| `Qwen3.8-Flash-Next-UD-IQ4_XS-00003-of-00003.gguf` | 43,836,407,744 | `d4634e6d84f0ebb0940be15c90d3790bf6464e3dea3a1cddc567dc0e83ad8833` |
+
+Setup treats it like UD-Q4_K_XL (the list above): the same RAM budget (your RAM less 24 GB; at most all 55 GiB of its
+experts, so a PC with ~80 GB of RAM or more holds all of them), the pack with `--compat-bf16`, no `experts.bin`, one
+GPU by default. It does not ask on AMD: its experts' formats have prompt kernels there too. Images are an option (asked,
+off by default; NVIDIA): the image path has no restriction for this pack, which uses the original model's image
+encoder; not yet run with images.
+
+What is known so far: it packs and runs on a Strix Halo (AMD gfx1151, 128 GB unified memory), where prompts read at
+~820 tokens/s at 8K and 64K and the answers passed the retrieval checks at 8K and 64K. It has **not** been measured on
+NVIDIA yet; please report what you see. One fidelity note that applies to every `--compat-bf16` pack (UD-Q4_K_XL too):
+the pack rounds the file's Q8_0 hyper-connection projections to BF16, and on the Strix Halo that rounding put
+perplexity 6-9% above llama.cpp's on the same file (teacher-forced, short context). Reading the Q8_0 values instead
+closes most of that gap; it is measured on AMD only and not in this release.
+
## Scope and validation
-- Only UD-Q4_K_XL at revision `38bb39e` is targeted. Other Unsloth quantizations use formats this engine may not
- have kernels for; the engine checks every layer's formats at start and refuses an unsupported one by name.
+- UD-Q4_K_XL and UD-IQ4_XS at revision `38bb39e` are targeted. Other Unsloth quantizations use formats this engine may
+ not have kernels for; the engine checks every layer's formats at start and refuses an unsupported one by name.
- Tests: the packer's synthetic 4-shard and conversion tests (`.venv/bin/python -m unittest discover -s tools -p
test_iq_pack.py`); CTests `gguf_split_test`, `expert_layout_test`, `native_expert_parity_*` (the three real expert
format pairs against ggml-cpu, the Q5_1 min term, Q8_0 rows), `prefill_mmq_kquant_test` (with `-DSTRATA_MMQ_KQUANTS=ON`: the
diff --git a/docs/multigpu-roadmap.md b/docs/multigpu-roadmap.md
index 749b251e87..5916ccb465 100644
--- a/docs/multigpu-roadmap.md
+++ b/docs/multigpu-roadmap.md
@@ -6,7 +6,7 @@ Roadmap authority is GitHub Issue #1 and its child Issues. This document summari
## Current production baseline
-The current promoted engine generation is **Strata 0.1.38** in this fork, integrated from upstream `99f3dbd0b21d1401b3769e0c0d963913607f380b`. The 0.1.38 compatibility gate preserves the independent-lane architecture while consuming upstream prompt/decode, reliability, security/status, profile-persistence, unbuffered-load, and optional peer-tier primitives. Upstream layer-split/peer execution remains a challenger/backend primitive rather than a replacement for request-level lanes.
+The current promoted engine generation is **Strata 0.1.38** in this fork, integrated from upstream `99f3dbd0b21d1401b3769e0c0d963913607f380b`. A **validated 0.1.39 sync candidate** exists on `sync/upstream-0.1.39` at `3727c6bdfb12ba2ffc07402b68ae86c0ed2ac27f`, merging upstream `6f32ec070f23ced9f50e704d854d775da52591ab`; it is not promoted until main moves. The candidate preserves the independent-lane architecture while consuming upstream batching, Responses API, conversation-cache telemetry, decode/kernel, CPU-pool, expert-cache, low-RAM/file-tier, and layer-split improvements. Ordinary lanes strip inherited upstream internal batching just as they already strip inherited layer-split/peer execution, so those mechanisms remain explicit challenger/backend primitives rather than accidental replacements for request-level lanes.
The architectural baseline is:
@@ -156,7 +156,7 @@ Measure the crossover rather than assuming aggregation wins: single-request TTFT
Do **not** begin with dynamic GPU bonding. Reopen bond/unbond policy only if the static `1+1+1` versus `2+1` experiment demonstrates a repeatable workload-dependent crossover large enough to justify topology changes. Any dynamic version must retain a clean independent-lane fallback and must not migrate an active session merely to rebalance GPUs.
-**Current status on Strata 0.1.38 / the reference host: deferred, evidence-negative as an always-on topology, but intentionally left open.** The matched static probe preserved the known single-request benefit (~99 tok/s for the two-GPU Super-Lane versus ~68 tok/s for an ordinary lane), but `2+1` did not establish a useful M=2 aggregate advantage and regressed M=3 aggregate goodput by about 15% versus `1+1+1`. This does not reject upstream layer split as a primitive. Reopen with a short single-request smoke, then M=2, then M=3 only if upstream materially improves layer-split decode/prefill, handoff/staging, split-auto placement, queueing/FIFO behavior, or another relevant execution path enough to move the crossover.
+**Current status on the Strata 0.1.39 sync candidate / reference host: reopened as a serious default-topology challenger; not yet promoted.** Upstream 0.1.39 materially changed the layer-split server through batch slots, pipeline groups and stage-weight trimming. The fixed three-GPU `18,34` pipeline with `--batch 3 --batch-groups 3 --trim-stage-weights` and the shared expert arena now beats independent lanes in the measured fixed decode region at both M=2 (**147.84 ± 2.26 vs 143.19 ± 3.06 tok/s, +3.25%**) and M=3 (**209.66 ± 4.02 vs 192.16 ± 4.11, +9.11%**). The retained M=1 layer-split control is also much faster than one lane, although that point was not rerun with the exact pipeline config. Cold-prefill is the important counterexample: ~15K x3 strongly favors lanes (**5901.34 vs 3289.19 tok/s**), while ~110K x3 narrowly favors the pipelined split (**6028.09 vs 5822.71 tok/s**). The next gate is therefore no longer more fixed microbenchmarks; it is mixed prompt/output lengths, session behavior, queue/tail latency, power, failure isolation and operational flexibility under the always-on three-GPU layer-split topology. Full evidence: [`strata-0.1.39-performance-crossover-20261005.md`](strata-0.1.39-performance-crossover-20261005.md).
### Elastic Super-Lane lifecycle
@@ -247,10 +247,10 @@ Until measurements justify them, this roadmap does **not** assume that Strata sh
## Near-term order
-1. Keep the completed Phase 1/2 serving-control and Phase 3 lifecycle gates as regression controls on the promoted 0.1.38 software baseline; retain the measured 0.1.30/0.1.31 evidence under its original engine generation.
+1. Keep the completed Phase 1/2 serving-control and Phase 3 lifecycle gates as regression controls on the promoted 0.1.38 software baseline. The validated 0.1.39 sync candidate has passed the bounded compatibility gate and should remain a candidate until explicitly promoted; retain measured 0.1.30/0.1.31/0.1.38 evidence under its original engine generation.
2. Do not add another mandatory serving phase without a measured residual.
-3. Keep the completed decode-assist and static `2+1` measurements as **0.1.38 evidence-negative / deferred challengers**, not permanent rejections. Do not repeat the same reference-host sweeps without a material upstream, hardware, or workload change.
-4. Reopen decode assist or static Super-Lane with the smallest staged smoke that can show the crossover moved; only then repeat broader concurrency or lifecycle campaigns. Elastic Super-Lane remains gated on a successful future static crossover; cross-lane migration stays later.
+3. Keep decode assist as **0.1.38 evidence-negative / deferred**, but treat static layer split / Super-Lane as **reopened on 0.1.39** because the measured batch-group pipeline now crosses independent lanes in some decode and very-long-prefill regions. Do not collapse the result into a universal winner claim.
+4. Before any topology promotion, test mixed prompt/output lengths, queue/tail behavior, power, failure-domain cost and the drain/reconfigure/restore lifecycle against the independent-lane fallback. Elastic Super-Lane remains gated on this broader static crossover holding after reconfiguration cost; cross-lane migration stays later.
5. Treat upstream helper, peer, layer-split and load/unload controls as distinct execution/lifecycle primitives: helper offload is valid without P2P, peer execution is deferred on hardware without a validated P2P path, and neither automatically replaces independent lanes.
The default bias remains deliberate simplicity: add coupling only when measurements show it buys something.
diff --git a/docs/multigpu-shared-runtime.md b/docs/multigpu-shared-runtime.md
index 18c1cae194..e77acc6691 100644
--- a/docs/multigpu-shared-runtime.md
+++ b/docs/multigpu-shared-runtime.md
@@ -17,7 +17,7 @@ Detailed reference-host hardware, tuning values, benchmark tables, and validatio
## Shared arena
-Strata 0.1.38 retains the file-backed arena primitive introduced upstream in 0.1.30 through `--shared-expert-arena FILE` (originating from this fork's upstream PR #129). On Linux, upstream `PinnedArena` maps one shared file with a 4 KiB header containing the arena geometry and pack fingerprint; incompatible size or pack identity is refused before the arena is used. The ordinary anonymous/hugetlb path remains unchanged when the option is absent. The Linux whole-arena pinning behavior used by the lane runtime remains intact; the WDDM shared-memory cap is Windows-specific unless explicitly overridden with `STRATA_ARENA_PIN_GIB`.
+The validated Strata 0.1.39 sync candidate retains the file-backed arena primitive introduced upstream in 0.1.30 through `--shared-expert-arena FILE` (originating from this fork's upstream PR #129). On Linux, upstream `PinnedArena` maps one shared file with a 4 KiB header containing the arena geometry and pack fingerprint; incompatible size or pack identity is refused before the arena is used. The ordinary anonymous/hugetlb path remains unchanged when the option is absent. The Linux whole-arena pinning behavior used by the lane runtime remains intact; the WDDM shared-memory cap is Windows-specific unless explicitly overridden with `STRATA_ARENA_PIN_GIB`.
The lane supervisor composes that upstream primitive rather than intercepting `mmap`: it strips inherited shared-arena options, adds the same explicit `--shared-expert-arena` path to every shared lane, and removes the option entirely for the private-arena benchmark arm. The default backing lives under `/dev/shm`, matching upstream's tmpfs contract; an explicit `--arena-file` selects an operator-managed path. Automatically chosen backing files are removed on graceful supervisor exit, while explicit backing-file lifetime remains operator-owned.
@@ -76,6 +76,18 @@ The proxy also exposes `X-Strata-Lane-Index`, `X-Strata-Admission-Rank`, `X-Stra
`balanced-additive-new-prefill-retained-state-proxy-v1` is the promoted Phase-2B default, while `safe-affinity-live-state-v1` remains an explicit rollback control; either may run without persistent benchmark tracing. Other placement policies remain experimental and require `--bench-trace-jsonl` when selected through `--bench-scheduler-policy`. All policies preserve existing-session affinity, health/capability filtering, one active generation per lane, affinity reservations, vision reservation and compatible FIFO ordering; they can only choose differently among idle lanes for a new session. The trace retains the historical `placement_key` / `selected_placement_key` fields for compatibility and adds the active `placement_score` / `selected_placement_score`, policy name, scope and proxy source. Policies that use the last completed request byte footprint say `retained-state-proxy` in their names and report `retained_last_completed_request_bytes_v1`; this is routing-state evidence, not engine-truth active load or compute cost. The promoted balanced-additive policy orders new-session candidates first by remembered affinity-session count and then by additive estimated new-prefill plus retained-state cost. Persistent-supervisor validation on the reference host kept all six measured waves at 2/2/2 session placement across two independent campaigns, while the safe and unbalanced additive controls each developed 6-to-1 concentration on later waves; exact measurements are retained in the public recipe repository.
+## Validated 0.1.39 sync candidate
+
+The `sync/upstream-0.1.39` branch merges upstream Strata **0.1.39** (`6f32ec070f23ced9f50e704d854d775da52591ab`) into the fork at merge commit `3727c6bdfb12ba2ffc07402b68ae86c0ed2ac27f`. Main remains on the promoted 0.1.38 baseline until an explicit promotion.
+
+The candidate keeps one ordinary whole-GPU engine per lane. Because upstream 0.1.39 adds engine-internal request batching and layer-split batch groups, the supervisor removes inherited top-level `parallel` and CLI `--batch`, `--slots`, `--batch-groups`, and `--trim-stage-weights` from ordinary lane configs, in addition to the existing layer-split/peer stripping. This prevents a copied upstream config from silently changing the Lanes concurrency unit from one request/session per independent engine into nested batching. A future hybrid-lane experiment must opt into that coupling explicitly.
+
+The supervisor also treats `/v1/responses` as generation work so the OpenAI Responses API follows the same admission, busy-lane exclusion, and lane selection path as Chat Completions and Anthropic Messages. Parking telemetry no longer extends the engine `DONE` record in this sync; upstream 0.1.39's native conversation-cache monitor owns that field space, avoiding the former semantic collision with upstream's appended offloaded-expert counter.
+
+The bounded 0.1.39 compatibility gate passed **333 Python serving tests with 7 skipped**, a fresh **CUDA 13.4 sm_120 Release `strata` build** with `STRATA_BUILD_TESTS=OFF`, a second fresh Release build with `STRATA_BUILD_TESTS=ON` that linked the full `strata` executable, and focused `pinned_shared_test`, `expert_profile_save_test`, and `file_expert_source_test` CTests. A live IQ3_XXS single-lane smoke generated successfully on GPU 0. A live three-lane 32K compatibility smoke then routed three simultaneous requests one each to GPUs 0/1/2; lane 0 populated the ~39.97 GiB shared expert arena once, while lanes 1 and 2 reported `shared population ready; source load skipped`. These are promotion/compatibility checks, not a new performance campaign.
+
+A later bounded reference-host topology campaign exercised the 0.1.39 execution challenger explicitly. Ordinary lanes still strip nested batch/layer-split flags by default, but a separate upstream-native three-GPU layer-split server using explicit split `18,34`, `--batch 3 --batch-groups 3 --trim-stage-weights`, and the same shared expert backing measured **147.84 ± 2.26 tok/s** at M=2 versus **143.19 ± 3.06** for two lanes and **209.66 ± 4.02 tok/s** at M=3 versus **192.16 ± 4.11** for three lanes. Cold-prefill crosses by length: lanes lead at ~15K x3 (**5901.34 vs 3289.19 tok/s**), while the pipelined split narrowly leads at ~110K x3 (**6028.09 vs 5822.71 tok/s**). This is challenger evidence, not a change to the ordinary lane contract; details and caveats are in [`strata-0.1.39-performance-crossover-20261005.md`](strata-0.1.39-performance-crossover-20261005.md).
+
## Current promoted engine baseline
The shared-lane runtime is based on upstream Strata **0.1.38**, integrated from upstream commit `99f3dbd0b21d1401b3769e0c0d963913607f380b`. The 3-lane reference contract remains one engine per GPU, 262144 context and 32768 resident KV per lane, disjoint CPU partitions, asymmetric per-lane PCIe tuning, and one upstream-native shared expert arena.
diff --git a/docs/strata-0.1.39-performance-crossover-20261005.md b/docs/strata-0.1.39-performance-crossover-20261005.md
new file mode 100644
index 0000000000..c82b6c59a8
--- /dev/null
+++ b/docs/strata-0.1.39-performance-crossover-20261005.md
@@ -0,0 +1,44 @@
+# Strata 0.1.39 topology crossover evidence — 2026-10-05
+
+## Status
+
+This record belongs to the validated but not-yet-promoted Strata 0.1.39 sync candidate. It records only the measurements needed for the current topology decision; concrete raw evidence and supporting controls live in the public recipe repository.
+
+Measured source: `d51d7e9cbc327f90c2fd59b1e20a949033f594e2` on `sync/upstream-0.1.39`, upstream Strata `6f32ec070f23ced9f50e704d854d775da52591ab` (v0.1.39).
+
+## Decode concurrency
+
+The strongest measured three-GPU upstream-native challenger uses explicit split `18,34`, `--batch 3 --batch-groups 3 --trim-stage-weights`, and the shared expert arena.
+
+| Concurrent requests | Independent lanes | Three-GPU layer split | Result |
+| ---: | ---: | ---: | --- |
+| 1 | 73.63 ± 1.67 tok/s | **120.62 ± 1.24 tok/s**¹ | layer split +63.8% |
+| 2 | 143.19 ± 3.06 tok/s | **147.84 ± 2.26 tok/s** | layer split +3.25% |
+| 3 | 192.16 ± 4.11 tok/s | **209.66 ± 4.02 tok/s** | layer split +9.11% |
+
+¹ M=1 is the retained same-binary three-GPU layer-split single-request control. The exact batch3/groups3/trim config was not rerun at M=1. M=2 and M=3 are exact measurements of the fixed pipelined config.
+
+The M=2 engine remained in `strata batch (pipelined, 3 groups of 1)`; with only two active requests one group is unused. Both requests completed together at roughly 6.9 s mean E2E, not as a FIFO staircase.
+
+The old three-request FIFO result is therefore only a serial control, not the 0.1.39 layer-split concurrency ceiling.
+
+## Cold-prefill crossover
+
+Three cold prompts were submitted simultaneously and every retained request reported `cache_n=0`.
+
+| Prompt regime | Independent lanes | Pipelined layer split | Result |
+| --- | ---: | ---: | --- |
+| ~15K x3 | **5901.34 ± 55.16 tok/s** | 3289.19 ± 18.83 | lanes +79.4% |
+| ~110K x3 | 5822.71 ± 4.49 | **6028.09 ± 14.50** | layer split +3.5% |
+
+The ~15K region favors independent request-level prefill. At ~110K the intra-prompt chunk pipeline is saturated enough to cross back slightly in favor of layer split.
+
+The 15K/110K arms are workload/length matched but not byte-identical because the unique nonce encoded the topology arm.
+
+## Decision impact
+
+The 0.1.39 evidence no longer supports treating independent lanes as the general performance winner for concurrent decode. The fixed three-GPU layer-split server is ahead in the measured M=2 and M=3 decode regions, while medium multi-cold-prefill remains a large independent-lane advantage and very-long cold prefill crosses back toward layer split.
+
+This reopens static layer split / Super-Lane as a serious production-topology candidate, but it does not yet change the default contract. Remaining promotion gates are workload mix, unequal prompt/output lengths, session behavior, queue/tail latency, failure isolation, power, and operational flexibility.
+
+Public recipe record: https://github.com/rhgo1749/qwen3.8-flash-next-strata-gpu-per-lane-recipe/blob/main/docs/strata-0.1.39-performance-crossover-20261005.md
diff --git a/include/strata/core/conversation_cache.hpp b/include/strata/core/conversation_cache.hpp
index 0a190c709e..b105deb60c 100644
--- a/include/strata/core/conversation_cache.hpp
+++ b/include/strata/core/conversation_cache.hpp
@@ -53,13 +53,72 @@ struct ConversationKvReuse {
std::vector kv;
// Original image extent for validation, and the earliest subsequent rewrite.
int64_t captured_tokens = 0, unchanged_tokens = 0;
+ // With a layer split: the later stages' own retained K/V, one per stage, same extents (empty: none)
+ std::vector stages;
size_t bytes() const {
- size_t n = kv.capacity() * sizeof(ConversationKv);
+ size_t n = kv.capacity() * sizeof(ConversationKv) + stages.capacity() * sizeof(ConversationKvReuse);
for (const auto& layer : kv) n += layer.bytes();
+ for (const auto& s : stages) n += s.bytes();
return n;
}
};
+/// Layer-split parking: each running checkpoint holds the first stage's state and, in `stage_parts`, one part per
+/// later stage; a parked conversation keeps them in one image per stage. `split` MOVES the checkpoints apart (no
+/// running state is copied): the complete ones (a part for each of `stages` stages) into `stage0` and `parts[k]`,
+/// every part carrying the checkpoint's ids / images / LRU stamp, the others aside; `merge` puts them back in their
+/// original order. merge(split(x)) == x.
+struct ConversationCheckpointSplit {
+ std::vector stage0;
+ std::vector> parts; ///< [stage][checkpoint]
+ std::vector rest; ///< not split (no complete set of stage parts)
+ std::vector complete; ///< per original position: in stage0/parts or in rest
+};
+
+inline ConversationCheckpointSplit conversation_checkpoints_split(std::vector&& checks,
+ size_t stages) {
+ ConversationCheckpointSplit out;
+ out.parts.resize(stages);
+ for (auto& c : checks) {
+ const bool whole = c.stage_parts.size() == stages;
+ out.complete.push_back(whole);
+ if (!whole) { out.rest.push_back(std::move(c)); continue; }
+ for (size_t k = 0; k < stages; ++k) {
+ ConversationCheckpoint part = std::move(c.stage_parts[k]);
+ part.ids = c.ids; part.imgs = c.imgs; part.used = c.used; part.stage_parts.clear();
+ out.parts[k].push_back(std::move(part));
+ }
+ c.stage_parts.clear();
+ out.stage0.push_back(std::move(c));
+ }
+ checks.clear();
+ return out;
+}
+
+/// false (and `out` empty) when the parts do not line up: a stage with another number of checkpoints, or a part
+/// whose ids differ from its checkpoint's.
+inline bool conversation_checkpoints_merge(ConversationCheckpointSplit&& split, std::vector& out) {
+ out.clear();
+ for (const auto& p : split.parts)
+ if (p.size() != split.stage0.size()) return false;
+ size_t whole = 0, other = 0;
+ for (bool c : split.complete) (c ? whole : other) += 1;
+ if (!split.complete.empty() && (whole != split.stage0.size() || other != split.rest.size())) return false;
+ if (split.complete.empty()) split.complete.assign(split.stage0.size(), true); // a restored image: all whole
+ for (size_t j = 0; j < split.stage0.size(); ++j)
+ for (const auto& p : split.parts)
+ if (p[j].ids != split.stage0[j].ids) return false;
+ size_t w = 0, r = 0;
+ for (bool c : split.complete) {
+ if (!c) { out.push_back(std::move(split.rest[r++])); continue; }
+ ConversationCheckpoint cp = std::move(split.stage0[w]);
+ for (auto& p : split.parts) cp.stage_parts.push_back(std::move(p[w]));
+ ++w;
+ out.push_back(std::move(cp));
+ }
+ return true;
+}
+
struct SavedConversation {
// Runtime compatibility only; NOT a model/weights identity or disk schema.
std::array geometry{};
@@ -69,10 +128,13 @@ struct SavedConversation {
std::vector checkpoints;
std::vector kv; // main layers followed by the draft layer
bool cvec = true;
+ // with a layer split, the later stages' own images, one per stage, in stage order
+ std::vector stage_images;
size_t bytes() const {
size_t n = live.bytes() + checkpoints.capacity() * sizeof(ConversationCheckpoint) +
kv.capacity() * sizeof(ConversationKv);
+ for (const auto& s : stage_images) n += s.bytes();
for (const auto& c : checkpoints) n += c.bytes();
for (const auto& k : kv) n += k.bytes();
return n;
@@ -110,13 +172,17 @@ class ConversationCache {
// Retain only the restored K/V buffers, not duplicate running checkpoints.
// This optimization never evicts a parked conversation to make itself fit.
- void retain(std::vector&& kv, int64_t tokens) {
+ // `stage_kv`: with a layer split, the later stages' restored K/V (one per stage), retained with the first's.
+ void retain(std::vector&& kv, int64_t tokens,
+ std::vector>&& stage_kv = {}) {
reuse_ = {};
- ConversationKvReuse candidate{std::move(kv), tokens, tokens};
+ ConversationKvReuse candidate{std::move(kv), tokens, tokens, {}};
+ for (auto& k : stage_kv) candidate.stages.push_back(ConversationKvReuse{std::move(k), tokens, tokens, {}});
if (enabled() && candidate.bytes() <= budget_ - bytes_) reuse_ = std::move(candidate);
}
void limit_reuse(int64_t first_dirty) {
reuse_.unchanged_tokens = std::min(reuse_.unchanged_tokens, first_dirty);
+ for (auto& s : reuse_.stages) s.unchanged_tokens = std::min(s.unchanged_tokens, first_dirty);
if (reuse_.unchanged_tokens <= 0) reuse_ = {};
}
ConversationKvReuse take_reuse() { return std::exchange(reuse_, {}); }
diff --git a/include/strata/core/conversation_snapshot.hpp b/include/strata/core/conversation_snapshot.hpp
index a840bf2eda..1a56680df3 100644
--- a/include/strata/core/conversation_snapshot.hpp
+++ b/include/strata/core/conversation_snapshot.hpp
@@ -76,4 +76,21 @@ ConversationRestore conversation_snapshot_restore(const SavedConversation& image
const ModelGeometry& g, const QsaState& draft,
std::string& error);
+// The same with `draft == nullptr`: an image WITHOUT the draft layer's K/V (kv holds the session's own QSA layers
+// only). A layer split's later stages park this way; the draft ring is saved once, with the first stage's image.
+// An image is validated and restored with the same kind of call it was saved with (a K/V layer count mismatch is
+// rejected as invalid).
+bool conversation_snapshot_bytes(const ConversationView& view, const SessionState& session, const ModelGeometry& g,
+ const QsaState* draft, size_t& bytes, std::string& error);
+bool conversation_snapshot_capture_bytes(const ConversationKvReuse& reuse, const ConversationView& view,
+ const SessionState& session, const ModelGeometry& g, const QsaState* draft,
+ size_t& bytes, std::string& error);
+bool conversation_snapshot_save(SavedConversation& image, const ConversationView& view, const SessionState& session,
+ const ModelGeometry& g, const QsaState* draft, std::string& error,
+ ConversationKvReuse reuse = {}, size_t* reused_bytes = nullptr);
+bool conversation_snapshot_validate(const SavedConversation& image, const SessionState& session,
+ const ModelGeometry& g, const QsaState* draft, std::string& error);
+ConversationRestore conversation_snapshot_restore(const SavedConversation& image, SessionState& session,
+ const ModelGeometry& g, const QsaState* draft, std::string& error);
+
} // namespace strata::core
diff --git a/include/strata/core/expert_cache.hpp b/include/strata/core/expert_cache.hpp
index 4a708355f9..8c031ff307 100644
--- a/include/strata/core/expert_cache.hpp
+++ b/include/strata/core/expert_cache.hpp
@@ -16,14 +16,10 @@
// * `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.
+// **WHAT THIS FILE 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 computes nothing itself: the GPU computes the experts it
+// holds in the verify window's grouped kernels and the prompt path (the hit/miss split), and the CPU pool the rest.
+// (Written when nothing consumed the slots yet and `--expert-cache` defaulted to 0; setup's configs use `auto`.)
#pragma once
#include
@@ -93,13 +89,44 @@ class ExpertCache {
void close();
bool valid() const { return base_ != nullptr; }
- int64_t slots() const { return slots_; }
+ /// The slots usable now: all of them, unless `shrink` gave the tail's VRAM back (#533) - then the ones wholly
+ /// inside the VRAM still backed, always a prefix. The prompt path's loan (the last slots) and every report
+ /// follow this; `full_slots()` is what `open` allocated.
+ int64_t slots() const { return live_slots_; }
+ int64_t full_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(); }
+ /// The bytes of `slots()` (all of the arena unless shrunk); `full_bytes()`: of every slot.
+ int64_t bytes() const { return slot_end(live_slots_); }
+ int64_t full_bytes() const { return slot_end(slots_); }
double gib() const { return (double) bytes() / 1073741824.0; }
+ /// **#533: HOT VRAM RESIZE, OPT-IN (--vram-elastic).** With a segment size set before `open`/`open_sized`, the
+ /// arena is one reserved address range backed by physical segments of that size (CUDA virtual memory
+ /// management) instead of one cudaMalloc, so `shrink` can give the tail's VRAM back to the driver - for another
+ /// program - and `grow` can take it again, while every slot keeps its address (the captured graphs, the verify
+ /// plan's pointers and the prompt path's loan all hold addresses into it). 0 (the default): one cudaMalloc, as
+ /// always. CUDA only: a HIP build refuses it at open. The slots past `slots()` after a shrink must hold no
+ /// resident expert and no loan: the CALLER evicts them first (they become CPU misses).
+ void set_segment_bytes(int64_t seg_bytes) { seg_req_ = seg_bytes > 0 ? seg_bytes : 0; }
+ bool segmented() const { return !segs_.empty(); }
+ int64_t segment_bytes() const { return seg_; }
+ /// Bytes of the arena backed by VRAM now (a whole number of segments; the arena when not segmented).
+ int64_t mapped_bytes() const;
+ /// Unmaps every segment past the first `keep_bytes` (rounded UP to a segment boundary): `slots()` becomes the
+ /// slots wholly inside what stays. Waits for the device first. False with `err` when not segmented or a driver
+ /// call failed.
+ bool shrink(int64_t keep_bytes, std::string& err);
+ /// Maps segments again up to `want_bytes` (rounded DOWN to a segment, at most the arena; the last, shorter
+ /// segment only when `want_bytes` covers the arena). Stops at the first segment the driver cannot back (false,
+ /// `err`: what was mapped by then stays). The new slots are empty until the caller fills them.
+ bool grow(int64_t want_bytes, std::string& err);
+ /// The number of leading slots that fit wholly inside the first `bytes` bytes.
+ int64_t slots_within(int64_t bytes) const;
+ /// The bytes the first `n` slots span.
+ int64_t bytes_of(int64_t n) const { return slot_end(n < 0 ? 0 : n > slots_ ? slots_ : n); }
+
/// `(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;
@@ -108,6 +135,13 @@ class ExpertCache {
/// 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);
+ /// Publish a same-layer replacement after its slot copy has completed.
+ void replace(int64_t layer, int32_t old_expert, int32_t new_expert) {
+ auto& old = residency_[(size_t) layer * n_expert_ + old_expert];
+ residency_[(size_t) layer * n_expert_ + new_expert] = old;
+ old = kNotResident;
+ }
+
/// **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
@@ -164,7 +198,17 @@ class ExpertCache {
uint8_t* blocking_staging_ = nullptr;
std::size_t blocking_staging_bytes_ = 0;
#endif
+ int64_t slot_end(int64_t n) const { return off_.empty() ? n * blob_ : (int64_t) off_[(size_t) n]; }
+ bool open_segmented(uint64_t want, std::string& err);
+ void release_segmented();
uint8_t* base_ = nullptr;
+ int64_t live_slots_ = 0; ///< #533: slots() - all of them unless shrunk
+ int64_t seg_req_ = 0; ///< #533: the segment size asked for (0: one cudaMalloc)
+ int64_t seg_ = 0; ///< #533: the segment size used (a multiple of the driver's granularity)
+ uint64_t reserved_ = 0; ///< #533: the reserved address range's size
+ std::vector segs_; ///< #533: each segment's physical handle (0: unmapped)
+ std::vector seg_size_; ///< #533: each segment's size (the last one may be shorter)
+ int64_t mapped_segs_ = 0; ///< #533: segments [0, mapped_segs_) are backed
std::vector residency_; ///< [n_layers * n_expert] -> slot or kNotResident
int64_t slots_ = 0;
int64_t n_layers_ = 0;
diff --git a/include/strata/core/expert_source.hpp b/include/strata/core/expert_source.hpp
index 3b0e07dab2..fb46359bf4 100644
--- a/include/strata/core/expert_source.hpp
+++ b/include/strata/core/expert_source.hpp
@@ -65,6 +65,22 @@ struct CgroupMemoryStat {
/// Returns false when the required memory.stat counters were unavailable.
bool cgroup_available_bytes(uint64_t limit, const CgroupMemoryStat& stat, uint64_t& bytes);
+/// #633: the RAM this process can get. `available`: MemAvailable (Windows: the available physical memory), lowered
+/// to the room under the tightest cgroup limit; `cgroup_limit`: that tightest limit itself (v2 memory.max of the group
+/// and its ancestors, or v1 memory.limit_in_bytes), ~0 when there is none - what a container can never exceed.
+struct HostMemory {
+ uint64_t available = 0;
+ uint64_t cgroup_limit = ~uint64_t{0};
+};
+
+/// Linux reads `meminfo`, `self_cgroup` and the cgroup tree under `cgroup_root` (the parameters are for tests; the
+/// defaults are the real files). cgroup v2 as before (an unreadable limit of a group that has one fails); cgroup v1's
+/// memory controller (`/memory/`: memory.limit_in_bytes - memory.usage_in_bytes); no cgroup line at all is
+/// MemAvailable alone. False when the RAM cannot be determined.
+bool host_available_memory(HostMemory& m, const std::string& meminfo = "/proc/meminfo",
+ const std::string& self_cgroup = "/proc/self/cgroup",
+ const std::string& cgroup_root = "/sys/fs/cgroup");
+
/// Build compact offsets for experts absent from both the primary GPU cache and an optional second GPU tier.
/// Kept CPU-only so selection and byte accounting can be tested without initializing a GPU.
bool make_cache_complement_plan(
@@ -122,6 +138,11 @@ class ExpertSource {
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; }
+ /// A file-backed source: start reading this expert's pages now (it will be needed by the CPU); no-op elsewhere.
+ virtual void prefetch(int64_t layer, int64_t expert) { (void) layer; (void) expert; }
+ /// A file-backed source: this expert lives in VRAM, so its pages need not stay in RAM - hand them back to the
+ /// kernel (a later read re-reads the file; no result depends on it). Returns the bytes released; 0 elsewhere.
+ virtual uint64_t release(int64_t layer, int64_t expert) { (void) layer; (void) expert; return 0; }
/// Whether the verify window may give the GPU a PCIe share of this layer's misses at all (each expert is still
/// checked with `pinned`). The arena answers per layer through its expert 0; the resident RAM mode's compact
/// copy has no expert 0 when the GPU cache holds it, so it answers for the whole copy.
@@ -331,6 +352,9 @@ struct ExpertDispatch {
GpuPlanSink* plan = nullptr;
int pcie_num = 0;
int64_t pcie_experts = 0; ///< distinct experts the GPU read over PCIe in verify windows
+ /// #588: routed (token, expert) entries the GPU computed from outside its cache in verify windows: read over PCIe
+ /// (--pcie-frac, kind 1) or on another GPU (kind 2). In neither cache_hits nor cache_refused.
+ int64_t offload_entries = 0;
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.
@@ -485,8 +509,14 @@ class FileExpertSource : public ExpertSource {
/// beside `ram_bytes` (the RAM budget), cached now or not (their mapped pages would land in the working set); see
/// experts_unbuffered. The mapped reads' page faults are one small request each, and the pages they bring in
/// take the RAM the budget was sized for. `why` says what decided.
+ /// #577: `ram_bytes` counts up to every expert, and only the experts outside it are what the cache must keep.
bool set_unbuffered(uint64_t ram_bytes, std::string& why);
+ /// #577: the same decision once the RAM copy is built (pin_cache_complement), from the RAM it really holds and the
+ /// expert bytes outside it; switches either way (startup only, nothing reading). Returns whether unbuffered.
+ bool recheck_unbuffered(std::string& why);
bool unbuffered() const { return !direct_.empty(); }
+ /// Every expert's bytes (n_layers x n_expert blobs).
+ uint64_t expert_bytes() const;
/// #286, unbuffered: assembles the blobs of these pairs ahead of the `blob` calls that will ask for them (the
/// GPU cache's fill from the profile) - one batch of reads instead of one blob at a time. At most 64 pairs.
void prefetch_pairs(const std::pair* pairs, int64_t n);
@@ -529,6 +559,7 @@ class FileExpertSource : public ExpertSource {
/// #286: the blobs from the drive, unbuffered: every role's 4 KiB-aligned window is read at once (overlapped)
/// into this thread's aligned buffer, then copied into place. False when a read fails.
bool read_direct(const Fill* fills, size_t n) const;
+ bool open_direct(std::string& why);
std::vector paths_; ///< the mapped files, as maps_
std::vector direct_; ///< #286: per file, an unbuffered overlapped handle (Windows)
std::vector role_file_; ///< 3 x n_layers: index into maps_ / direct_
@@ -647,10 +678,14 @@ class ArenaExpertSource : public ExpertSource {
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;
+ void prefetch(int64_t layer, int64_t expert) override;
+ uint64_t release(int64_t layer, int64_t expert) 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_; }
+ /// #633: set by `open` when the RAM available is less than the arena needs (a recommendation; it still loads)
+ const std::string& ram_warning() const { return ram_warning_; }
double load_gib_per_second() const { return gib_per_s_; }
// Loader fix: the load, split. `load_seconds()` is the wall clock of the load loop; the other two are
// sums over the reader threads (see LoadStats), so on their own they say how much of that wall was spent
@@ -662,6 +697,8 @@ class ArenaExpertSource : public ExpertSource {
private:
void* arena_ = nullptr; ///< the PinnedArena, owned
+ void* map_ = nullptr; ///< STRATA_ARENA_MMAP: the arena file, mapped read-only (not the PinnedArena)
+ uint64_t map_bytes_ = 0;
std::vector dev_slice_; ///< device alias of each registered slice (or of the whole range)
uint64_t slice_bytes_ = 0;
const uint8_t* base_ = nullptr;
@@ -669,6 +706,7 @@ class ArenaExpertSource : public ExpertSource {
int64_t n_expert_ = 0;
int64_t reads_ = 0;
std::string note_;
+ std::string ram_warning_;
double gib_per_s_ = 0.0;
double load_seconds_ = 0.0;
double load_read_s_ = 0.0;
diff --git a/include/strata/core/layer.hpp b/include/strata/core/layer.hpp
index a8965da133..aec6e9eb7c 100644
--- a/include/strata/core/layer.hpp
+++ b/include/strata/core/layer.hpp
@@ -168,6 +168,9 @@ bool moe_layer(const WeightTable& tables, const ModelGeometry& g, int64_t layer,
/// 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 verify window's n tokens routed at once (see layer.cpp); bitwise per token what `moe_route` gives.
+bool moe_route_window(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t k, const MoEBuffers& b,
+ const float* x, float* logits, int32_t* ids, float* weights, int n, void* stream, std::string& err);
/// The finishing half: the shared expert and the combination.
///
diff --git a/include/strata/core/mtp.hpp b/include/strata/core/mtp.hpp
index e2084f6d28..bfb6ee0f57 100644
--- a/include/strata/core/mtp.hpp
+++ b/include/strata/core/mtp.hpp
@@ -82,6 +82,7 @@ class MtpDrafter {
/// Coupled mode with penalties: the history the next draft() chain starts from - what the session holds after the
/// window's commit (`tail`) and the window's pick at its last accepted row (`next`, the next window's row 0).
void set_draft_history(const int32_t* tail, int64_t n_tail, int32_t next);
+ void set_ple_session(SessionState* s) { ple_ss_ = s; }
bool coupled() const { return coupled_active_; }
double ms_draft = 0, ms_prefill = 0;
@@ -124,6 +125,7 @@ class MtpDrafter {
const ModelGeometry* g_ = nullptr;
SessionState* ss_ = nullptr;
+ SessionState* ple_ss_ = nullptr;
const WeightTable* wt_ = nullptr;
const NativeHead* head_ = nullptr;
const float* window_R_ = nullptr;
diff --git a/include/strata/core/native_dense.hpp b/include/strata/core/native_dense.hpp
index 8330fac55b..2d3cb94c68 100644
--- a/include/strata/core/native_dense.hpp
+++ b/include/strata/core/native_dense.hpp
@@ -19,12 +19,17 @@ class NativeDense {
~NativeDense();
NativeDense(const NativeDense&) = delete;
NativeDense& operator=(const NativeDense&) = delete;
+ /// With layer_hi >= 0, only the `blk..` matrices with layer_lo <= l < layer_hi are uploaded (a layer
+ /// split's stage holds its own layers' projections, not the whole model's); the others keep data == nullptr.
bool load(const std::vector& shards, WeightTable& table, std::string& err,
- bool include_ple_key = false);
+ bool include_ple_key = false, int64_t layer_lo = 0, int64_t layer_hi = -1);
/// Plan v0.3 P1: the canonical tensor names `load` would serve natively from these shards (eligible name,
/// supported type, 2-D), read from the GGUF headers only - so the canonical arena can skip them.
static bool served_names(const std::vector& shards, bool include_ple_key,
std::set& out, std::string& err);
+ /// Layer split: load only blocks [lb, le) (every other `blk.N.` projection belongs to another GPU's stage; the
+ /// PLE tensors are loaded everywhere). Process-wide, read by the next `load`; (-1, -1) = all layers.
+ static void set_layer_range(int lb, int le);
/// #326: a native pack whose `blk.1.ple_key.weight` row is unquantized (iq_pack --compat-bf16 of a GGUF key
/// the native kernel also reads, e.g. OrcaRouter's IQ3_XXS) serves the PLE from that row, so it is taken out
/// of `skip` and `load` does not upload the GGUF key over it. A quantized row leaves `skip` unchanged.
diff --git a/include/strata/core/pinned.hpp b/include/strata/core/pinned.hpp
index 44cf86c1e0..7d1f380c54 100644
--- a/include/strata/core/pinned.hpp
+++ b/include/strata/core/pinned.hpp
@@ -125,8 +125,11 @@ LoadStats load_experts_direct(const std::string& path, uint8_t* dst, const std::
/// STRATA_UNBUFFERED_LOAD=1 / 0 forces it. `why` says what decided.
/// `cache_counts` false (the file tier with a RAM budget): only whether the files could be kept decides - their mapped
/// pages land in the process's working set, so a partly cached file would still take the RAM the budget was sized for.
+/// `read_bytes` (#577): the bytes the files are read for, when that is less than the files (the file tier reads only
+/// the experts outside its RAM copy); `kAllFileBytes` = every byte of `files`. See platform::file_cache_keeps.
+constexpr uint64_t kAllFileBytes = ~0ull;
bool experts_unbuffered(const std::vector& files, uint64_t arena_bytes, std::string& why,
- bool cache_counts = true);
+ bool cache_counts = true, uint64_t read_bytes = kAllFileBytes);
// 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.
diff --git a/include/strata/core/remote_expert_opt.hpp b/include/strata/core/remote_expert_opt.hpp
new file mode 100644
index 0000000000..5eb488c734
--- /dev/null
+++ b/include/strata/core/remote_expert_opt.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace strata::core {
+class RemoteExperts;
+class ExpertSource;
+
+// Remote Expert Decode Optimization v1 on existing helper caches. One instance per serial engine.
+class RemoteExpertOpt {
+public:
+ RemoteExpertOpt() = default;
+ ~RemoteExpertOpt();
+ RemoteExpertOpt(const RemoteExpertOpt&) = delete;
+ RemoteExpertOpt& operator=(const RemoteExpertOpt&) = delete;
+
+ void attach(RemoteExperts& remote); // before RemoteExperts::open
+ bool init(std::string& err); // after all helper caches are open
+ bool owns(int64_t layer, int32_t expert) const;
+ bool adapt(const std::vector& usage, const std::vector& primary,
+ const std::vector>& pending, int max_swaps,
+ ExpertSource& source);
+
+ // Bracket the existing pool callback; its signature and dispatch remain unchanged.
+ void begin(const float* weights, int token_begin, int tokens);
+ void end() { weights_ = nullptr; }
+ void copy_rows(float* dst, const float* src, int token_begin, int tokens,
+ const int32_t* hit_rows, const int32_t* count, void* stream) const;
+ void combine(float* dst, float* scratch, int token_begin, int tokens,
+ const uint32_t* skip, uint32_t ring, void* stream) const;
+
+private:
+ friend class RemoteExperts;
+ static size_t metadata_bytes();
+ bool active() const { return weights_ != nullptr; }
+ void prepare(const RemoteExperts& remote, void* metadata) const;
+ bool reduce(RemoteExperts& remote, const void* metadata, std::string& err);
+ void accumulate(const RemoteExperts& remote);
+
+ struct Peer { RemoteExperts* remote; float* sum = nullptr; };
+ std::vector peers_;
+ float* h_sum_ = nullptr;
+ float* m_sum_ = nullptr;
+ int32_t* h_mask_ = nullptr;
+ int32_t* m_mask_ = nullptr;
+ const float* weights_ = nullptr;
+ int token_begin_ = 0, tokens_ = 0;
+};
+} // namespace strata::core
diff --git a/include/strata/core/remote_experts.hpp b/include/strata/core/remote_experts.hpp
index 805bc950f8..224d24f5f0 100644
--- a/include/strata/core/remote_experts.hpp
+++ b/include/strata/core/remote_experts.hpp
@@ -11,6 +11,7 @@
#include
namespace strata::core {
+class RemoteExpertOpt;
/// A static, profile-filled expert tier on another CUDA device. CUDA0 keeps all
/// dense weights and state; results return through the existing pinned CPU rows.
@@ -27,7 +28,7 @@ class RemoteExperts {
bool open(int device, int slots, int64_t layers, int64_t experts,
const std::vector>& ranked,
const ExpertCache& primary, ExpertSource& source,
- std::vector& claimed, std::string& err);
+ std::vector& claimed, std::string& err, bool auto_size = false);
void close();
/// `kind` is the primary verifier's classification (-1 = CPU candidate),
@@ -36,6 +37,7 @@ class RemoteExperts {
int64_t k, const int32_t* kind, const int32_t* primary_res,
std::string& err);
bool owns(int64_t index) const { return owned_[(size_t) index] != 0; }
+ bool optimized_decode() const { return remote_opt_ != nullptr; }
bool finish(float* out, std::string& err);
int64_t resident() const { return cache_.resident(); }
int64_t computed() const { return computed_; }
@@ -48,6 +50,8 @@ class RemoteExperts {
double ms_wait() const { return ms_wait_; }
private:
+ friend class RemoteExpertOpt;
+ RemoteExpertOpt* remote_opt_ = nullptr;
int device_ = -1;
int64_t n_expert_ = 0;
int32_t groups_ = 0;
diff --git a/include/strata/core/session.hpp b/include/strata/core/session.hpp
index 6d1becfac4..4d633205ea 100644
--- a/include/strata/core/session.hpp
+++ b/include/strata/core/session.hpp
@@ -16,6 +16,7 @@
#include "strata/core/hit_hook.hpp"
#include "strata/core/layer.hpp"
+#include "strata/kernels/cpu/pool.hpp"
#include
@@ -369,7 +370,7 @@ 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
+ strata::kernels::cpu::ThreadAffinity pinned_core{}; ///< affinity to restore, or invalid if the host was not pinned
bool pinned = false;
/// Allocates the buffers and pins the host thread. Call ONCE, at session setup.
diff --git a/include/strata/core/verify.hpp b/include/strata/core/verify.hpp
index b80d10bd26..e68f7b3a4b 100644
--- a/include/strata/core/verify.hpp
+++ b/include/strata/core/verify.hpp
@@ -31,6 +31,8 @@
#include
#include
+#include
+#include " used to hand the model a real end-of-reasoning token. Before the template is rendered
+# they are swapped for these private-use characters, and the server encodes the spans they mark as ordinary text.
+THINK_TAGS = {"": "\U000F0E01", "": "\U000F0E02"}
+THINK_MARKS = {v: k for k, v in THINK_TAGS.items()}
+
+
+def _mark(text: str) -> str:
+ for tag, mark in THINK_TAGS.items():
+ text = text.replace(tag, mark)
+ return text
+
+
+def _mark_deep(v):
+ if isinstance(v, str):
+ return _mark(v)
+ if isinstance(v, dict):
+ return {k: _mark_deep(x) for k, x in v.items()}
+ if isinstance(v, list):
+ return [_mark_deep(x) for x in v]
+ return v
+
+
+def _has_tag(v) -> bool:
+ if isinstance(v, str):
+ return "" in v or "" in v
+ if isinstance(v, dict):
+ return any(_has_tag(x) for x in v.values())
+ if isinstance(v, list):
+ return any(_has_tag(x) for x in v)
+ return False
+
+
+def mark_think_literals(messages: list[dict], tools: list[dict] | None):
+ """#537: (messages, tools) with every literal / in their text swapped for THINK_TAGS' marks, and
+ whether there was one (None: no change, the same objects back - a prompt without them renders as it always did).
+ An assistant message whose content opens with a whole ... block (clients that send the reasoning
+ inline) keeps that one block as the model's markers, as before."""
+ if not _has_tag(messages) and not _has_tag(tools):
+ return messages, tools, False
+ out = []
+ for m in messages:
+ m = dict(m)
+ content = m.get("content")
+ for k, v in m.items():
+ if k != "role":
+ m[k] = _mark_deep(v)
+ if m.get("role") == "assistant" and isinstance(content, str) and content.lstrip().startswith("") \
+ and "" in content:
+ i, j = content.index("") + len(""), content.index("")
+ m["content"] = content[:i] + _mark(content[i:j]) + "" + _mark(content[j + len(""):])
+ out.append(m)
+ return out, _mark_deep(tools), True
+
+
+_THINK_MARK_RE = re.compile("|".join(THINK_MARKS))
+
+
+def unmark_think_literals(prompt: str) -> tuple[str, list[tuple[int, int]]]:
+ """The rendered prompt with THINK_TAGS' marks turned back into the tags' text, and the (start, end) spans of those
+ tags in it: the server encodes them as ordinary text (the tokenizer's encode_plain_spans)."""
+ out, spans, pos, n = [], [], 0, 0
+ for m in _THINK_MARK_RE.finditer(prompt):
+ out.append(prompt[pos:m.start()])
+ n += m.start() - pos
+ tag = THINK_MARKS[m.group(0)]
+ out.append(tag)
+ spans.append((n, n + len(tag)))
+ n += len(tag)
+ pos = m.end()
+ out.append(prompt[pos:])
+ return "".join(out), spans
+
+
def _late_system_to_user(messages: list[dict]) -> list[dict]:
"""The chat template takes a system message only at the start ("System message must be at the beginning").
Clients also send them mid-conversation - Claude Code's hook context as {"role": "system"} after the first user
@@ -160,6 +236,27 @@ def _object_list(value, name: str) -> list[dict]:
return value
+def _tool_list(value, wrapper: str | None) -> list[dict]:
+ """#592: a request's "tools" as a list of tool objects, each with a name - in the OpenAI shape
+ {"type": "function", "function": {"name": ...}} (`wrapper` "function"; a bare {"name": ...} is still taken), or
+ in the Anthropic shape {"name": ...} (`wrapper` None). A value that is not (a string such as "auto", a list of
+ names, an object without a name) is a ValueError - a 400 naming the field - where it used to take the request
+ thread down with no reply at all. No value (or an empty one) is no tools, as always."""
+ if not value:
+ return []
+ shape = ('{"type": "function", "function": {"name": ..., "parameters": {...}}}' if wrapper else
+ '{"name": ..., "input_schema": {...}}')
+ try:
+ tools = _object_list(value, "tools")
+ except ValueError:
+ raise ValueError(f"tools must be a list of tool objects, each {shape}") from None
+ for i, t in enumerate(tools):
+ fn = t.get(wrapper, t) if wrapper and t.get("type") == wrapper else t
+ if not isinstance(fn, dict) or not isinstance(fn.get("name"), str) or not fn["name"]:
+ raise ValueError(f"tools[{i}] has no name: each tool must be {shape}")
+ return tools
+
+
def openai_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]:
"""OpenAI Chat Completions -> (template messages, template tools, template kwargs)."""
messages = []
@@ -182,10 +279,10 @@ def openai_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]:
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
+ tools = [t.get("function", t) if t.get("type") == "function" else t
+ for t in _tool_list(req.get("tools"), "function")] or None
- # OpenAI tool_choice compatibility. Normalize the API-specific field before rendering the chat template.
+ # OpenAI tool_choice compatibility. Normalize the API-specific field before rendering the chat template.
choice = req.get("tool_choice")
forced = None
if choice is None or choice == "auto":
@@ -210,7 +307,6 @@ def openai_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]:
raise ValueError("tool_choice must be 'auto', 'none', 'required', or a function selection")
if forced:
messages.insert(0, {"role": "system", "content": forced})
-
kwargs = {}
# OpenAI Chat Completions: "reasoning_effort"; Responses style: "reasoning": {"effort": ...}
reasoning = req.get("reasoning") if isinstance(req.get("reasoning"), dict) else {}
@@ -260,7 +356,7 @@ def anthropic_to_messages(req: dict, think_unasked: bool = True) -> tuple[list[d
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
+ for t in _tool_list(req.get("tools"), None)] or None
kwargs = {}
# Anthropic: "thinking": {"type": "disabled"} or {"type": "enabled", "budget_tokens": N};
# "output_config": {"effort": "low" | "medium" | "high"}
diff --git a/serve/multigpu_server.py b/serve/multigpu_server.py
index 2664a36aaa..35398e936f 100644
--- a/serve/multigpu_server.py
+++ b/serve/multigpu_server.py
@@ -42,7 +42,7 @@
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade",
}
-GENERATE_PATHS = {"/v1/chat/completions", "/v1/messages"}
+GENERATE_PATHS = {"/v1/chat/completions", "/v1/messages", "/v1/responses"}
VISION_METADATA_PATHS = {"/health", "/props", "/models", "/v1/models"}
AFFINITY_HEADERS = ("x-strata-session-id", "x-conversation-id", "x-session-id", "x-thread-id")
AFFINITY_FIELDS = ("conversation_id", "session_id", "thread_id")
@@ -94,6 +94,7 @@ def sanitize_lane_config(cfg: dict, *, allow_profile_persistence: bool = False,
lane_cfg = copy.deepcopy(cfg)
lane_cfg.pop("gpu", None)
lane_cfg.pop("layer_split", None)
+ lane_cfg.pop("parallel", None)
if not allow_profile_persistence:
# server.py can synthesize the CLI writer flags from these top-level
# keys, so stripping only cfg["args"] would still leave several lanes
@@ -118,9 +119,13 @@ def sanitize_lane_config(cfg: dict, *, allow_profile_persistence: bool = False,
"--expert-cache-device2",
"--expert-cache-device3",
"--expert-cache-remote-placement",
+ "--batch",
+ "--slots",
+ "--batch-groups",
):
args = remove_option(args, name)
args = remove_flag(args, "--split-skip-if-fits")
+ args = remove_flag(args, "--trim-stage-weights")
# Upstream 0.1.36 can persist an adaptive expert profile. A copied
# parent path would give several lane processes the same writer, so
diff --git a/serve/responses.py b/serve/responses.py
new file mode 100644
index 0000000000..4a017c38d8
--- /dev/null
+++ b/serve/responses.py
@@ -0,0 +1,538 @@
+"""serve/responses.py - #451: OpenAI's Responses API (POST /v1/responses), stateless, on the chat path.
+
+A Responses request becomes the same template messages, tools and kwargs a Chat Completions request does, runs through
+the same Service.run, and its events (reasoning, text, tool calls) come back as Responses output items and typed SSE
+events. Nothing is stored: the client sends the whole conversation in `input` every time (`store: false`, as Codex CLI
+does), including the reasoning, message and function_call items of earlier answers.
+
+Codex CLI is the first client. What it needs, and what this does:
+ * `instructions` and `developer` messages -> the system message (leading ones merged, so the prompt start is stable
+ and the conversation cache holds across turns);
+ * function tools, also inside `namespace` tools (Codex's MCP and agent tools: the model sees `namespace.name`) and
+ `custom` tools (a free-form `input` string); hosted tools (web search, ...) are left out, the model cannot run them;
+ * reasoning items: the model's thinking is returned as `reasoning_text` content (no summaries: this model does not
+ write them, see docs/DETAILS.md) and, when `include` asks for `reasoning.encrypted_content`, also as an opaque
+ `encrypted_content` string (base64, not encrypted: the client already holds the text). Either form sent back is
+ put into the conversation again, so the prompt matches what the model wrote and the cache is reused;
+ * the official event order: response.created, response.in_progress, response.output_item.added, deltas,
+ ...done events, response.output_item.done, and response.completed / response.incomplete / response.failed.
+
+Based on #630 by CC-David-CC (the item/event assembler, namespace tools, call_id ordering of tool results).
+"""
+from __future__ import annotations
+
+import base64
+import copy
+import json
+import time
+import uuid
+
+from serve.frontend import Event, _late_system_to_user, _parts_of, effort_kwargs
+
+ENCRYPTED_PREFIX = "strata.r1:" # our own reasoning replay strings; others' are ignored
+HOSTED_TOOLS = ("web_search", "web_search_preview", "file_search", "computer_use_preview", "computer_use",
+ "code_interpreter", "image_generation", "local_shell", "mcp", "tool_search")
+
+
+class ResponsesError(ValueError):
+ """A request error in the Responses format: {"error": {"message", "type", "param", "code"}}."""
+
+ def __init__(self, message, param=None, code=None, status=400, kind="invalid_request_error"):
+ super().__init__(message)
+ self.param, self.code, self.status, self.kind = param, code, status, kind
+
+ def body(self):
+ return error_body(str(self), self.kind, self.param, self.code)
+
+
+def error_body(message, kind="invalid_request_error", param=None, code=None) -> dict:
+ return {"error": {"message": message, "type": kind, "param": param, "code": code}}
+
+
+def new_id(prefix: str) -> str:
+ return f"{prefix}_{uuid.uuid4().hex}"
+
+
+# ------------------------------------------------------------------------------------------------ request -> chat
+def _content(content, param):
+ """A message's (or a tool output's) content -> the template's form: a string, or text and image parts."""
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content
+ if not isinstance(content, list):
+ raise ResponsesError("expected a string or an array of content parts", param)
+ parts = []
+ for j, part in enumerate(content):
+ if isinstance(part, str):
+ parts.append({"type": "text", "text": part})
+ continue
+ if not isinstance(part, dict):
+ raise ResponsesError("expected a content part object", f"{param}[{j}]")
+ kind = part.get("type")
+ if kind in ("input_text", "output_text", "text", "summary_text", "reasoning_text"):
+ parts.append({"type": "text", "text": part.get("text") or ""})
+ elif kind == "refusal":
+ parts.append({"type": "text", "text": part.get("refusal") or ""})
+ elif kind in ("input_image", "image_url"):
+ if not part.get("image_url"):
+ raise ResponsesError("only images given as image_url (a data: or http(s) URL) are supported; "
+ "this server keeps no files", f"{param}[{j}]", "unsupported_parameter")
+ parts.append({"type": "input_image", "image_url": part["image_url"]})
+ else:
+ raise ResponsesError(f"content parts of type {kind!r} are not supported (text and images are)",
+ f"{param}[{j}].type", "unsupported_parameter")
+ return _parts_of(parts)
+
+
+def _reasoning_text(item, param) -> str:
+ """The thinking a reasoning item carries: its reasoning_text content, else our own encrypted_content. A summary
+ alone, or another server's encrypted_content, carries none that can go back into the prompt."""
+ texts = [p.get("text") or "" for p in item.get("content") or [] if isinstance(p, dict)
+ and p.get("type") in ("reasoning_text", "text")]
+ if texts:
+ return "".join(texts)
+ enc = item.get("encrypted_content")
+ if isinstance(enc, str) and enc.startswith(ENCRYPTED_PREFIX):
+ try:
+ return json.loads(base64.b64decode(enc[len(ENCRYPTED_PREFIX):], validate=True).decode("utf-8"))["text"]
+ except (ValueError, KeyError, TypeError, UnicodeError):
+ raise ResponsesError("this reasoning item's encrypted_content is damaged", param + ".encrypted_content",
+ "invalid_encrypted_content") from None
+ return ""
+
+
+def _arguments(raw, param) -> dict:
+ """A function_call's arguments (a JSON string) -> the mapping the template renders. Arguments that are not a JSON
+ object (a call the output ended inside) are kept as text rather than refusing the whole conversation."""
+ if isinstance(raw, dict):
+ return raw
+ if raw is None or (isinstance(raw, str) and not raw.strip()):
+ return {}
+ if not isinstance(raw, str):
+ raise ResponsesError("function_call arguments must be a JSON string", param)
+ try:
+ value = json.loads(raw)
+ except ValueError:
+ return {"arguments": raw}
+ return value if isinstance(value, dict) else {"arguments": value}
+
+
+def _tool_output(item, param):
+ out = item.get("output")
+ if isinstance(out, dict): # some clients send a JSON result as an object
+ return json.dumps(out, ensure_ascii=False)
+ return out if isinstance(out, str) else _content(out, param + ".output")
+
+
+def input_messages(req: dict) -> list[dict]:
+ """`instructions` + `input` -> the chat template's messages."""
+ messages = []
+ instructions = req.get("instructions")
+ if instructions not in (None, ""):
+ messages.append({"role": "system", "content": _content(instructions, "instructions")
+ if not isinstance(instructions, str) else instructions})
+ items = req.get("input")
+ if items is None:
+ raise ResponsesError("input is required: a string or an array of input items", "input", "missing_required_parameter")
+ if isinstance(items, str):
+ items = [{"type": "message", "role": "user", "content": items}]
+ if not isinstance(items, list):
+ raise ResponsesError("input must be a string or an array of input items", "input")
+ thinking = [] # reasoning waiting for the assistant turn it belongs to
+ open_turn = None # the assistant message the next function_call joins
+
+ def turn(content=""):
+ nonlocal open_turn
+ open_turn = {"role": "assistant", "content": content}
+ if thinking:
+ open_turn["reasoning_content"] = "".join(thinking)
+ thinking.clear()
+ messages.append(open_turn)
+ return open_turn
+
+ for i, item in enumerate(items):
+ param = f"input[{i}]"
+ if not isinstance(item, dict):
+ raise ResponsesError("expected an input item object", param)
+ kind = item.get("type") or ("message" if "role" in item else None)
+ if kind == "message":
+ role = item.get("role")
+ if role not in ("user", "assistant", "system", "developer"):
+ raise ResponsesError(f"unknown message role {role!r}", param + ".role")
+ content = _content(item.get("content"), param + ".content")
+ if role == "assistant":
+ turn(content if isinstance(content, str) else "".join(
+ p.get("text", "") for p in content if p.get("type") == "text"))
+ else:
+ open_turn = None
+ thinking.clear()
+ messages.append({"role": "system" if role == "developer" else role, "content": content})
+ elif kind == "reasoning":
+ text = _reasoning_text(item, param)
+ if text:
+ if open_turn is not None and (open_turn.get("tool_calls") or open_turn["content"]):
+ open_turn = None # thinking starts a new model turn
+ thinking.append(text)
+ elif kind in ("function_call", "custom_tool_call"):
+ name = item.get("name")
+ if not isinstance(name, str) or not name:
+ raise ResponsesError("a function_call needs a name", param + ".name")
+ if item.get("namespace"):
+ name = f"{item['namespace']}.{name}"
+ if kind == "function_call":
+ args = _arguments(item.get("arguments"), param + ".arguments")
+ else:
+ args = {"input": item.get("input") if isinstance(item.get("input"), str) else ""}
+ target = open_turn if open_turn is not None and not thinking else turn()
+ target.setdefault("tool_calls", []).append({"function": {"name": name, "arguments": args},
+ "_call_id": item.get("call_id")})
+ elif kind in ("function_call_output", "custom_tool_call_output"):
+ messages.append({"role": "tool", "content": _tool_output(item, param), "_call_id": item.get("call_id")})
+ open_turn = None
+ thinking.clear()
+ elif kind == "item_reference":
+ raise ResponsesError("item references need stored responses, and this server keeps none: send the items "
+ "themselves", param, "unsupported_parameter")
+ else:
+ raise ResponsesError(f"input items of type {kind!r} are not supported", param + ".type",
+ "unsupported_parameter")
+ _order_tool_results(messages)
+ # leading system/developer messages (Codex: instructions, then its developer message) become one system message
+ # at the start; later ones become user messages in place, as on the chat path
+ lead = 0
+ while lead < len(messages) and messages[lead]["role"] == "system" and isinstance(messages[lead]["content"], str):
+ lead += 1
+ if lead > 1:
+ messages[:lead] = [{"role": "system", "content": "\n\n".join(m["content"] for m in messages[:lead])}]
+ return _late_system_to_user(messages)
+
+
+def _order_tool_results(messages):
+ """The template places tool results by position: each run of tool messages is put in the order of the calls of
+ the assistant turn before it (a client may send the results in the order they finished), then the private call
+ ids are dropped."""
+ i = 0
+ while i < len(messages):
+ if messages[i]["role"] != "tool":
+ i += 1
+ continue
+ j = i
+ while j < len(messages) and messages[j]["role"] == "tool":
+ j += 1
+ prev = messages[i - 1] if i else {}
+ order = [c.get("_call_id") for c in prev.get("tool_calls") or []]
+ rank = {cid: k for k, cid in enumerate(order) if cid}
+ messages[i:j] = sorted(messages[i:j], key=lambda m: rank.get(m.get("_call_id"), len(order)))
+ i = j
+ for m in messages:
+ m.pop("_call_id", None)
+ for c in m.get("tool_calls") or []:
+ c.pop("_call_id", None)
+
+
+def request_tools(req: dict):
+ """-> (template tools or None, {template name: (namespace, name, kind)}, names of hosted tools left out)."""
+ tools, names, skipped = [], {}, []
+ given = req.get("tools") or []
+ if not isinstance(given, list):
+ raise ResponsesError("tools must be an array", "tools")
+
+ def add(tool, param, namespace=None, ns_description=""):
+ kind = tool.get("type")
+ name = tool.get("name")
+ if not isinstance(name, str) or not name:
+ raise ResponsesError("a tool needs a name", param + ".name")
+ flat = f"{namespace}.{name}" if namespace else name
+ description = tool.get("description") or ""
+ if ns_description:
+ description = ns_description + ("\n\n" + description if description else "")
+ if kind == "function":
+ params = tool.get("parameters") or {"type": "object", "properties": {}}
+ else: # custom: free-form text, maybe in a grammar the model reads
+ fmt = tool.get("format") or {}
+ if isinstance(fmt, dict) and fmt.get("type") == "grammar" and fmt.get("definition"):
+ description += f"\n\nThe input must follow this {fmt.get('syntax', '')} grammar:\n{fmt['definition']}"
+ params = {"type": "object", "properties": {"input": {"type": "string", "description": "the raw input"}},
+ "required": ["input"]}
+ tools.append({"name": flat, "description": description, "parameters": params})
+ names[flat] = (namespace, name, kind)
+
+ for i, tool in enumerate(given):
+ param = f"tools[{i}]"
+ if not isinstance(tool, dict):
+ raise ResponsesError("expected a tool object", param)
+ kind = tool.get("type")
+ if kind in ("function", "custom"):
+ add(tool, param)
+ elif kind == "namespace":
+ for j, member in enumerate(tool.get("tools") or []):
+ if not isinstance(member, dict) or member.get("type", "function") not in ("function", "custom"):
+ raise ResponsesError("a namespace holds function tools", f"{param}.tools[{j}]")
+ add({"type": "function", **member}, f"{param}.tools[{j}]", tool.get("name"),
+ tool.get("description") or "")
+ elif kind in HOSTED_TOOLS or isinstance(kind, str):
+ skipped.append(kind) # the model cannot run OpenAI's hosted tools: left out
+ else:
+ raise ResponsesError("a tool needs a type", param + ".type")
+ choice = req.get("tool_choice")
+ if choice == "none":
+ return None, names, skipped
+ return tools or None, names, skipped
+
+
+def text_format(req: dict):
+ """`text.format` -> the chat path's response_format (None: plain text)."""
+ text = req.get("text")
+ if text is None:
+ return None
+ if not isinstance(text, dict):
+ raise ResponsesError("text must be an object", "text")
+ fmt = text.get("format")
+ if fmt is None:
+ return None
+ if not isinstance(fmt, dict):
+ raise ResponsesError("text.format must be an object", "text.format")
+ kind = fmt.get("type")
+ if kind in (None, "text"):
+ return None
+ if kind == "json_object":
+ return {"type": "json_object"}
+ if kind == "json_schema":
+ spec = {k: fmt[k] for k in ("name", "schema", "strict", "description") if k in fmt}
+ return {"type": "json_schema", "json_schema": spec}
+ raise ResponsesError("text.format.type must be text, json_object or json_schema", "text.format.type")
+
+
+def check_request(req: dict) -> None:
+ """What this stateless server cannot do, refused before anything runs."""
+ if req.get("previous_response_id"):
+ raise ResponsesError("this server keeps no responses (stateless): send the whole conversation in input "
+ "instead of previous_response_id", "previous_response_id", "unsupported_parameter")
+ if req.get("conversation"):
+ raise ResponsesError("this server keeps no conversations (stateless): send the whole conversation in input",
+ "conversation", "unsupported_parameter")
+ if req.get("background"):
+ raise ResponsesError("background responses are not supported: use stream or wait for the answer",
+ "background", "unsupported_parameter")
+ if req.get("prompt"):
+ raise ResponsesError("stored prompt templates are not supported: send instructions and input",
+ "prompt", "unsupported_parameter")
+ n = req.get("max_output_tokens")
+ if n is not None and (isinstance(n, bool) or not isinstance(n, int) or n < 1):
+ raise ResponsesError("max_output_tokens must be a positive whole number", "max_output_tokens")
+ reasoning = req.get("reasoning")
+ if reasoning is not None and not isinstance(reasoning, dict):
+ raise ResponsesError("reasoning must be an object", "reasoning")
+
+
+def template_kwargs(req: dict, shared: dict) -> dict:
+ effort = (req.get("reasoning") or {}).get("effort")
+ if effort is None:
+ effort = shared.get("reasoning_effort") # the Chat settings shared with apps, as on the chat path
+ try:
+ return effort_kwargs(effort)
+ except ValueError as e:
+ raise ResponsesError(str(e), "reasoning.effort") from None
+
+
+# ------------------------------------------------------------------------------------------------ output
+class Assembler:
+ """One response: its output items and its typed events, built from Service.run's events. The non-streaming
+ answer is the `response` of the last event, so both modes are the same items."""
+
+ def __init__(self, req: dict, model: str, prompt_tokens: int, names: dict, encrypted: bool,
+ json_mode: bool = False):
+ self.names, self.encrypted, self.json_mode = names, encrypted, json_mode
+ self.prompt_tokens = prompt_tokens
+ self.seq = 0
+ self.item = None # the open output item
+ self.json_text = [] # structured output: the answer, held until it is checked
+ reasoning = req.get("reasoning") if isinstance(req.get("reasoning"), dict) else {}
+ self.response = {
+ "id": new_id("resp"), "object": "response", "created_at": int(time.time()), "status": "in_progress",
+ "background": False, "error": None, "incomplete_details": None,
+ "instructions": req.get("instructions"), "max_output_tokens": req.get("max_output_tokens"),
+ "model": model, "output": [], "parallel_tool_calls": req.get("parallel_tool_calls", True),
+ "previous_response_id": None,
+ "reasoning": {"effort": reasoning.get("effort"), "summary": reasoning.get("summary")},
+ "store": False, "temperature": req.get("temperature"),
+ "text": req.get("text") if isinstance(req.get("text"), dict) else {"format": {"type": "text"}},
+ "tool_choice": req.get("tool_choice", "auto"), "tools": req.get("tools") or [],
+ "top_p": req.get("top_p"), "truncation": "disabled", "usage": None, "user": None,
+ "metadata": req.get("metadata") or {}}
+
+ # --- events
+ def event(self, kind, **data) -> dict:
+ e = {"type": kind, "sequence_number": self.seq, **data}
+ self.seq += 1
+ return e
+
+ def snapshot(self) -> dict:
+ return copy.deepcopy(self.response)
+
+ def start(self) -> list[dict]:
+ return [self.event("response.created", response=self.snapshot()),
+ self.event("response.in_progress", response=self.snapshot())]
+
+ def in_progress(self) -> dict:
+ """A keep-alive a client counts as activity (SSE comments are not events: Codex's idle timer ignores them)."""
+ return self.event("response.in_progress", response=self.snapshot())
+
+ def _open(self, item) -> list[dict]:
+ self.response["output"].append(item)
+ self.item = item
+ out = [self.event("response.output_item.added", output_index=self.index, item=copy.deepcopy(item))]
+ if item["type"] == "message":
+ part = {"type": "output_text", "text": "", "annotations": [], "logprobs": []}
+ item["content"].append(part)
+ out.append(self.event("response.content_part.added", item_id=item["id"], output_index=self.index,
+ content_index=0, part=copy.deepcopy(part)))
+ return out
+
+ @property
+ def index(self) -> int:
+ return len(self.response["output"]) - 1
+
+ def close(self, status="completed") -> list[dict]:
+ item, self.item = self.item, None
+ if item is None:
+ return []
+ at, out = self.response["output"].index(item), []
+ item["status"] = status
+ if item["type"] == "message":
+ part = item["content"][0]
+ out += [self.event("response.output_text.done", item_id=item["id"], output_index=at, content_index=0,
+ text=part["text"], logprobs=[]),
+ self.event("response.content_part.done", item_id=item["id"], output_index=at, content_index=0,
+ part=copy.deepcopy(part))]
+ elif item["type"] == "reasoning":
+ text = item["content"][0]["text"]
+ out.append(self.event("response.reasoning_text.done", item_id=item["id"], output_index=at,
+ content_index=0, text=text))
+ if self.encrypted:
+ item["encrypted_content"] = ENCRYPTED_PREFIX + base64.b64encode(
+ json.dumps({"text": text}, ensure_ascii=False).encode("utf-8")).decode("ascii")
+ elif item["type"] == "function_call":
+ out.append(self.event("response.function_call_arguments.done", item_id=item["id"], output_index=at,
+ name=item["name"], arguments=item["arguments"]))
+ elif item["type"] == "custom_tool_call":
+ out.append(self.event("response.custom_tool_call_input.done", item_id=item["id"], output_index=at,
+ input=item["input"]))
+ out.append(self.event("response.output_item.done", output_index=at, item=copy.deepcopy(item)))
+ return out
+
+ def feed(self, ev: Event) -> list[dict]:
+ out = []
+ kind = ev.kind
+ if kind == "reasoning":
+ if not ev.text:
+ return out
+ if self.item is None or self.item["type"] != "reasoning":
+ out += self.close()
+ out += self._open({"id": new_id("rs"), "type": "reasoning", "status": "in_progress", "summary": [],
+ "content": [{"type": "reasoning_text", "text": ""}]})
+ self.item["content"][0]["text"] += ev.text
+ out.append(self.event("response.reasoning_text.delta", item_id=self.item["id"], output_index=self.index,
+ content_index=0, delta=ev.text))
+ elif kind == "content":
+ if not ev.text:
+ return out
+ if self.json_mode: # sent once it is checked (finish)
+ out += self.close()
+ self.json_text.append(ev.text)
+ return out
+ if self.item is None or self.item["type"] != "message":
+ out += self.close()
+ out += self._open({"id": new_id("msg"), "type": "message", "status": "in_progress",
+ "role": "assistant", "content": []})
+ self.item["content"][0]["text"] += ev.text
+ out.append(self.event("response.output_text.delta", item_id=self.item["id"], output_index=self.index,
+ content_index=0, delta=ev.text, logprobs=[]))
+ elif kind == "tool_start":
+ out += self.close()
+ out += self._open(self._call_item(ev))
+ elif kind == "tool_args":
+ item = self.item
+ if item is None or item.get("call_id") != ev.call.id:
+ return out
+ if item["type"] == "function_call": # a custom tool's input is sent whole, at its end
+ item["arguments"] += ev.text
+ out.append(self.event("response.function_call_arguments.delta", item_id=item["id"],
+ output_index=self.index, delta=ev.text))
+ elif kind == "tool_call":
+ item = self.item
+ if item is None or item.get("call_id") != ev.call.id: # a call that was not announced while written
+ out += self.close()
+ out += self._open(self._call_item(ev))
+ item = self.item
+ if item["type"] == "function_call":
+ item["arguments"] = json.dumps(ev.call.arguments, ensure_ascii=False)
+ out.append(self.event("response.function_call_arguments.delta", item_id=item["id"],
+ output_index=self.index, delta=item["arguments"]))
+ if item["type"] == "custom_tool_call":
+ value = ev.call.arguments.get("input") if isinstance(ev.call.arguments, dict) else None
+ item["input"] = value if isinstance(value, str) else json.dumps(ev.call.arguments, ensure_ascii=False)
+ out.append(self.event("response.custom_tool_call_input.delta", item_id=item["id"],
+ output_index=self.index, delta=item["input"]))
+ out += self.close()
+ return out
+
+ def _call_item(self, ev: Event) -> dict:
+ namespace, name, kind = self.names.get(ev.call.name, (None, ev.call.name, "function"))
+ if kind == "custom":
+ item = {"id": new_id("ctc"), "type": "custom_tool_call", "status": "in_progress", "call_id": ev.call.id,
+ "name": name, "input": ""}
+ else:
+ item = {"id": new_id("fc"), "type": "function_call", "status": "in_progress", "call_id": ev.call.id,
+ "name": name, "arguments": ""}
+ if namespace:
+ item["namespace"] = namespace
+ return item
+
+ def finish(self, done: dict, validate=None) -> list[dict]:
+ """The end: close what is open, then response.completed (or .incomplete when max_output_tokens ran out).
+ `validate(text, finish)` checks a structured answer and returns its text (or raises)."""
+ length = done.get("finish") == "length"
+ # a call still open was cut off (its tool_call never came, #211): incomplete, whatever the finish
+ unfinished = self.item is not None and self.item["type"] in ("function_call", "custom_tool_call")
+ out = self.close("incomplete" if length or unfinished else "completed")
+ if self.json_mode:
+ text = validate("".join(self.json_text), done.get("finish"))
+ out += self._open({"id": new_id("msg"), "type": "message", "status": "in_progress", "role": "assistant",
+ "content": []})
+ self.item["content"][0]["text"] = text
+ out.append(self.event("response.output_text.delta", item_id=self.item["id"], output_index=self.index,
+ content_index=0, delta=text, logprobs=[]))
+ out += self.close()
+ n = done.get("completion_tokens", 0)
+ prompt = done.get("prompt_tokens", self.prompt_tokens)
+ self.response["usage"] = {
+ "input_tokens": prompt, "input_tokens_details": {"cached_tokens": min(done.get("reused") or 0, prompt)},
+ "output_tokens": n, "output_tokens_details": {"reasoning_tokens": done.get("reasoning_tokens", 0)},
+ "total_tokens": prompt + n}
+ if length:
+ self.response["status"] = "incomplete"
+ self.response["incomplete_details"] = {"reason": "max_output_tokens"}
+ return out + [self.event("response.incomplete", response=self.snapshot())]
+ self.response["status"] = "completed"
+ self.response["completed_at"] = int(time.time())
+ return out + [self.event("response.completed", response=self.snapshot())]
+
+ def failed(self, message: str, code: str = "server_error") -> dict:
+ """response.failed: an error after the stream started (the HTTP status is already sent)."""
+ if self.item is not None:
+ self.item["status"] = "incomplete"
+ self.item = None
+ self.response["status"] = "failed"
+ self.response["error"] = {"code": code, "message": message}
+ return self.event("response.failed", response=self.snapshot())
+
+
+def collect(events) -> dict:
+ """The non-streaming answer: the final response object."""
+ last = None
+ for e in events:
+ if e is not None:
+ last = e
+ return last["response"]
diff --git a/serve/runconfig.py b/serve/runconfig.py
new file mode 100644
index 0000000000..472b00212b
--- /dev/null
+++ b/serve/runconfig.py
@@ -0,0 +1,179 @@
+"""serve/runconfig.py - #564: the web page's Settings view of the run config (strata-.json).
+
+A short list of documented keys can be read and changed from Strata's own page (GET / POST /config). Everything
+else in the file - the keys setup writes, the network and security keys (host, api_key, cors_origins,
+trusted_origins, allowed_hosts), the MCP servers and before_load (which run programs), sampling keys not listed
+here - is kept as it is: a change touches only the keys it names (as setup run again keeps the user's keys, #629).
+The earlier file is kept as strata-.json.bak. The server reads the config when it starts, so a change is
+used from the next start on.
+"""
+from __future__ import annotations
+
+import json
+import os
+import shutil
+from pathlib import Path
+
+# (key, kind, help). kind: "bool", "int>=0", "num>=0", ("enum", values), "names", or ("sampling", check) for a key
+# of the "sampling" block, ("arg", flag) for an engine option kept in "args".
+EDITABLE = [
+ ("sampling.temperature", ("sampling", "num>=0"),
+ "Default temperature for requests that send none (0 = greedy, the default without a sampling block)"),
+ ("sampling.top_p", ("sampling", "0=0", "Cap the thinking of every request at this many tokens (0 or empty: no cap)"),
+ ("fit_max_tokens", "bool", "Shorten a max_tokens that does not fit the context instead of answering 400"),
+ ("anthropic_thinking", ("enum", ["model", "on_request"]),
+ "Anthropic requests that do not ask for thinking: think as the model does (model) or not (on_request)"),
+ ("effort_position", ("enum", ["start", "end"]),
+ "Where a non-default reasoning effort goes: start (the default) or end (keeps the cache when it changes)"),
+ ("aliases", "names", "Other model names the server lists and answers to (comma-separated)"),
+ ("idle_unload_s", "num>=0", "Unload the model after this many seconds without requests (0 or empty: never)"),
+ ("lazy_load", "bool", "Start without loading the model; the first request loads it (text only)"),
+ ("engine_silence_s", "num>=0", "End a request when the engine says nothing for this long (default 300 s, 0 = wait)"),
+ ("api_monitor", "bool", "Keep the last 100 requests' prompts and answers in memory for /api-monitor"),
+ ("open_browser", "bool", "Open the chat page in the browser when the model is ready"),
+ ("vram_reserve_mib", ("arg", "--vram-reserve-mib"),
+ "VRAM in MiB the engine leaves free for other programs (engine default 700)"),
+]
+SPEC = {k: kind for k, kind, _ in EDITABLE}
+
+
+def _arg(cfg: dict, flag: str):
+ a = cfg.get("args") if isinstance(cfg.get("args"), list) else []
+ if flag in a[:-1]:
+ v = str(a[a.index(flag) + 1])
+ return int(v) if v.isdigit() else v
+ return None
+
+
+def value_of(cfg: dict, key: str):
+ kind = SPEC[key]
+ if isinstance(kind, tuple) and kind[0] == "sampling":
+ s = cfg.get("sampling")
+ return s.get(key.split(".", 1)[1]) if isinstance(s, dict) else None
+ if isinstance(kind, tuple) and kind[0] == "arg":
+ return _arg(cfg, kind[1])
+ return cfg.get(key)
+
+
+def view(cfg: dict, path: str | Path) -> dict:
+ """GET /config: the editable keys with their values (None: not set, the default applies)."""
+ out = []
+ for key, kind, help_ in EDITABLE:
+ k = kind[0] if isinstance(kind, tuple) else kind
+ out.append({"key": key, "value": value_of(cfg, key), "help": help_,
+ "kind": "number" if k in ("sampling", "arg", "int>=0", "num>=0") else k,
+ **({"choices": kind[1]} if k == "enum" else {})})
+ return {"file": Path(path).name, "keys": out,
+ "note": "Saved to the run config; used from the next start of the model."}
+
+
+def _number(key, v, whole=False, lo=0.0, hi=None, lo_open=False):
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or (whole and v != int(v)) or \
+ (v <= lo if lo_open else v < lo) or (hi is not None and v > hi):
+ rng = f"{'more than' if lo_open else 'at least'} {lo:g}" + (f" and at most {hi:g}" if hi is not None else "")
+ raise ValueError(f"{key}: expected a {'whole ' if whole else ''}number, {rng}, not {v!r}")
+ return int(v) if whole else v
+
+
+def check(key: str, v, cfg: dict):
+ """The value to store for `key`, or a ValueError naming what is expected. None removes the key (its default)."""
+ if key not in SPEC:
+ raise ValueError(f"{key!r} cannot be changed here (only the keys the Settings view lists)")
+ if v is None:
+ return None
+ kind = SPEC[key]
+ k = kind[0] if isinstance(kind, tuple) else kind
+ if k == "bool":
+ if not isinstance(v, bool):
+ raise ValueError(f"{key}: expected true or false, not {v!r}")
+ if key == "lazy_load" and v and cfg.get("vision"):
+ raise ValueError("lazy_load: lazy loading is text-only, and this model reads images (\"vision\")")
+ return v
+ if k == "enum":
+ if v not in kind[1]:
+ raise ValueError(f"{key}: expected one of {', '.join(kind[1])}, not {v!r}")
+ return v
+ if k == "names":
+ if isinstance(v, str):
+ v = [x.strip() for x in v.split(",") if x.strip()]
+ if not isinstance(v, list) or not all(isinstance(x, str) and x.strip() and len(x) <= 128 for x in v):
+ raise ValueError(f"{key}: expected a list of model names, not {v!r}")
+ return v or None
+ if k == "int>=0":
+ return _number(key, v, whole=True)
+ if k == "num>=0":
+ return _number(key, v)
+ if k == "arg":
+ return _number(key, v, whole=True)
+ rule = kind[1] # a sampling key: the server's own rules
+ if rule == "num>=0":
+ return float(_number(key, v))
+ if rule == "0 tuple[dict, list[str]]:
+ """The config with `changes` ({key: value or None}) made, and the keys that changed. Every value is checked
+ first, so a request with one bad value changes nothing (ValueError)."""
+ if not isinstance(changes, dict) or not changes:
+ raise ValueError('send {"set": {"": , ...}}')
+ checked = {k: check(k, v, cfg) for k, v in changes.items()}
+ new = json.loads(json.dumps(cfg)) # a deep copy: the caller's dict is left alone
+ changed = []
+ for key, v in checked.items():
+ if value_of(new, key) == v:
+ continue
+ kind = SPEC[key]
+ if isinstance(kind, tuple) and kind[0] == "sampling":
+ s = dict(new.get("sampling") or {}) if isinstance(new.get("sampling"), dict) else {}
+ sub = key.split(".", 1)[1]
+ if v is None:
+ s.pop(sub, None)
+ else:
+ s[sub] = v
+ if s:
+ new["sampling"] = s
+ else:
+ new.pop("sampling", None)
+ elif isinstance(kind, tuple) and kind[0] == "arg":
+ a = list(new.get("args") or [])
+ flag = kind[1]
+ if flag in a[:-1]:
+ i = a.index(flag)
+ if v is None:
+ del a[i:i + 2]
+ else:
+ a[i + 1] = str(v)
+ elif v is not None:
+ a += [flag, str(v)]
+ new["args"] = a
+ elif v is None:
+ new.pop(key, None)
+ else:
+ new[key] = v
+ changed.append(key)
+ return new, changed
+
+
+def save(path: str | Path, cfg: dict) -> Path:
+ """The config written whole (a temporary file moved over the old one), the earlier one kept as .bak."""
+ path = Path(path)
+ bak = path.with_name(path.name + ".bak")
+ shutil.copyfile(path, bak)
+ tmp = path.with_name(path.name + ".tmp")
+ tmp.write_text(json.dumps(cfg, indent=1), encoding="utf-8")
+ os.replace(tmp, path)
+ return bak
+
+
+def load(path: str | Path) -> dict:
+ cfg = json.loads(Path(path).read_text(encoding="utf-8-sig"))
+ if not isinstance(cfg, dict):
+ raise ValueError(f"{Path(path).name} is not a JSON object")
+ return cfg
diff --git a/serve/server.py b/serve/server.py
index 5a85db024c..9e669827c9 100644
--- a/serve/server.py
+++ b/serve/server.py
@@ -21,6 +21,7 @@
from __future__ import annotations
import argparse
+import contextlib
import collections
import base64
import hashlib
@@ -51,14 +52,28 @@
sys.path.insert(0, str(ROOT / "tools"))
sys.path.insert(0, str(ROOT)) # run as a script (run-.bat) as well as a module
from serve.frontend import (ChatTemplate, Event, OutputParser, anthropic_to_messages, # noqa: E402
- images_of, openai_to_messages)
+ images_of, mark_think_literals, openai_to_messages, unmark_think_literals)
from serve.mcp import McpCancelled, hub_from_config # noqa: E402
+from serve import runconfig # noqa: E402
from serve.winjob import contain # noqa: E402
from serve.structured import StructuredOutputError, prepare_format, validated_json # noqa: E402
+from serve import responses as responses_api # noqa: E402
+from serve.responses import ResponsesError, error_body as responses_error_body # noqa: E402
IM_END = "<|im_end|>"
IMAGE_PAD = "<|image_pad|>"
+# #458: the trailing effort turn ("effort_position": "end"); low is the template's own sentence, medium (which the
+# template says nothing for: no xhigh sentence is medium there) says it, since the xhigh one stays at the top
+EFFORT_TURN = "<|im_start|>system\n{}<|im_end|>\n"
+EFFORT_TEXT = {"low": "Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the "
+ "conclusion without unnecessary elaboration.",
+ "medium": "Reasoning effort is set to medium. Think as much as the task needs, without unnecessary "
+ "elaboration."}
VISION_START = "<|vision_start|>"
+# #606: a reply that repeats one token this many times in a row is ended there ("length"): a model in a loop, or a
+# broken state that answers one token forever (an issue saw 36,689 tokens of "!"). The config's "repeat_stop_tokens"
+# sets it; 0 turns it off.
+REPEAT_STOP_TOKENS = 256
# #123: what closes the thinking when it reaches reasoning_budget_tokens (the model's own end-of-thinking tag after it)
REASONING_WRAP_UP = "\n\nI have thought about this long enough; time to give my answer.\n\n\n"
LOOPBACK_NAMES = ("localhost", "127.0.0.1", "::1")
@@ -292,6 +307,87 @@ def say(key, text):
print(f"[strata] still starting ({time.time() - t0:.0f} s) - please wait ...", flush=True)
+class ConvCacheLog:
+ """#596: the engine's conversation cache as its log tells it (the engine writes "strata serve: conversation
+ cache: parked N tokens ...; parked=P bytes=B evictions=E" and "restored N tokens ...; parked=P bytes=B" to stderr,
+ which is the log): read on from where it was last read, from the start of the engine's current run."""
+ EVENT = re.compile(r"conversation cache: (parked|skipped|restored) (\d+) tokens.*?parked=(\d+) bytes=(\d+)"
+ r"(?: evictions=(\d+))?")
+ DROPPED = re.compile(r"conversation cache: dropped \d+ superseded .*?parked=(\d+)")
+ READ_MAX = 1 << 20 # at most the last MiB of new lines per read
+
+ def __init__(self):
+ self.key, self.pos = None, 0
+ self.reset()
+
+ def reset(self):
+ self.state = {"parked": 0, "bytes": 0, "evictions": 0, "parks": 0, "restores": 0, "last_event": None,
+ "last_tokens": None, "last_at": None}
+
+ def poll(self, path, start) -> dict:
+ """The state after the log's new lines; `start` is where the engine's current run began in it."""
+ if not path or start is None:
+ return dict(self.state)
+ if self.key != (path, start): # another start of the engine: its cache starts empty
+ self.key, self.pos = (path, start), start
+ self.reset()
+ try:
+ size = os.path.getsize(path)
+ if size < self.pos: # the log was emptied or replaced
+ self.pos = 0
+ if size - self.pos > self.READ_MAX:
+ self.pos = size - self.READ_MAX
+ with open(path, "rb") as f:
+ f.seek(self.pos)
+ data = f.read(size - self.pos)
+ except OSError:
+ return dict(self.state)
+ end = data.rfind(b"\n") + 1 # whole lines only: the rest is read next time
+ self.pos += end
+ now = time.time()
+ for line in data[:end].decode("utf-8", "replace").splitlines():
+ if "conversation cache:" not in line:
+ continue
+ m = self.EVENT.search(line)
+ if m:
+ st = self.state
+ st["parked"], st["bytes"] = int(m.group(3)), int(m.group(4))
+ if m.group(5) is not None:
+ st["evictions"] = int(m.group(5))
+ if m.group(1) != "skipped":
+ st["parks" if m.group(1) == "parked" else "restores"] += 1
+ st["last_event"], st["last_tokens"], st["last_at"] = m.group(1), int(m.group(2)), now
+ continue
+ m = self.DROPPED.search(line)
+ if m:
+ self.state["parked"] = int(m.group(1))
+ return dict(self.state)
+
+
+def conversation_cache_view(info: dict, hist: list, totals: dict, parked: dict) -> dict:
+ """#596: the Monitor's Conversation cache card: the parked conversations (the engine's opt-in
+ --conversation-cache-mib: budget, slots, what its log says) and how much of the prompts the cache gave back."""
+ mib = info.get("conversation_cache_mib")
+ last = hist[-1] if hist else None
+ return {"enabled": isinstance(mib, int) and mib > 0, "budget_mib": mib if isinstance(mib, int) else None,
+ "slots": info.get("conversation_cache_slots"), **parked,
+ "requests": len(hist), "requests_reused": sum(1 for r in hist if (r.get("reused") or 0) > 0),
+ "reused_tokens": totals.get("reused", 0), "prompt_tokens": totals.get("prompt_tokens", 0),
+ "last_reused": last.get("reused") if last else None,
+ "last_prompt": last.get("prompt_tokens") if last else None}
+
+
+_BTRACE = bool(os.environ.get("STRATA_BATCH_TRACE"))
+
+
+def btrace(*a):
+ if _BTRACE:
+ print("[batch-trace]", threading.get_ident() % 10000, *a, file=sys.stderr, flush=True)
+
+
+EOS_IDS = {248044, 248046} # <|endoftext|>, <|im_end|>: the engine's default --eos-ids
+
+
class StrataEngine:
"""The resident engine: `strata --serve` reads `GEN ` lines and streams `T ` lines, then
`DONE ...`. Requests are serialized by the service's FIFO, so one pipe is enough.
@@ -302,6 +398,25 @@ class StrataEngine:
"""
silence_s = ENGINE_SILENCE_S # #481: main() sets the config's engine_silence_s (survives restart())
silent_note = None # #481: why the server ended a silent engine (death_note says it)
+ batch = 0 # --batch: the engine's batch slots (0: one request at a time)
+
+ # `last`: the figures of the engine's last DONE line. With batch slots several requests run at once, each on its
+ # own thread, so each one reads its own request's figures (Service.run records them); one at a time, the one dict.
+ @property
+ def last(self):
+ if self.batch:
+ tl = self.__dict__.setdefault("_tl", threading.local())
+ if not hasattr(tl, "last"):
+ tl.last = {}
+ return tl.last
+ return self.__dict__.get("_last", {})
+
+ @last.setter
+ def last(self, value):
+ self.__dict__["_last"] = value
+ if self.batch:
+ tl = self.__dict__.setdefault("_tl", threading.local())
+ tl.last = value
def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str | None = None,
env: dict | None = None, lazy: bool = False):
@@ -328,6 +443,7 @@ def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str |
self.log = open(log, "a", encoding="utf-8") if log else subprocess.DEVNULL
loading = threading.Event() # set once READY: the narrator below stops
log_start = os.path.getsize(log) if log else 0 # where this start's lines begin (start_failure_hint)
+ self.log_start = log_start # #596: the Monitor's conversation cache reads from here
if log:
threading.Thread(target=narrate_start, args=(log, os.path.getsize(log), args, loading),
daemon=True).start()
@@ -361,6 +477,7 @@ def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str |
pass
raise RuntimeError("the engine exited before it was ready" + (f" (see {log})" if log else "") +
start_failure_hint(log, log_start) + start_log_tail(log, log_start))
+ self.known_ctx = self.max_context # survives a failed restart: requests keep their limit and restart it
# (from PR #41, midhatn) a locally built engine can sit next to another release's BUILD.json: engines that
# report their own version (INFO engine=, 0.1.8+) win, the manifest stays the fallback for older ones
if self.info.get("engine"):
@@ -368,16 +485,51 @@ def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str |
self.ended = False # READY: alive from here (restart() set it True, #344)
# the engine's stdout on a thread, so a request can wait with a timeout (heartbeats, cancel checks)
self.lines: queue.Queue = queue.Queue()
+ # --batch: concurrent requests in the engine's batch slots (see generate_batched). The engine says how many
+ # it runs (INFO batch_slots=N: it may have fewer than asked, or none, when they do not fit)
+ asked = next((int(args[args.index(k) + 1]) for k in ("--batch", "--slots") if k in args), 0)
+ self.batch = int(self.info.get("batch_slots") or 0)
+ if asked and self.batch != asked:
+ print(f"[strata] parallel requests: {asked} asked, the engine runs {self.batch or 'one at a time'} "
+ "(its log says why)", flush=True)
+ groups = int(args[args.index("--batch-groups") + 1]) if "--batch-groups" in args else 1
+ groups = groups if self.batch and groups > 0 and self.batch % groups == 0 else 1
+ gs = self.batch // groups if self.batch else 0
+ # slots in the order that spreads requests over the pipeline's groups first: 0, gs, 2gs, .., 1, gs+1, ..
+ self.slot_order = [g * gs + t for t in range(gs) for g in range(groups)]
+ self.slot_q = [queue.Queue() for _ in range(self.batch)]
+ self.slot_busy = [False] * self.batch
+ # what each slot's sessions hold (prompt + every token a window fed), so a conversation's next turn goes to
+ # the slot that has its start (the engine checks it again); when the slot was last used
+ self.slot_held: list[list[int]] = [[] for _ in range(self.batch)]
+ self.slot_used = [0.0] * self.batch
+ self.slot_live: list[dict | None] = [None] * self.batch # /metrics: the request in each slot
+ self.slot_cv = threading.Condition()
+ self.waiting = 0 # requests waiting for the control lines (ctl)
+ self.wait_lens: list[list[int]] = [] # ... their prompt lengths (a long read gives way to short ones)
+ self.ctl_epoch = 0 # how often the control lines were taken
+ self._yielded = None # (slot, tokens read): the last request on them gave way
+ self.ctl = threading.Lock() # one admission or solo request on the control lines at a time
+ self.wlock = threading.Lock() # stdin writes from several request threads
self.pump = threading.Thread(target=self._pump, daemon=True)
self.pump.start()
def _pump(self):
proc, lines = self.proc, self.lines # this process's: a restart replaces both (#344)
+ slot_q = self.slot_q
for line in proc.stdout:
+ if line.startswith(("BT ", "BDONE ")) and slot_q: # --batch: a batch slot's own lines
+ try:
+ slot_q[int(line.split()[1])].put(line)
+ continue
+ except (IndexError, ValueError):
+ pass
lines.put(line)
if self.proc is proc: # a killed engine's pump must not mark its successor dead
self.ended = True # its output closed: it is gone, even before the OS says so
lines.put(None)
+ for q in slot_q:
+ q.put(None)
def death_note(self) -> str:
"""Why the engine most likely ended, from the end of its log: its own watchdog (issue #29), else RAM."""
@@ -420,15 +572,37 @@ def unload(self):
self.close()
self.unloaded = True
- def restart(self):
- """Start the engine again (the same command) after it died; the new process has its own line queue."""
+ RESTART_RETRY_S = 15.0 # between the tries of restart(): a dying engine's VRAM may take a while to come back
+
+ def restart(self, tries: int = 3):
+ """Start the engine again (the same command) after it died; the new process has its own line queue.
+ close() ends and waits for the old process first (EngineStuck when it cannot be ended). A start that still
+ exits before READY - a dead engine's VRAM can take a while to come back, notably on ROCm - is retried
+ (PR #637)."""
self.close()
info = dict(self.info)
- # #344: not alive until READY - __init__ sets max_context to 0 and blocks until the engine says READY, and a
- # request that saw alive() in that window skipped load() and failed with "context (0)". __init__ clears
- # `ended` itself once READY (before its pump thread can set it again).
- self.ended = True
- self.__init__(*self.spawn)
+ self.starting = True # prepare() answers 503 "starting" meanwhile (#344)
+ try:
+ for i in range(tries):
+ # #344: not alive until READY - __init__ sets max_context to 0 and blocks until the engine says
+ # READY, and a request that saw alive() in that window skipped load() and failed with "context
+ # (0)". __init__ clears `ended` itself once READY (before its pump thread can set it again).
+ self.ended = True
+ try:
+ self.__init__(*self.spawn)
+ break
+ except RuntimeError:
+ try:
+ self.proc.wait(timeout=60)
+ except (subprocess.TimeoutExpired, OSError):
+ pass
+ if i == tries - 1:
+ raise
+ print(f"[strata] the engine did not start (try {i + 1} of {tries}); again in "
+ f"{self.RESTART_RETRY_S:g} s", flush=True)
+ time.sleep(self.RESTART_RETRY_S)
+ finally:
+ self.starting = False
self.info = {**info, **self.info}
def _parse_done(self, line):
@@ -443,8 +617,39 @@ def _parse_done(self, line):
self.last.update(ram_blobs=int(f[11]), file_blobs=int(f[12]), file_mb=float(f[13]))
if len(f) >= 15: # #471 (engine 0.1.36+): the prompt tokens actually read
self.last.update(prompt_read=int(f[14]))
- if len(f) >= 18: # lane-local conversation parking telemetry
- self.last.update(parked_conversations=int(f[15]), parked_bytes=int(f[16]), park_evictions=int(f[17]))
+ if len(f) >= 16: # #588 (engine 0.1.39+): routed experts read over PCIe
+ self.last.update(offloaded=int(f[15]))
+
+ def vram(self, reserve_mib: int | None, timeout: float = 120.0) -> dict:
+ """#533: `VRAM ` between requests (the caller holds the service's FIFO): the engine shrinks its
+ expert cache until that much VRAM is free, or grows it back when more is free; None: back to the reserve it
+ started with. -> the engine's figures (expert_slots, expert_cache_mib, vram_free_mib, ...); raises ValueError
+ with the engine's reason (e.g. it was not started with --vram-elastic), EngineDied when it ended."""
+ if not self.alive():
+ raise EngineDied("the engine is not running")
+ self.proc.stdin.write("VRAM" + ("" if reserve_mib is None else f" {int(reserve_mib)}") + "\n")
+ self.proc.stdin.flush()
+ deadline = time.time() + timeout
+ while True:
+ try:
+ line = self.lines.get(timeout=max(0.1, deadline - time.time()))
+ except queue.Empty:
+ raise EngineDied("the engine did not answer the VRAM command") from None
+ if line is None:
+ raise EngineDied("the engine ended")
+ line = line.strip()
+ if line.startswith("ERR"):
+ raise ValueError(line[4:].strip() or "the engine refused the VRAM command")
+ if line.startswith("VRAM "):
+ out = {}
+ for kv in line.split()[1:]:
+ k, _, v = kv.partition("=")
+ out[k] = int(v) if v.lstrip("-").isdigit() else v
+ self.info.update({k: out[k] for k in ("expert_slots", "vram_free_mib") if k in out})
+ self.info["vram"] = out
+ return out
+ if time.time() > deadline:
+ raise EngineDied("the engine did not answer the VRAM command")
@staticmethod
def sampling_keys(sampling: dict) -> str:
@@ -500,10 +705,414 @@ def projection_key(sampling: dict) -> str:
on = sampling.get("experimental_speed_projection")
return f" cvec={int(on)}" if isinstance(on, bool) else ""
+ def _send(self, text: str):
+ btrace("send>", text[:60])
+ try:
+ with self.wlock:
+ self.proc.stdin.write(text + "\n")
+ self.proc.stdin.flush()
+ except OSError: # the pipe is gone: the engine died (not the client)
+ raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})") from None
+
+ def _control(self, cancel, on_token, stop_when=None):
+ """Reads the control lines of the request on them (GEN / BGEN), yielding None heartbeats. Calls on_token(id)
+ for each `T`; returns ("done", None) at DONE, or ("badm", continues) at BADM (after DONE). `stop_when()`
+ true sends STOP once (the request is then read to its DONE)."""
+ stopped = False
+ while True:
+ try:
+ line = self.lines.get(timeout=10.0)
+ except queue.Empty:
+ if cancel.is_set() and not stopped:
+ self._send("STOP")
+ stopped = True
+ yield None
+ continue
+ if line is None:
+ raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})")
+ if not line.startswith(("PP ", "INFO")):
+ btrace("ctl<", self._ctl_mode, line.strip()[:60])
+ if line.startswith("T "):
+ on_token(int(line[2:]))
+ if not stopped and (cancel.is_set() or (stop_when is not None and stop_when())):
+ self._send("STOP")
+ stopped = True
+ yield False # a token is pending (not a heartbeat)
+ elif line.startswith("PP "):
+ f = line.split()
+ if len(f) >= 3 and f[1].isdigit() and f[2].isdigit():
+ self.progress = (int(f[1]), int(f[2]))
+ self.prefill_tok_s_mean = float(f[4]) if len(f) >= 5 else None
+ yield None
+ elif line.startswith("DONE"):
+ self._parse_done(line)
+ self._last_done = line
+ elif line.startswith("BADM "):
+ f = line.split()
+ self._ctl_result = ("badm", len(f) >= 3 and f[2] == "1")
+ return
+ elif line.startswith("YIELDED "): # the read gave way (BYIELD):
+ f = line.split()
+ if len(f) >= 3 and f[1].lstrip("-").isdigit() and f[2].isdigit():
+ self._yielded = (int(f[1]), int(f[2]))
+ elif line.startswith("ERR"):
+ raise ValueError(line[4:].strip())
+ if line.startswith("DONE") and self._ctl_mode == "solo":
+ self._ctl_result = ("done", None)
+ return
+
+ def _drain_control(self, until: str, timeout: float = 300.0):
+ """After a consumer left early: read the control lines up to the next `until` line (DONE or BADM) so the next
+ request does not read this one's leftovers. Returns that line (None: the engine ended or never answered)."""
+ end = time.monotonic() + timeout
+ while time.monotonic() < end:
+ try:
+ line = self.lines.get(timeout=max(0.1, end - time.monotonic()))
+ except queue.Empty:
+ break
+ if line is None:
+ return None
+ if line.startswith("DONE"):
+ self._parse_done(line)
+ if line.startswith(until) or line.startswith("ERR"):
+ return line
+ return None
+
+ def _release_slot_when_done(self, slot: int, stream: list[int] | None = None):
+ """A slot whose consumer left (a stop token, a stop string, a disconnect): BSTOP it and free it once the engine
+ says BDONE (in the background). `stream`: the prompt and every token of it so far - with the tokens still to
+ come before BDONE, all but the last are what the slot holds then (the next turn of its conversation)."""
+ try:
+ self._send(f"BSTOP {slot}")
+ except EngineDied:
+ pass
+ def wait():
+ end = time.monotonic() + 600.0
+ tail = list(stream or [])
+ while time.monotonic() < end:
+ try:
+ line = self.slot_q[slot].get(timeout=5.0)
+ except queue.Empty:
+ continue
+ if line is None:
+ tail = []
+ break
+ if line.startswith("BT "):
+ try:
+ tail.append(int(line.split()[2]))
+ except (IndexError, ValueError):
+ tail = []
+ if line.startswith("BDONE "):
+ break
+ self.slot_held[slot] = tail[:-1] if stream and tail else []
+ with self.slot_cv:
+ self.slot_busy[slot] = False
+ self.slot_cv.notify_all()
+ threading.Thread(target=wait, daemon=True).start()
+
+ YIELDS_MAX = 2 # #656: how often one request's prompt read gives way to a shorter waiting one
+
+ def _take_control(self, cancel, plen: int, after_epoch: int | None = None):
+ """Waits for the control lines (one prompt read at a time), yielding None heartbeats; False when cancelled.
+ `after_epoch`: a request whose read gave way lets the requests waiting then go first."""
+ entry = [plen]
+ with self.slot_cv:
+ self.waiting += 1
+ self.wait_lens.append(entry)
+ beat = time.monotonic()
+ try:
+ while True:
+ with self.slot_cv:
+ turn = after_epoch is None or self.ctl_epoch > after_epoch or self.waiting <= 1
+ if turn and self.ctl.acquire(timeout=0.5):
+ break
+ if not turn:
+ with self.slot_cv:
+ self.slot_cv.wait(timeout=0.5)
+ if cancel.is_set():
+ return False
+ if time.monotonic() - beat >= 10.0: # a heartbeat every 10 s, as the engine's own wait
+ beat = time.monotonic()
+ yield None
+ finally:
+ with self.slot_cv:
+ self.waiting -= 1
+ self.wait_lens.remove(entry)
+ with self.slot_cv:
+ self.ctl_epoch += 1
+ self.slot_cv.notify_all()
+ return True
+
+ SOLO_AGAIN_MAX = 2 # how often a request left alone in a slot goes back to the solo path
+ SOLO_AGAIN_MIN_LEFT = 32 # ... only with at least this many tokens still allowed (max_tokens)
+
+ def _may_go_solo(self, left: int, times: int, embeddings) -> bool:
+ """A request decoding in a slot that is alone now (no other slot busy, nobody waiting) goes back to the solo
+ path with its MTP drafts: the engine continues it from the slot's sessions (its slot cache; INFO
+ slot_cache=1). STRATA_PARALLEL_SOLO=0 keeps it in the slot."""
+ if (embeddings or times >= self.SOLO_AGAIN_MAX or left < self.SOLO_AGAIN_MIN_LEFT or
+ not (self.info or {}).get("slot_cache") or os.environ.get("STRATA_PARALLEL_SOLO") == "0"):
+ return False
+ with self.slot_cv:
+ return sum(1 for b in self.slot_busy if b) == 1 and self.waiting == 0
+
+ def _shorter_waiting(self, plen: int) -> bool:
+ """#656: someone waits for the control lines with a prompt under half this one's: worth giving way to."""
+ with self.slot_cv:
+ return any(e[0] * 2 <= plen for e in self.wait_lens)
+
+ def generate_batched(self, ids, max_new, sampling, cancel, embeddings=None):
+ """--batch. Alone (no slot busy, nobody waiting): the solo path (GEN, with drafts: the fastest stream)
+ - and when another request arrives meanwhile, this one is STOPped and continues in a batch slot (BGEN with
+ its prompt + what it generated: the engine reuses that prefix). Otherwise: BGEN into a free slot, then the
+ slot's own BT lines until BDONE. Several requests run at once; the control lines (prompt reading, admission)
+ are taken one request at a time - and a long prompt read gives way at a chunk boundary to a waiting request
+ with a much shorter prompt (#656: `BYIELD `; the part read waits in a slot and the read goes on after).
+ A consumer that stops early leaves the engine in step: the solo request is STOPped and read to its DONE, an
+ admission to its BADM, a slot is BSTOPped and freed at its BDONE."""
+ self.progress = None
+ keys = self.sampling_keys(sampling or {})
+ out: list[int] = []
+ pending: list[int] = []
+ prompt, left = list(ids), int(max_new)
+ ok = yield from self._take_control(cancel, len(prompt))
+ if not ok:
+ return
+ holding = True
+ btrace("ctl acquired")
+ slot, gen0, reserved = None, None, None
+ phase = "none" # solo -> (admit -> slot) ; "done" once the engine has finished with this request
+ stop_sent = False
+ yields, solo_again = 0, 0
+ try:
+ while True: # a request in a slot that is left alone goes back to the solo path
+ if not holding:
+ ok = yield from self._take_control(cancel, len(prompt))
+ if not ok:
+ return
+ holding = True
+ with self.slot_cv:
+ alone = not any(self.slot_busy) and self.waiting == 0
+ if alone and left > 1:
+ head = f"GENI {left}{keys} {embeddings}" if embeddings else f"GEN {left}{keys}"
+ self._send(f"{head} {','.join(str(int(t)) for t in prompt)}")
+ phase = "solo"
+ self._ctl_mode, self._ctl_result, self._yielded = "solo", None, None
+ def others():
+ with self.slot_cv:
+ return self.waiting > 0
+ for x in self._control(cancel, pending.append, stop_when=others):
+ while pending:
+ t = pending.pop(0)
+ out.append(t)
+ yield t
+ if x is None: # a heartbeat (False: a token, flushed above)
+ if (reserved is None and not out and not embeddings and yields < self.YIELDS_MAX and
+ self._shorter_waiting(len(prompt))):
+ with self.slot_cv: # the slot the part read will wait in
+ reserved = self.pick_slot(prompt)
+ if reserved is not None:
+ self.slot_busy[reserved] = True
+ if reserved is not None:
+ self._send(f"BYIELD {reserved}")
+ yield None
+ phase = "none" # its DONE is read
+ while pending:
+ t = pending.pop(0)
+ out.append(t)
+ yield t
+ if self._yielded is not None and reserved is not None and self._yielded[0] == reserved:
+ slot, reserved = reserved, None # gave way: the read goes on in that slot (below)
+ elif reserved is not None: # it did not give way: the slot is free again
+ with self.slot_cv:
+ self.slot_busy[reserved] = False
+ self.slot_cv.notify_all()
+ reserved = None
+ if slot is None:
+ finish = (self.last or {}).get("finish") if isinstance(self.last, dict) else None
+ left = int(max_new) - len(out)
+ if cancel.is_set() or left <= 0 or finish in ("stop", "length") or (out and out[-1] in EOS_IDS):
+ return
+ prompt = list(ids) + out # promoted: it continues in a batch slot from here
+ while True:
+ if slot is None:
+ # a free slot (they free themselves at BDONE, which needs no control lines): the one that holds
+ # the start of this prompt (its conversation's last turn), else the one used longest ago
+ with self.slot_cv:
+ while True:
+ slot = self.pick_slot(prompt)
+ if slot is not None:
+ self.slot_busy[slot] = True
+ break
+ self.slot_cv.wait(timeout=10.0)
+ if cancel.is_set():
+ return
+ if self._yielded is not None: # it gave way: the others waiting then go first
+ self.slot_held[slot] = list(prompt[:self._yielded[1]])
+ self._yielded = None
+ yields += 1
+ self.ctl.release()
+ holding = False
+ with self.slot_cv:
+ epoch = self.ctl_epoch
+ ok = yield from self._take_control(cancel, len(prompt), after_epoch=epoch)
+ if not ok:
+ return
+ holding = True
+ while not self.slot_q[slot].empty():
+ self.slot_q[slot].get_nowait()
+ head = f"BGENI {slot} {left}{keys} {embeddings}" if embeddings else f"BGEN {slot} {left}{keys}"
+ live = {"slot": slot, "state": "reading", "prompt_tokens": len(prompt), "generated": 0,
+ "started": time.time(), "first_token": None}
+ self.slot_live[slot] = live
+ self._send(f"{head} {','.join(str(int(t)) for t in prompt)}")
+ self.slot_held[slot] = [] # the admission overwrites what the slot held
+ phase = "admit"
+ self._ctl_mode, self._ctl_result, self._yielded = "batch", None, None
+ asked = False
+ for x in self._control(cancel, pending.append):
+ while pending:
+ t = pending.pop(0)
+ out.append(t)
+ yield t
+ if x is None:
+ if (not asked and not embeddings and yields < self.YIELDS_MAX and
+ self._shorter_waiting(len(prompt))):
+ self._send(f"BYIELD {slot}")
+ asked = True
+ yield None
+ cont = bool(self._ctl_result and self._ctl_result[1])
+ phase = "slot" if cont else "none"
+ while pending:
+ t = pending.pop(0)
+ out.append(t)
+ yield t
+ if not cont and self._yielded is not None and self._yielded[0] == slot and not cancel.is_set():
+ continue # gave way: again once the shorter request is in
+ break
+ self.ctl.release()
+ holding = False
+ if not cont:
+ return
+ live.update(state="decoding", first_token=time.time(), generated=len(out))
+ gen0 = len(out) - 1 # the admission's own token: the slot feeds it first
+ going_solo = False
+ while True:
+ try:
+ line = self.slot_q[slot].get(timeout=10.0)
+ except queue.Empty:
+ if cancel.is_set() and not stop_sent:
+ self._send(f"BSTOP {slot}")
+ stop_sent = True
+ yield None
+ continue
+ if line is None:
+ phase = "none"
+ raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})")
+ if line.startswith("BT "):
+ t = int(line.split()[2])
+ out.append(t)
+ live["generated"] = len(out)
+ if cancel.is_set():
+ if not stop_sent:
+ self._send(f"BSTOP {slot}")
+ stop_sent = True
+ continue
+ yield t
+ if not going_solo and not stop_sent and self._may_go_solo(int(max_new) - len(out), solo_again,
+ embeddings):
+ self._send(f"BSTOP {slot}") # alone now: on with the drafts (below)
+ going_solo = True
+ elif line.startswith("BDONE "):
+ phase = "none"
+ f = line.split()
+ if len(f) >= 5 and isinstance(self.last, dict):
+ self.last = {**self.last, "finish": f[3], "decode_ms": float(f[4]),
+ "generated": int(f[2]) if f[2].isdigit() else self.last.get("generated")}
+ # what the slot's sessions hold now: the prompt and every token fed (all but the last one)
+ self.slot_held[slot] = list(prompt) + out[gen0:-1] if len(out) > gen0 else []
+ if (going_solo and f[3:4] == ["cancel"] and not cancel.is_set() and len(out) < int(max_new)
+ and not (out and out[-1] in EOS_IDS)):
+ # the solo path continues it: the engine copies the slot's sessions back (all but the
+ # last token are in them) and decodes with MTP drafts again
+ self.slot_live[slot] = None
+ self.slot_used[slot] = time.time()
+ with self.slot_cv:
+ self.slot_busy[slot] = False
+ self.slot_cv.notify_all()
+ slot, gen0 = None, None
+ prompt, left = list(ids) + out, int(max_new) - len(out)
+ solo_again += 1
+ btrace("back to the solo path")
+ break
+ return
+ finally:
+ # a consumer that left early (or an error): keep the engine and this server in step
+ btrace("finally phase", phase, "slot", slot, "holding", holding)
+ try:
+ if phase == "solo":
+ self._send("STOP")
+ self._drain_control("DONE")
+ elif phase == "admit":
+ line = self._drain_control("BADM")
+ if line and line.startswith("BADM ") and line.split()[2:3] == ["1"]:
+ phase = "slot"
+ except EngineDied:
+ pass
+ if holding:
+ self.ctl.release()
+ if reserved is not None:
+ with self.slot_cv:
+ self.slot_busy[reserved] = False
+ self.slot_cv.notify_all()
+ if slot is not None:
+ self.slot_live[slot] = None
+ self.slot_used[slot] = time.time()
+ if phase == "slot":
+ self.slot_held[slot] = []
+ stream = list(prompt) + out[gen0:] if gen0 is not None and len(out) > gen0 else None
+ self._release_slot_when_done(slot, stream) # freed at its BDONE
+ else:
+ with self.slot_cv:
+ self.slot_busy[slot] = False
+ self.slot_cv.notify_all()
+
+ def pick_slot(self, prompt: list[int]) -> int | None:
+ """A free slot for `prompt` (the caller holds slot_cv): the one whose held tokens are the longest start of the
+ prompt (the engine then reads only the rest), else an empty one, else the one used longest ago - so the
+ conversations other slots hold stay for their next turns. None: every slot is busy."""
+ free = [b for b in self.slot_order if not self.slot_busy[b]]
+ if not free:
+ return None
+ def held_prefix(b):
+ h = self.slot_held[b]
+ return len(h) if h and len(h) < len(prompt) and prompt[:len(h)] == h else 0
+ best = max(free, key=held_prefix)
+ if held_prefix(best) > 0:
+ return best
+ return min(free, key=lambda b: (bool(self.slot_held[b]), self.slot_used[b]))
+
+ def slots_view(self) -> list[dict]:
+ """/metrics: each batch slot - idle (with the tokens it holds for a next turn), reading or decoding."""
+ now, view = time.time(), []
+ for b in range(self.batch):
+ r = self.slot_live[b]
+ if r is None:
+ view.append({"slot": b, "state": "idle", "held_tokens": len(self.slot_held[b])})
+ continue
+ ft = r.get("first_token")
+ view.append({"slot": b, "state": r["state"], "prompt_tokens": r["prompt_tokens"],
+ "generated": r["generated"], "elapsed_s": round(now - r["started"], 1),
+ "tok_s": round(r["generated"] / max(1e-6, now - ft), 1) if ft else None})
+ return view
+
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
"""Yields token ids, and None as a heartbeat every 10 s while the engine is quiet (reading a long prompt):
the HTTP layer turns it into an SSE comment, which keeps clients' watchdogs calm and notices a client that
has gone. A consumer that stops early (or `cancel`) makes the engine STOP, so it does not run to max_new."""
+ if getattr(self, "batch", 0):
+ yield from self.generate_batched(ids, max_new, sampling, cancel, embeddings)
+ return
self.progress = None
self.prefill_tok_s_mean = None
# an image request takes the same sampling keys as text (#75: it used to decode greedily whatever was asked)
@@ -780,6 +1389,31 @@ def gpu_list(cfg: dict) -> list[int]:
return [int(str(x).strip()) for x in items if str(x).strip() != ""]
+def effort_end_args(cfg: dict, exe: str, tok) -> list[str] | None:
+ """#458 (opt-in): the engine arguments for "effort_position": "end" - the id of "system" as --tail-role-token, so
+ the engine checkpoints in front of the trailing effort turn - or None when the config leaves it at the top (the
+ default) or the engine is too old for it (said once; the prompt stays the default one). ValueError for a value
+ other than "start" / "end"."""
+ pos = cfg.get("effort_position", "start")
+ if pos not in ("start", "end"):
+ raise ValueError(f'"effort_position" must be "start" (the default) or "end", not {pos!r}')
+ if pos == "start":
+ return None
+ try:
+ with open(exe, "rb") as f:
+ known = b"--tail-role-token" in f.read()
+ except OSError:
+ known = False
+ role = tok.encode("system", parse_special=True)
+ if not known or len(role) != 1:
+ print("[strata] effort_position \"end\" needs engine 0.1.39 or newer (--tail-role-token): the reasoning "
+ "effort stays at the top of the prompt", flush=True)
+ return None
+ print("[strata] effort_position end: a request's non-default reasoning effort goes right before the answer, so "
+ "changing it keeps the cached conversation", flush=True)
+ return ["--tail-role-token", str(role[0])]
+
+
def engine_silence_s(cfg: dict) -> float:
"""#481: the config's "engine_silence_s" - seconds an engine may print nothing during a request before the server
ends it (default ENGINE_SILENCE_S; 0 = wait forever). ValueError for anything but a number >= 0."""
@@ -791,18 +1425,84 @@ def engine_silence_s(cfg: dict) -> float:
return float(v)
+def layer_split_value(cfg: dict) -> str:
+ """#644: the config's "layer_split" as the engine's --layer-split value: "auto" (the default), or the FIRST LAYER of
+ each later GPU's share - one rising number per GPU after the first, e.g. "24,36,42" for 4 GPUs (layers 0-23 on the
+ first card, 24-35, 36-41, 42 to the end) - as a string or a JSON list ([24, 36, 42]). ValueError with the format
+ and an example for anything else, such as per-card layer counts ("24,16,12,12")."""
+ v = cfg.get("layer_split")
+ if v is None or (isinstance(v, str) and v.strip().lower() in ("", "auto")):
+ return "auto"
+ n = len(gpu_list(cfg))
+ items = v if isinstance(v, (list, tuple)) else str(v).split(",") if isinstance(v, (str, int)) else None
+ vals = []
+ for x in items or []:
+ try:
+ if isinstance(x, bool) or isinstance(x, float):
+ raise ValueError
+ vals.append(int(str(x).strip()))
+ except ValueError:
+ vals = None
+ break
+ want = max(n - 1, 1)
+ example = ",".join(str(round(48 * (i + 1) / (want + 1))) for i in range(want))
+ hint = (f'"layer_split" is the first layer of each later GPU, one rising number per GPU after the first '
+ f'({want} for {n} GPUs), not a count of layers per card - e.g. "{example}" (or [{example.replace(",", ", ")}])'
+ f' for an even share of 48 layers, or "auto" (the default) to place them by each card\'s free VRAM')
+ if not vals:
+ raise ValueError(f"{hint}; got {v!r}")
+ if n > 1 and len(vals) != n - 1:
+ raise ValueError(f"{hint}; got {len(vals)} number(s) ({v!r}) for {n} GPUs")
+ if vals[0] < 2 or any(b <= a for a, b in zip(vals, vals[1:])):
+ raise ValueError(f"{hint}; got {v!r}, which does not rise from 2 or more")
+ return ",".join(str(x) for x in vals)
+
+
def engine_args(cfg: dict) -> list[str]:
"""The engine's arguments: the config's, and with several GPUs the layer split across them ("layer_split" in the
- config: "auto" by default, or the first layer of each later GPU's share, e.g. "18" or "16,32")."""
+ config: "auto" by default, or the first layer of each later GPU's share, e.g. "18" or "16,32"; see
+ layer_split_value)."""
args = list(cfg["args"])
if len(gpu_list(cfg)) > 1 and "--layer-split" not in args:
- args += ["--layer-split", str(cfg.get("layer_split") or "auto")]
+ args += ["--layer-split", layer_split_value(cfg)]
# opt-in: an auto split runs on the first card alone when it holds every profiled expert and the KV
if len(gpu_list(cfg)) > 1 and cfg.get("split_skip_if_fits") and "--split-skip-if-fits" not in args:
args.append("--split-skip-if-fits")
+ # #533 (opt-in): "vram_elastic": true - the expert cache in segments, so POST /v1/vram can give VRAM back to other
+ # programs and take it back; "vram_segment_mib" sets the segment size (the engine's default: 512)
+ if cfg.get("vram_elastic") is True and "--vram-elastic" not in args:
+ args.append("--vram-elastic")
+ seg = cfg.get("vram_segment_mib")
+ if isinstance(seg, int) and not isinstance(seg, bool) and seg > 0 and "--vram-segment-mib" not in args:
+ args += ["--vram-segment-mib", str(seg)]
+ args += parallel_args(cfg, args)
return learned_profile_args(cfg, args)
+PARALLEL_MAX = 8 # the engine's batch window holds at most 8 rows (kVerifyMaxT)
+
+
+def parallel_args(cfg: dict, args: list[str]) -> list[str]:
+ """#465 (opt-in): "parallel": N in the config - up to N requests decode together in the engine's batch slots
+ (--batch N); more wait their turn. 1 or absent: one request at a time, as before. A value the engine cannot use
+ is said and passed on: the engine warns and adjusts (recommend, never force)."""
+ if "--batch" in args or "--slots" in args:
+ return []
+ n = cfg.get("parallel")
+ if n is None or n is False:
+ return []
+ if isinstance(n, bool) or not isinstance(n, int):
+ print(f'[strata] "parallel" must be a whole number of requests (2..{PARALLEL_MAX}), not {n!r}: ignored',
+ flush=True)
+ return []
+ if n <= 1:
+ return []
+ if n > PARALLEL_MAX:
+ print(f'[strata] "parallel": {n} - the engine runs at most {PARALLEL_MAX} requests together; it will use '
+ f"{PARALLEL_MAX}", flush=True)
+ return ["--batch", str(n)]
+
+
def profile_shape(path: str) -> tuple[int, int] | None:
"""An expert profile's (layers, experts per layer), from its header (tools/make_profile.py's format), or None
when the file is missing, is not one or is shorter than the pairs its header promises."""
@@ -895,11 +1595,14 @@ 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):
+ ALWAYS = () # specials matched without parse_special (type 4, as )
+
+ def encode(self, text, parse_special=False, plain=()):
out, i = [], 0
while i < len(text):
for k, s in enumerate(self.SPECIALS):
- if parse_special and text.startswith(s, i):
+ if (parse_special or s in self.ALWAYS) and text.startswith(s, i) and not any(
+ a <= i < b for a, b in plain):
out.append(256 + k)
i += len(s)
break
@@ -957,6 +1660,12 @@ def __init__(self, engine: Engine, tokenizer, template: ChatTemplate, model_name
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)
+ # #458 (opt-in, the config's "effort_position": "end"): a non-default reasoning effort goes in a short system
+ # turn before the answer instead of the top of the prompt, so switching it keeps the cached conversation
+ self.effort_end = False
+ self.conv_log = ConvCacheLog() # #596: the parked conversations, from the engine's log
+ self.config_path = None # #564: the run config the web page's Settings view edits
+ self.config_lock = threading.Lock()
# #321: browser pages of these origins may call /v1/* (CORS; "*" = any page - only with an api_key that
# matters); empty = no CORS headers at all, as before
self.cors_origins: list[str] = []
@@ -969,6 +1678,9 @@ def __init__(self, engine: Engine, tokenizer, template: ChatTemplate, model_name
self.host_names: set[str] = set(LOOPBACK_NAMES)
self.status = {"busy": False, "queued": 0} # GET /status: what the model is doing right now
self.rate = collections.deque(maxlen=32) # (time, generated) samples for the live tok/s window
+ # "parallel" (the engine's batch slots): every running request's own status and rate window; self.status
+ # then says busy while any runs and shows the newest one
+ self.live_reqs: dict[int, tuple[dict, collections.deque]] = {}
# #332: the API request monitor (/api-monitor) keeps the last 100 requests' prompts and answers in memory,
# so it is off unless the config's "api_monitor" (or --api-monitor) turns it on
self.api_monitor = False
@@ -990,7 +1702,9 @@ def __init__(self, engine: Engine, tokenizer, template: ChatTemplate, model_name
self.idle_unload_s = 0
self.min_free_vram_mib = 0
self.before_load = None
+ self.vram_reserve = None # #533: the last POST /v1/vram reserve (None: the start's)
self.reasoning_budget_tokens = 0 # #123: the config's default thinking budget (0: none)
+ self.repeat_stop_tokens = REPEAT_STOP_TOKENS # #606: one token this many times in a row ends a reply (0: off)
self.anthropic_think_unasked = True # #278: "anthropic_thinking": "on_request" -> False
self.stop_ids = set(tokenizer.encode(IM_END, parse_special=True) +
tokenizer.encode("<|endoftext|>", parse_special=True))
@@ -1090,6 +1804,43 @@ def ensure_loaded(self):
"(a minute or two) ...", flush=True)
self.engine.restart()
print("[strata] the engine is running again", flush=True)
+ if self.vram_reserve is not None and hasattr(self.engine, "vram"): # #533: the reserve asked for last
+ try:
+ self.engine.vram(self.vram_reserve)
+ except (ValueError, EngineDied) as e:
+ print(f"[strata] the VRAM reserve ({self.vram_reserve} MiB) was not applied: {e}", flush=True)
+
+ vram_wait_s = 300.0 # #533: how long POST /v1/vram waits for a running request
+
+ def vram(self, reserve_mib: int | None) -> dict:
+ """#533, POST /v1/vram: keep `reserve_mib` of VRAM free for other programs (None: the reserve the engine
+ started with), applied between requests - a request that is running finishes first (up to wait_s). Only an
+ engine started with --vram-elastic (the config's "vram_elastic": true) can do it; it never resizes on its own.
+ An unloaded engine applies it when it loads. -> {"status": ..., and the engine's figures}."""
+ if not hasattr(self.engine, "vram"):
+ raise ValueError("this engine cannot resize its VRAM use")
+ if not self.fifo.acquire(timeout=self.vram_wait_s):
+ raise ModelBusy("a request is still running; try again when it has finished")
+ # parallel requests do not hold the fifo: the engine's control lines (a prompt being admitted) are taken too,
+ # and the engine refuses a resize while a slot decodes
+ ctl = getattr(self.engine, "ctl", None) if getattr(self.engine, "batch", 0) else None
+ if ctl is not None and not ctl.acquire(timeout=self.vram_wait_s):
+ self.fifo.release()
+ raise ModelBusy("a request is still running; try again when it has finished")
+ try:
+ if not self.loaded():
+ self.vram_reserve = reserve_mib
+ return {"status": "not loaded", "reserve_mib": reserve_mib,
+ "note": "applied when the model loads"}
+ out = self.engine.vram(reserve_mib)
+ self.vram_reserve = reserve_mib
+ print(f"[strata] VRAM: {out.get('vram_free_mib')} MiB free, expert cache {out.get('expert_cache_mib')} of "
+ f"{out.get('expert_cache_full_mib')} MiB ({out.get('expert_slots')} experts)", flush=True)
+ return {"status": "ok", **out}
+ finally:
+ if ctl is not None:
+ ctl.release()
+ self.fifo.release()
def _say_died(self, e: Exception) -> None:
"""The server window's line for an engine that died (or was ended, #481) in the middle of a request."""
@@ -1204,10 +1955,19 @@ def start_telemetry(self):
amd=getattr(self, "backend", None) == "hip")
def _tok_s(self):
- """tok/s over the last RATE_WINDOW_S seconds. Returns 0.0 while nothing is generating."""
+ """tok/s over the last RATE_WINDOW_S seconds. Returns 0.0 while nothing is generating. With parallel requests:
+ the sum of theirs."""
+ with self.status_lock:
+ many = [(dict(st), list(rt)) for st, rt in self.live_reqs.values()]
+ if many:
+ return sum(self._rate_of(st, rt) for st, rt in many)
with self.status_lock:
s = dict(self.status)
rate = list(self.rate)
+ return self._rate_of(s, rate)
+
+ @staticmethod
+ def _rate_of(s, rate):
if not s.get("busy") or not s.get("first_token"):
return 0.0
now = time.time()
@@ -1220,10 +1980,9 @@ def _tok_s(self):
def _tok_s_mean(self):
"""The whole-request mean since the first token (the old formula), kept so the two can be compared."""
with self.status_lock:
- s = dict(self.status)
- if not s.get("busy") or not s.get("first_token"):
- return 0.0
- return s["generated"] / max(1e-6, time.time() - s["first_token"])
+ many = [dict(st) for st, _ in self.live_reqs.values()] or [dict(self.status)]
+ return sum(s["generated"] / max(1e-6, time.time() - s["first_token"]) for s in many
+ if s.get("busy") and s.get("first_token"))
def _prefill_tok_s_mean(self):
"""Engine-reported mean over newly read tokens, excluding the cached prefix."""
@@ -1267,6 +2026,8 @@ def metrics(self, all_requests=False) -> dict:
the hardware (with a minute of history per series)."""
with self.status_lock:
s = dict(self.status)
+ if self.live_reqs: # parallel requests: the newest one's status
+ s = {**s, **dict(list(self.live_reqs.values())[-1][0]), "queued": s.get("queued", 0)}
hist = list(self.history)
totals = dict(self.totals)
now = time.time()
@@ -1290,10 +2051,23 @@ def metrics(self, all_requests=False) -> dict:
"tok_s_window_s": RATE_WINDOW_S if state == "generating" else None}
if state == "reading" and progress:
live["prompt_read"], live["prompt_total"] = progress
+ par = int(getattr(self.engine, "batch", 0) or 0)
+ if par: # #465: the requests running together, slot by slot
+ with self.status_lock:
+ running = len(self.live_reqs)
+ slots = self.engine.slots_view() if hasattr(self.engine, "slots_view") else []
+ # the requests in flight that are not in a slot: the one alone on the solo path, or waiting their turn
+ in_slots = sum(1 for x in slots if x["state"] != "idle")
+ live.update(parallel=par, running=running, slots=slots, outside_slots=max(0, running - in_slots),
+ waiting=int(getattr(self.engine, "waiting", 0) or 0))
+ if running and state == "idle":
+ live["state"] = "generating"
engine = {"model": self.model, "max_context": self.engine.max_context, "images": self.vision is not None,
**dict(getattr(self.engine, "info", {}) or {})}
tel = self.telemetry.snapshot() if getattr(self, "telemetry", None) else {"now": {}, "history": {}, "static": {}}
+ parked = self.conv_log.poll(getattr(self.engine, "log_path", None), getattr(self.engine, "log_start", None))
return {"engine": engine, "live": live, "requests": hist[::-1][:None if all_requests else 12],
+ "conversation_cache": conversation_cache_view(engine, hist, totals, parked),
"requests_kept": len(hist), "totals": totals, "hardware": tel["now"],
"hardware_static":
tel["static"], "history": tel["history"], "time": now}
@@ -1322,12 +2096,16 @@ def scaled(v, unit, digits=0):
"started": int(self.started_at), "uptime_s": int(time.time() - self.started_at),
"cache_max_tokens": ctx,
"context": {"native": ctx, "max_positions": ctx},
- "concurrency": {"serving": 1, "requested": 1}, # one request at a time; more wait their turn
- "dialects": ["/v1/chat/completions", "/v1/messages"],
+ # one request at a time (more wait their turn), or "parallel": N batch slots (#465)
+ "concurrency": {"serving": max(1, int(getattr(self.engine, "batch", 0) or 0)),
+ "requested": max(1, int(getattr(self.engine, "batch", 0) or 0))},
+ "dialects": ["/v1/chat/completions", "/v1/messages", "/v1/responses"],
"vision": {"enabled": images, "available": images, "error": None},
"activity": {"requests": totals["requests"] + int(busy), "in_flight": int(busy) + int(s.get("queued") or 0),
"last_request_at": int(last_at) if last_at else None},
"last_timings": last_t,
+ "vram": dict((getattr(self.engine, "info", {}) or {}).get("vram") or {},
+ elastic=bool((getattr(self.engine, "info", {}) or {}).get("vram_elastic"))),
"machine": {
"at": int(time.time()),
"gpu": {"name": static.get("gpu_name"), "used_mib": scaled(hw.get("gpu_mem_used"), 2 ** 20),
@@ -1336,11 +2114,38 @@ def scaled(v, unit, digits=0):
"ram": {"used_gib": scaled(hw.get("ram_used"), 2 ** 30, 1),
"total_gib": scaled(hw.get("ram_total"), 2 ** 30, 1)} if hw.get("ram_total") else None}}
+ def render_prompt(self, messages, tools, kwargs) -> str:
+ """The template rendered. #458: with `effort_end`, a request with a non-default effort (low, medium or no
+ thinking) is rendered as a default one up to the answer - the same prompt start, so the conversation cache
+ keeps it - and its effort follows in a short system turn right before the answer (thinking off: the template's
+ empty thinking block). The engine (--tail-role-token) checkpoints in front of that turn."""
+ effort = kwargs.get("reasoning_effort")
+ off = kwargs.get("enable_thinking") is False
+ if not self.effort_end or (not off and effort in (None, "", "xhigh", "high")):
+ return self.template.render(messages, tools=tools, **kwargs)
+ base = {k: v for k, v in kwargs.items() if k not in ("reasoning_effort", "enable_thinking")}
+ history = self.template.render(messages, tools=tools, add_generation_prompt=False, **base)
+ full = self.template.render(messages, tools=tools, add_generation_prompt=True, **kwargs)
+ mine = self.template.render(messages, tools=tools, add_generation_prompt=False, **kwargs)
+ if not full.startswith(mine): # a template this cannot take apart: rendered as asked
+ return full
+ tail = "" if off else EFFORT_TURN.format(EFFORT_TEXT.get(effort, EFFORT_TEXT["medium"]))
+ return history + tail + full[len(mine):]
+
+ def encode_prompt(self, messages, tools, kwargs) -> list[int]:
+ """The request's prompt: the template rendered and tokenized. #537: a / written inside a
+ message's text is encoded as the text it is, not as the model's reasoning markers (the template's own are)."""
+ marked, marked_tools, changed = mark_think_literals(messages, tools)
+ prompt = self.render_prompt(marked, marked_tools, kwargs)
+ if not changed:
+ return self.tok.encode(prompt, parse_special=True)
+ prompt, plain = unmark_think_literals(prompt)
+ return self.tok.encode(prompt, parse_special=True, plain=plain)
+
def prepare(self, messages, tools, kwargs, max_new=None):
"""-> (ids, thinking, max_new). An unset or non-positive max_new (some clients send -1) means "unlimited":
the rest of the context."""
- prompt = self.template.render(messages, tools=tools, **kwargs)
- ids = self.tok.encode(prompt, parse_special=True)
+ ids = self.encode_prompt(messages, tools, kwargs)
self.embeddings.path = None
images = images_of(messages)
if images:
@@ -1376,30 +2181,37 @@ def prepare(self, messages, tools, kwargs, max_new=None):
for path, _ in encoded:
f.write(path.read_bytes())
self.embeddings.path = combined
- if self.engine.max_context <= 0: # #344: (re)starting, not a prompt that is too long
- raise EngineStarting("the engine is starting (a minute or two); try again shortly")
- room = self.engine.max_context - CTX_SLACK - len(ids)
+ ctx = self.engine.max_context
+ if ctx <= 0:
+ if getattr(self.engine, "starting", False): # #344: (re)starting, not a prompt that is too long
+ raise EngineStarting("the engine is starting (a minute or two); try again shortly")
+ # a failed restart leaves max_context 0: check against the last known context, so the request reaches
+ # run() - which starts the engine again - instead of failing with "exceeds the context (0)" forever
+ ctx = getattr(self.engine, "known_ctx", 0)
+ if ctx <= 0:
+ raise EngineStarting("the engine is starting (a minute or two); try again shortly")
+ room = ctx - CTX_SLACK - len(ids)
if max_new is None or max_new <= 0 or (self.fit_max_tokens and room < 1):
if room < 1:
raise ValueError(f"prompt ({len(ids)} tokens) leaves no room to answer in the context "
- f"({self.engine.max_context}); requests are never truncated")
+ f"({ctx}); requests are never truncated")
max_new = room
elif max_new > room:
if not self.fit_max_tokens:
raise ValueError(f"prompt ({len(ids)} tokens) + max tokens ({max_new}) exceeds the context "
- f"({self.engine.max_context}); requests are never truncated. Send a smaller "
+ f"({ctx}); requests are never truncated. Send a smaller "
f"max_tokens (at most {max(0, room)} here), or add \"fit_max_tokens\": true to the "
"model's strata-.json to shorten it to the room left (#545)")
max_new = max(1, room) # --fit-max-tokens: a shorter completion beats a 400
return ids, kwargs.get("enable_thinking", True) is not False, max_new
- def _note(self, n, evs):
+ def _note(self, n, evs, st=None, rate=None):
with self.status_lock:
- s = self.status
+ s = self.status if st is None else st
s["generated"] = n
if s.get("first_token") is None:
s["first_token"] = time.time()
- self.rate.append((time.time(), n)) # the live rate's window over the last RATE_WINDOW_S
+ (self.rate if rate is None else rate).append((time.time(), n)) # the live rate's window (RATE_WINDOW_S)
for ev in evs:
if ev.kind == "reasoning":
s["phase"] = "thinking"
@@ -1411,13 +2223,13 @@ def _note(self, n, evs):
s["phase"] = "tool call complete"
s["tail"] = ((s.get("tail") or "") + (ev.text or ""))[-600:]
- def _progress(self, last_print, every=1.0):
+ def _progress(self, last_print, every=1.0, st=None):
"""A progress line in the server window every `every` seconds while a request runs."""
now = time.time()
if now - last_print < every:
return last_print
with self.status_lock:
- s = dict(self.status)
+ s = dict(self.status if st is None else st)
el = now - s.get("started", now)
if s.get("first_token") is None:
pr = getattr(self.engine, "progress", None) # (position reached, prompt tokens): a reused prefix counts
@@ -1438,6 +2250,8 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
sampling = {**defaults, **req_values}
parser = OutputParser(thinking=thinking, tools=tools, stream_tools=True)
detok, n, finish = Detokenizer(self.tok), 0, "length"
+ run_tok, run_len, repeated = None, 0, False # #606: the current run of one repeated token
+ thinking_n = 0 # tokens written while thinking (Responses' reasoning_tokens)
timings, before = None, None # this request's timings; the engine's `last` before it
raw_ids = [] # every generated id (STRATA_DEBUG: dump raw model text)
emb = getattr(self.embeddings, "path", None)
@@ -1446,10 +2260,16 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
engine_last0 = getattr(self.engine, "last", None)
trace = getattr(self.request_trace, "record", None)
waiting = time.perf_counter()
+ # #465: with "parallel" (the engine's batch slots) requests run at once: each keeps its own status and rate
+ # window (self.status says busy while any runs); one at a time they are self.status / self.rate, as before
+ par = bool(getattr(self.engine, "batch", 0))
+ st = {} if par else self.status
+ rate = collections.deque(maxlen=32) if par else self.rate
with self.status_lock:
self.status["queued"] += 1
try:
- with self.fifo:
+ # --batch: the engine runs several requests at once (StrataEngine.generate_batched orders them)
+ with (contextlib.nullcontext() if getattr(self.engine, "batch", 0) else self.fifo):
try:
with self.status_lock:
if trace is not None:
@@ -1457,13 +2277,18 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
trace["state"] = "generating"
self.status["queued"] -= 1
# issue #27: it died in an earlier request (or was unloaded) - start it again instead of failing
- self.ensure_loaded()
+ # (parallel requests do not hold the fifo: one of them starts it, the others wait for that)
+ with (self.fifo if par else contextlib.nullcontext()):
+ self.ensure_loaded()
with self.status_lock:
- self.status.update(busy=True, phase="reading the prompt", prompt_tokens=len(ids),
- generated=0, started=time.time(), first_token=None, tool=None, tail="",
- max_tokens=max_new)
+ st.update(busy=True, phase="reading the prompt", prompt_tokens=len(ids),
+ generated=0, started=time.time(), first_token=None, tool=None, tail="",
+ max_tokens=max_new)
+ if par:
+ self.live_reqs[id(st)] = (st, rate)
+ self.status.update(st)
self.last_request_at = time.time()
- self.rate.clear() # the previous request's samples must not leak into this one
+ rate.clear() # the previous request's samples must not leak into this one
before = getattr(self.engine, "last", None)
last_print = time.time()
prompt, thought = ids, 0 # thought: the reasoning tokens so far (the budget's count)
@@ -1474,7 +2299,7 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
try:
for t in gen:
if t is None: # heartbeat while the engine is quiet
- last_print = self._progress(last_print)
+ last_print = self._progress(last_print, st=st)
yield "ping", None
continue
n += 1
@@ -1487,9 +2312,15 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
break
raw_ids.append(t)
seg.append(t)
+ thinking_n += parser.state == "reasoning"
+ run_len = run_len + 1 if t == run_tok else 1
+ run_tok = t
+ if self.repeat_stop_tokens and run_len >= self.repeat_stop_tokens:
+ repeated = True # #606: a degenerate output, not an answer: end it here
+ break
evs = parser.feed(detok.push(t))
- self._note(n, evs)
- last_print = self._progress(last_print)
+ self._note(n, evs, st, rate)
+ last_print = self._progress(last_print, st=st)
for ev in evs:
yield "event", ev
if budget and parser.state == "reasoning":
@@ -1533,13 +2364,19 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
for t in extra:
n += 1
raw_ids.append(t)
+ thinking_n += parser.state == "reasoning"
evs = parser.feed(detok.push(t))
- self._note(n, evs)
+ self._note(n, evs, st, rate)
for ev in evs:
yield "event", ev
prompt = prompt + seg + extra
if cancel.is_set():
finish = "cancel"
+ elif repeated:
+ print(f"[strata] the reply repeated one token ({self.tok.decode([run_tok])!r}) "
+ f"{run_len} times in a row: ended as \"length\" (repeat_stop_tokens in "
+ "strata-.json; 0 turns this off). If a new request with a short prompt does the "
+ "same, restart the server and report it (#606)", flush=True)
except GeneratorExit: # the client disconnected mid-stream
finish = "disconnect"
raise
@@ -1547,15 +2384,20 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
# #266: settle this request's status, history and totals while still holding the fifo: once
# it is released the next request sets its own status, which this must not record or clear
with self.status_lock:
- if self.status.get("busy"):
+ if st.get("busy"):
# only this request's DONE counts: same object means no DONE arrived (death, error,
# disconnect)
last = dict(getattr(self.engine, "last", {}) or {}) \
if getattr(self.engine, "last", None) is not engine_last0 else {}
- started = self.status.get("started", time.time())
+ started = st.get("started", time.time())
cvec = (getattr(self.engine, "info", {}) or {}).get("cvec", 0)
loaded = str(cvec) not in ("0", "", "None")
hit_rate = round(last["hits"] / last["lookups"], 3) if last.get("lookups") else None
+ # #588: the share the GPU read over PCIe (--pcie-frac) or another GPU computed - not in
+ # the hit rate's lookups, which is the VRAM share of the rest (None: an older engine)
+ routed = (last.get("lookups") or 0) + (last.get("offloaded") or 0)
+ pcie_share = round(last["offloaded"] / routed, 3) \
+ if last.get("offloaded") is not None and routed else None
seen = prompt_tokens_seen(len(ids), last) # #471: < len(ids) when cancelled mid-read
self.history.append({
"projection": (sampling or {}).get("experimental_speed_projection") is not False
@@ -1568,14 +2410,11 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
"prompt_ms": last.get("prompt_ms"), "decode_ms": last.get("decode_ms"),
"decode_tok_s": round(last["generated"] / (last["decode_ms"] / 1000), 1)
if n and last.get("generated") and last.get("decode_ms") else None,
- "hit_rate": hit_rate, "ram_blobs": last.get("ram_blobs"),
+ "hit_rate": hit_rate, "pcie_share": pcie_share, "ram_blobs": last.get("ram_blobs"),
"file_blobs": last.get("file_blobs"), "file_mb": last.get("file_mb"),
# #457: the speculative drafts from the DONE line (None: the engine did not say)
"drafts_offered": last.get("drafts_offered"),
- "drafts_accepted": last.get("drafts_accepted"),
- "parked_conversations": last.get("parked_conversations"),
- "parked_bytes": last.get("parked_bytes"),
- "park_evictions": last.get("park_evictions")})
+ "drafts_accepted": last.get("drafts_accepted")})
t = self.totals
t["requests"] += 1
t["prompt_tokens"] += seen
@@ -1591,10 +2430,12 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
self.last_timings = dict(timings, at=int(time.time())) if timings else None
self.last_request_at = time.time()
now = time.time()
- el = now - self.status.get("started", now)
- ft = self.status.get("first_token")
+ el = now - st.get("started", now)
+ ft = st.get("first_token")
rate = n / max(1e-6, now - ft) if ft else 0.0
hit_msg = f", expert cache {hit_rate*100:.1f}% hit" if hit_rate is not None else ""
+ if hit_msg and pcie_share:
+ hit_msg += f" (+{pcie_share*100:.1f}% of the routed experts over PCIe)"
print(f"[strata] done: {n} tokens in {el:.0f} s ({rate:.1f} tok/s) "
f"({finish}, cancel={cancel.is_set()}){hit_msg}", flush=True)
if finish == "length" and parser.state == "reasoning": # #530
@@ -1603,16 +2444,24 @@ def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple
"strata-.json for every request) leaves room to answer", flush=True)
if os.environ.get("STRATA_DEBUG") and raw_ids:
print(f"[strata] raw: {self.tok.decode(raw_ids)!r}", flush=True)
- self.status["busy"] = False
- self.status.pop("tail", None) # #212: the answer's end is not kept once it is done
- self.status.pop("tool", None)
+ st["busy"] = False
+ st.pop("tail", None) # #212: the answer's end is not kept once it is done
+ st.pop("tool", None)
+ if par:
+ self.live_reqs.pop(id(st), None)
+ if self.live_reqs: # the newest request still running
+ self.status.update(list(self.live_reqs.values())[-1][0])
+ else:
+ self.status.update(busy=False)
+ self.status.pop("tail", None)
+ self.status.pop("tool", None)
finally:
if emb:
Path(emb).unlink(missing_ok=True)
for ev in parser.finish():
yield "event", ev
yield "done", {"finish": finish, "completion_tokens": n, "reused": (timings or {}).get("cache_n", 0),
- "timings": timings}
+ "timings": timings, "reasoning_tokens": thinking_n}
def prompt_tokens_seen(prompt_tokens: int, last: dict) -> int:
@@ -2162,6 +3011,10 @@ def do_GET(self):
if self._authorized():
self._json(200, {"shared": bool(svc.shared), "defaults": svc.shared})
return
+ if path == "/config": # #564: the Settings view's keys of the run config
+ if self._authorized():
+ self._config_get()
+ return
if path == "/mcp":
# the MCP servers, their state and tools (the web app's switch and Monitor card)
if self._authorized():
@@ -2231,6 +3084,11 @@ def do_POST(self):
if path == "/settings":
self._settings()
return
+ if path == "/config":
+ self._config_post()
+ return
+ if path in ("/unload", "/load") and not self._control_body():
+ return
# JSON from Strata's own page only, as /settings: else a plain form POST from any site unloads the model
if path in ("/unload", "/load") and not self._own_page("the model can be loaded or unloaded"):
return
@@ -2253,6 +3111,10 @@ def do_POST(self):
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
if not isinstance(req, dict):
raise ValueError("send a JSON object")
+ if path.startswith("/v1/responses/"): # retrieve/delete/cancel/compact: nothing is stored
+ self._json(404, responses_error_body("this server keeps no responses (stateless); send the "
+ "whole conversation to POST /v1/responses", code="not_found"))
+ return
if path in ("/v1/load", "/v1/unload"):
if not self._own_page("the model can be loaded or unloaded"):
return
@@ -2277,9 +3139,22 @@ def do_POST(self):
result = "loaded"
self._json(200, {"status": result, **svc.v1_status()})
return
- if path in ("/v1/chat/completions", "/v1/messages"):
+ if path == "/v1/vram": # #533: give VRAM back to other programs, or take it
+ if not self._own_page("the VRAM reserve can be changed"):
+ return
+ r = req.get("reserve_mib")
+ if r is not None and (isinstance(r, bool) or not isinstance(r, int) or r < 0):
+ raise ValueError("reserve_mib: a whole number of MiB (0 or more), or null for the start's")
+ try:
+ self._json(200, svc.vram(r))
+ except EngineDied as e:
+ self._json(503, {"error": {"type": "server_error", "message": str(e)}})
+ return
+ if path in ("/v1/chat/completions", "/v1/messages", "/v1/responses"):
self.record = svc.begin_request(path, req)
- if path == "/v1/chat/completions":
+ if path == "/v1/responses":
+ self._responses(req)
+ elif path == "/v1/chat/completions":
self._openai(req)
elif path == "/v1/messages":
self._anthropic(req)
@@ -2288,7 +3163,10 @@ def do_POST(self):
else:
self._json(404, {"error": {"message": "not found"}})
except ValueError as e:
- self._json(400, {"error": {"type": "invalid_request_error", "message": str(e)}})
+ if path == "/v1/responses":
+ self._json(400, responses_error_body(str(e)))
+ else:
+ self._json(400, {"error": {"type": "invalid_request_error", "message": str(e)}})
except ModelBusy as e:
self._json(409, {"error": {"type": "model_busy", "message": str(e)}})
except StructuredOutputError as e:
@@ -2340,6 +3218,28 @@ def _props(self):
props["build_info"] = "Strata " + str(version)
self._json(200, props)
+ def _control_body(self) -> bool:
+ """Consume the unused control body before replying/closing (Windows otherwise sends a TCP reset)."""
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ except ValueError:
+ self._json(400, {"error": {"message": "invalid Content-Length"}})
+ return False
+ if not 0 <= length <= 65536:
+ self._json(413, {"error": {"message": "control request body is limited to 64 KiB"}})
+ return False
+ timeout = self.connection.gettimeout()
+ try:
+ self.connection.settimeout(2.0)
+ complete = len(self.rfile.read(length)) == length
+ except OSError:
+ complete = False
+ finally:
+ self.connection.settimeout(timeout)
+ if not complete:
+ self._json(400, {"error": {"message": "incomplete control request body"}})
+ return complete
+
def _own_page(self, what) -> bool:
"""Only JSON (a form or a "simple" cross-site request can't send it without a CORS preflight, which this
server never grants) and no foreign Origin: a web page elsewhere must not change settings or run tools."""
@@ -2357,6 +3257,44 @@ def _own_page(self, what) -> bool:
return False
return True
+ def _config_get(self):
+ if not svc.config_path:
+ self._json(404, {"error": {"message": "this server was started without a run config"}})
+ return
+ try:
+ cfg = runconfig.load(svc.config_path)
+ except (OSError, ValueError) as e:
+ self._json(500, {"error": {"type": "server_error", "message": f"the run config cannot be read: {e}"}})
+ return
+ self._json(200, runconfig.view(cfg, svc.config_path))
+
+ def _config_post(self):
+ """#564: change a few documented keys of the run config - JSON from Strata's own page only, as
+ /settings (the key is checked before); every other key of the file stays as it is."""
+ body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
+ if not self._own_page("the run config can be changed"):
+ return
+ if not svc.config_path:
+ self._json(404, {"error": {"message": "this server was started without a run config"}})
+ return
+ try:
+ req = json.loads(body or b"{}")
+ with svc.config_lock:
+ cfg = runconfig.load(svc.config_path)
+ new, changed = runconfig.apply(cfg, req.get("set") if isinstance(req, dict) else None)
+ if changed:
+ bak = runconfig.save(svc.config_path, new)
+ except ValueError as e:
+ self._json(400, {"error": {"type": "invalid_request_error", "message": str(e)}})
+ return
+ except OSError as e:
+ self._json(500, {"error": {"type": "server_error", "message": f"the run config cannot be written: {e}"}})
+ return
+ if changed:
+ print(f"[strata] the Settings view changed {', '.join(changed)} in {Path(svc.config_path).name} "
+ f"(the earlier file: {bak.name}); used from the next start", flush=True)
+ self._json(200, {**runconfig.view(new, svc.config_path), "changed": changed})
+
def _settings(self):
# They change what every client gets, so only the app's own page may set them
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
@@ -2396,6 +3334,11 @@ def _captured(self, items, api):
delta = item["choices"][0]["delta"]
content, reasoning = delta.get("content", ""), delta.get("reasoning_content", "")
usage, timings = item.get("usage"), item.get("timings")
+ elif api == "responses":
+ kind = item["type"]
+ content = item["delta"] if kind == "response.output_text.delta" else ""
+ reasoning = item["delta"] if kind == "response.reasoning_text.delta" else ""
+ usage, timings = (item.get("response") or {}).get("usage"), None
else:
_, event = item
delta = event.get("delta", {})
@@ -2424,7 +3367,7 @@ def _openai(self, req):
svc.load()
max_req = max_new = int(req.get("max_completion_tokens") or req.get("max_tokens") or 0) # 0/-1: the rest
use_mcp = req.get("strata_mcp") is True and svc.mcp is not None # the web app's opt-in (serve/mcp.py)
- own = {t.get("name") for t in tools or []}
+ own = {t.get("name") for t in tools or [] if isinstance(t, dict)} # #592: a second line of defence
if use_mcp:
if not self._own_page("MCP tools can be used"): # tools run with the user's rights on this PC
return
@@ -2471,13 +3414,133 @@ def _openai(self, req):
self._note(error=err["error"])
self.wfile.write(b"data: " + json.dumps(err).encode() + b"\n\ndata: [DONE]\n\n")
+ def _responses(self, req):
+ """#451: POST /v1/responses - OpenAI's Responses API, stateless (serve/responses.py), on the chat path.
+ Errors use the Responses format; once the stream has started they arrive as a response.failed event."""
+ try:
+ ids, thinking, tools, max_new, asm, validator, req = self._responses_prepare(req)
+ except ResponsesError as e:
+ return self._json(e.status, e.body())
+ except ModelBusy as e:
+ return self._json(409, responses_error_body(str(e), "server_error", code="model_busy"))
+ except (GpuBusy, EngineStarting, EngineStuck, EngineDied) as e:
+ return self._json(503, responses_error_body(str(e), "server_error", code="server_error"))
+ cancel = threading.Event()
+ self._watch_client(cancel) # #430 #431
+
+ def check(text, finish):
+ return validated_json(text, validator, finish)
+
+ def events():
+ yield from asm.start()
+ done = None
+ for kind, x in svc.run(ids, thinking, tools, max_new, req, cancel):
+ if kind == "ping":
+ yield None
+ elif kind == "event":
+ yield from asm.feed(x)
+ elif kind == "done":
+ done = x
+ if done is not None and done["finish"] != "cancel" and not cancel.is_set():
+ yield from asm.finish(done, check)
+ items = self._capture(events(), "responses")
+ if not req.get("stream"):
+ try:
+ result = responses_api.collect(items)
+ except StructuredOutputError as e:
+ return self._json(502, responses_error_body(str(e), "server_error",
+ code="structured_output_failed"))
+ except EngineDied as e:
+ return self._json(503, responses_error_body(f"{e}; the next request restarts it", "server_error",
+ code="server_error"))
+ except ValueError as e: # the engine's ERR line
+ return self._json(500, responses_error_body(str(e), "server_error", code="server_error"))
+ return self._json(200, result)
+ self._sse()
+ last = time.monotonic()
+
+ def send(e):
+ self.wfile.write(f"event: {e['type']}\n".encode() + b"data: " +
+ json.dumps(e, ensure_ascii=False).encode() + b"\n\n")
+
+ try:
+ for e in items:
+ if e is None:
+ # Codex's idle timeout (5 minutes by default) counts events, not SSE comments: while a long
+ # prompt is read, a response.in_progress every 15 s tells it the server is still working
+ if time.monotonic() - last >= 15:
+ send(asm.in_progress())
+ last = time.monotonic()
+ else:
+ self.wfile.write(b": keep-alive\n\n")
+ else:
+ send(e)
+ last = time.monotonic()
+ self.wfile.flush()
+ except OSError:
+ self._note(outcome="disconnected")
+ cancel.set() # client went away: stop the engine
+ items.close()
+ except EngineDied as e: # mid-stream: response.failed, then the stream ends
+ self._responses_failed(asm, f"{e}; the next request restarts it", "server_error", send)
+ except StructuredOutputError as e:
+ self._responses_failed(asm, str(e), "structured_output_failed", send)
+ except ValueError as e: # the engine's ERR after the stream started
+ self._responses_failed(asm, str(e), "server_error", send)
+
+ def _responses_failed(self, asm, message, code, send):
+ e = asm.failed(message, code)
+ self._note(error=e["response"]["error"])
+ try:
+ send(e)
+ self.wfile.flush()
+ except OSError:
+ pass
+
+ def _responses_prepare(self, req):
+ responses_api.check_request(req)
+ messages = responses_api.input_messages(req)
+ tools, names, skipped = responses_api.request_tools(req)
+ kw = responses_api.template_kwargs(req, svc.shared)
+ try:
+ messages, validator = prepare_format(responses_api.text_format(req), messages)
+ except ValueError as e:
+ raise ResponsesError(str(e).replace("response_format", "text.format"), "text.format") from None
+ if validator is not None and tools:
+ raise ResponsesError("a JSON text.format with tools is not supported", "text.format",
+ "unsupported_parameter")
+ noted = svc.__dict__.setdefault("hosted_tools_noted", set()) # said once per tool, not per request
+ if set(skipped) - noted:
+ print(f"[strata] /v1/responses: left out the hosted tools {', '.join(sorted(set(skipped) - noted))} "
+ f"(this server cannot run them)", flush=True)
+ noted.update(skipped)
+ if req.get("max_output_tokens") is None and "max_tokens" in svc.shared: # the shared Chat settings
+ req = {**req, "max_output_tokens": svc.shared["max_tokens"]}
+ try:
+ svc.reasoning_budget(req) # a bad value is a 400 before anything is sent
+ except ValueError as e:
+ raise ResponsesError(str(e), "reasoning_budget_tokens") from None
+ svc.load()
+ try:
+ ids, thinking, max_new = svc.prepare(messages, tools, kw, req.get("max_output_tokens") or 0)
+ except ResponsesError:
+ raise
+ except ValueError as e: # too long for the context, an image without vision
+ raise ResponsesError(str(e), "input", "context_length_exceeded" if "context" in str(e) else None) \
+ from None
+ _debug_req("responses", req, messages, tools, max_new, thinking, len(ids))
+ include = req.get("include") if isinstance(req.get("include"), list) else []
+ asm = responses_api.Assembler(req, svc.model_for(req), len(ids), names,
+ "reasoning.encrypted_content" in include, validator is not None)
+ # the request is also the sampling dict, as on the chat path (temperature, top_p, top_k, seed, ...)
+ return ids, thinking, tools, max_new, asm, validator, req
+
def _count_tokens(self, req):
"""Anthropic's token count, which Claude Code asks for its context figures: the prompt this server would
read for the same request, rendered and tokenized - the model does not run."""
req = svc.with_shared(req, "anthropic")
messages, tools, kw = anthropic_to_messages(req, svc.anthropic_think_unasked)
- prompt = svc.template.render(messages, tools=tools, **kw)
- self._json(200, {"input_tokens": len(svc.tok.encode(prompt, parse_special=True))})
+ self._json(200, {"input_tokens": len(svc.encode_prompt(messages, tools, kw))})
def _anthropic(self, req):
svc.load()
@@ -2919,7 +3982,11 @@ def main() -> int:
print("model unloaded; the first request loads it ..." if lazy else
"loading the model (the first start takes a minute or two) ...", flush=True)
if len(gpu_list(cfg)) > 1:
- print(f"[strata] layer split across GPUs {gpu_list(cfg)} ({cfg.get('layer_split') or 'auto'})", flush=True)
+ try:
+ split = layer_split_value(cfg) # #644: before the (minutes-long) start
+ except ValueError as e:
+ raise SystemExit(f"[strata] config {e}")
+ print(f"[strata] layer split across GPUs {gpu_list(cfg)} ({split})", flush=True)
# a relative "exe" is the config's cwd's: Windows' CreateProcess resolves "engine/strata.exe" against nothing
# it is told about (WinError 2), so it is made absolute here
exe = cfg["exe"] if os.path.isabs(cfg["exe"]) else os.path.abspath(os.path.join(cfg.get("cwd") or ".", cfg["exe"]))
@@ -2927,7 +3994,12 @@ def main() -> int:
silence = engine_silence_s(cfg) # #481: checked before the (minutes-long) start
except ValueError as e:
raise SystemExit(f"[strata] config {e}")
- engine = StrataEngine(exe, engine_args(cfg), cwd=cfg.get("cwd"), log=cfg.get("log"), env=env, lazy=lazy)
+ try:
+ effort_end = effort_end_args(cfg, exe, tok) # #458
+ except ValueError as e:
+ raise SystemExit(f"[strata] config {e}")
+ engine = StrataEngine(exe, engine_args(cfg) + (effort_end or []), cwd=cfg.get("cwd"), log=cfg.get("log"),
+ env=env, lazy=lazy)
engine.silence_s = silence # an attribute of its own: restart() keeps it
warn_tight_ram(engine.info.get("arena_mib"))
note = desktop_vram_note(cfg.get("backend"), engine.info.get("vram_free_mib"), engine.spawn[1],
@@ -2935,6 +4007,7 @@ def main() -> int:
if note: # #560 #516: before --open starts a browser on that card
print(note, flush=True)
else:
+ effort_end = None
engine, vision, sampling_defaults = MockEngine(tok, a.script or [
"Thinking about it.\n\nHello from the mock engine."]), None, {}
# the model's own chat template (exported with its tokenizer), else the original model's
@@ -2982,6 +4055,11 @@ def main() -> int:
if mode not in ("model", "on_request"):
raise SystemExit(f"[strata] config anthropic_thinking must be \"model\" or \"on_request\", not {mode!r}")
svc.anthropic_think_unasked = mode == "model"
+ rs = cfg.get("repeat_stop_tokens", REPEAT_STOP_TOKENS) # #606: opt-out with 0
+ if isinstance(rs, bool) or not isinstance(rs, int) or rs < 0:
+ raise SystemExit(f"[strata] config \"repeat_stop_tokens\" must be a whole number >= 0 (0 = off), not {rs!r}")
+ svc.repeat_stop_tokens = rs
+ svc.effort_end = bool(effort_end) # #458: "effort_position": "end" with an engine that has it
if cfg.get("reasoning_budget_tokens") is not None: # #123: a default thinking budget for every request
try:
svc.reasoning_budget_tokens = cfg["reasoning_budget_tokens"]
@@ -2994,6 +4072,8 @@ def main() -> int:
svc.gpu_index = (gpu_list(cfg) or [0])[0] # the Monitor reads the card the engine runs on (issue #51)
svc.gpu_indices = gpu_list(cfg) # ... or every card of a layer split (issue #112)
svc.backend = cfg.get("backend") # "hip": the AMD cards' readings come from sysfs (#301)
+ if a.config:
+ svc.config_path = a.config # #564: the web page's Settings view
if a.config: # the Chat settings shared with other apps, from last time
svc.shared_path = str(Path(a.config).with_suffix("")) + ".shared-settings.json"
try:
@@ -3032,8 +4112,8 @@ def main() -> int:
"in an admin PowerShell:\n New-NetFirewallRule -DisplayName \"Strata " + str(a.port) + "\" "
"-Direction Inbound -Protocol TCP -LocalPort " + str(a.port) + " -Action Allow -Profile Private\n"
" (and set this network to Private in Windows' network settings)", flush=True)
- if a.open:
- import webbrowser
+ if a.open and cfg.get("open_browser") is not False: # #609: the config's "open_browser": false wins (an older
+ import webbrowser # run-.bat still passes --open)
webbrowser.open(f"http://{'127.0.0.1' if a.host in ('0.0.0.0', '') else a.host}:{a.port}/")
# #96: docker stop sends SIGTERM, which Python ignores by default, so the container's PID 1 would be killed after
# the grace period with the engine still running. SIGTERM takes Ctrl+C's path below (QUIT to the engine).
diff --git a/serve/test_monitor.py b/serve/test_monitor.py
index ad554e0006..0c82cb0b53 100644
--- a/serve/test_monitor.py
+++ b/serve/test_monitor.py
@@ -194,5 +194,68 @@ def test_page_and_relative_assets_are_served(self):
self.assertEqual(self.request("/web/../server.py")[0], 404)
+class ConversationCacheCard(unittest.TestCase):
+ """#596: /metrics' "conversation_cache": the parked conversations from the engine's log lines, the budget and
+ slots from its INFO line, and how much of the prompts the cache gave back."""
+
+ PARK = ("strata serve: conversation cache: parked 5000 tokens in 12.0 ms; parked=1 bytes=104857600 evictions=0 "
+ "snapshot_bytes=104857600 reused_kv_bytes=0\n")
+ RESTORE = "strata serve: conversation cache: restored 4000 tokens (exact) in 30.0 ms; parked=2 bytes=209715200\n"
+
+ def test_the_log(self):
+ from serve.server import ConvCacheLog
+ import tempfile
+ with tempfile.TemporaryDirectory() as d:
+ log = Path(d) / "strata.log"
+ log.write_text("an earlier run\n" + self.PARK, encoding="utf-8")
+ start = log.stat().st_size # this run starts here: the earlier run's park is not counted
+ c = ConvCacheLog()
+ self.assertEqual(c.poll(str(log), start)["parked"], 0)
+ with open(log, "a", encoding="utf-8") as f:
+ f.write("strata serve: something else\n" + self.PARK + self.RESTORE + "strata serve: conversation ca")
+ st = c.poll(str(log), start)
+ self.assertEqual((st["parked"], st["bytes"], st["parks"], st["restores"]), (2, 209715200, 1, 1))
+ self.assertEqual((st["last_event"], st["last_tokens"]), ("restored", 4000))
+ with open(log, "a", encoding="utf-8") as f: # the cut line is read once it is whole
+ f.write("che: dropped 1 superseded copy of this conversation; parked=1\n")
+ self.assertEqual(c.poll(str(log), start)["parked"], 1)
+ self.assertEqual(c.poll(str(log), log.stat().st_size)["parked"], 0) # the engine started again
+ self.assertEqual(c.poll(None, None)["parked"], 0)
+
+ def test_metrics(self):
+ tok = ByteTokenizer()
+ engine = MockEngine(tok, "Thought.\nHello.", max_context=4096)
+ engine.info = {"conversation_cache_mib": 8192, "conversation_cache_slots": 4}
+ svc = Service(engine, tok, ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
+ httpd = serve(svc, port=0)
+ base = f"http://127.0.0.1:{httpd.server_address[1]}"
+ try:
+ with urllib.request.urlopen(base + "/metrics", timeout=10) as r:
+ c = json.loads(r.read())["conversation_cache"]
+ self.assertEqual((c["enabled"], c["budget_mib"], c["slots"], c["parked"], c["requests"]),
+ (True, 8192, 4, 0, 0))
+ req = urllib.request.Request(base + "/v1/chat/completions", headers={"Content-Type": "application/json"},
+ data=json.dumps({"messages": [{"role": "user", "content": "hi"}]}).encode())
+ with urllib.request.urlopen(req, timeout=10) as r:
+ r.read()
+ with urllib.request.urlopen(base + "/metrics", timeout=10) as r:
+ c = json.loads(r.read())["conversation_cache"]
+ self.assertEqual(c["requests"], 1)
+ self.assertGreater(c["last_prompt"], 0)
+ engine.info = {}
+ with urllib.request.urlopen(base + "/metrics", timeout=10) as r:
+ self.assertFalse(json.loads(r.read())["conversation_cache"]["enabled"]) # off: the reuse lines only
+ finally:
+ httpd.shutdown()
+ httpd.server_close()
+
+ def test_the_card_is_on_the_page(self):
+ web = Path(__file__).parent / "web"
+ html, js = (web / "index.html").read_text(encoding="utf-8"), (web / "app.js").read_text(encoding="utf-8")
+ for el in ("cc-card", "cc-slots-text", "cc-mem-text", "cc-facts", "cc-note"):
+ self.assertIn(f'id="{el}"', html)
+ self.assertIn(f'"{el}"', js)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/serve/test_multigpu_server.py b/serve/test_multigpu_server.py
index 221d766363..6219507917 100644
--- a/serve/test_multigpu_server.py
+++ b/serve/test_multigpu_server.py
@@ -132,6 +132,27 @@ def test_bound_lane_config_uses_single_physical_gpu_for_telemetry(self):
self.assertNotIn("layer_split", got)
self.assertNotIn("--layer-split", got["args"])
+ def test_lane_config_strips_upstream_internal_parallelism(self):
+ cfg = {
+ "parallel": 4,
+ "args": [
+ "--pack", "/m",
+ "--batch", "4",
+ "--slots", "3",
+ "--batch-groups", "2",
+ "--trim-stage-weights",
+ ],
+ }
+ got = M.sanitize_lane_config(cfg)
+ self.assertNotIn("parallel", got)
+ self.assertNotIn("--batch", got["args"])
+ self.assertNotIn("--slots", got["args"])
+ self.assertNotIn("--batch-groups", got["args"])
+ self.assertNotIn("--trim-stage-weights", got["args"])
+
+ def test_responses_api_is_generation_work(self):
+ self.assertIn("/v1/responses", M.GENERATE_PATHS)
+
def test_lane_config_disables_upstream_conversation_parking(self):
cfg = {"args": ["--pack", "/m", "--conversation-cache-mib", "8192",
"--conversation-cache-slots", "4"]}
diff --git a/serve/test_parallel.py b/serve/test_parallel.py
new file mode 100644
index 0000000000..a970fffae7
--- /dev/null
+++ b/serve/test_parallel.py
@@ -0,0 +1,382 @@
+"""#465: "parallel": N - several requests decode together in the engine's batch slots (--batch N).
+
+A fake engine (a Python script speaking the engine's GEN / BGEN / BT / BDONE / BADM / BSTOP lines) runs behind the
+real StrataEngine and Service, so the server's side is tested without a GPU: requests up to N run at once, more
+wait for a slot, /metrics shows the slots, each request's history row is its own, and a conversation's next turn
+goes back to the slot that holds it."""
+import json
+import sys
+import tempfile
+import threading
+import time
+import unittest
+import urllib.request
+from pathlib import Path
+from unittest import mock
+
+from serve.frontend import ChatTemplate
+from serve.server import ByteTokenizer, Service, StrataEngine, engine_args, parallel_args, serve
+
+# The fake engine: one token every STEP seconds per active slot (a "window" serves every active slot at once);
+# GEN (solo) streams T lines; BGEN reads the prompt (one T line, DONE), answers BADM and continues in the slot.
+# Every reply is the bytes of "ok, done." then <|im_end|> (ByteTokenizer: 257).
+FAKE_BATCH = r'''import queue, sys, threading, time
+args = sys.argv[1:]
+slots = int(args[args.index("--batch") + 1]) if "--batch" in args else 0
+fit = int(args[args.index("--fit") + 1]) if "--fit" in args else slots
+STEP = 0.02
+CH = 32
+lines, stop = queue.Queue(), threading.Event()
+def reader():
+ for l in sys.stdin:
+ l = l.strip()
+ if l == "STOP":
+ stop.set()
+ else:
+ lines.put(l)
+ lines.put(None)
+threading.Thread(target=reader, daemon=True).start()
+print("INFO engine=0.1.39" + (f" batch_slots={fit}" if fit >= 2 else "") +
+ (" slot_cache=1" if "--slotcache" in args else ""), flush=True)
+print("READY 4096 stop", flush=True)
+LONG = list(b"LONGREPLY")
+def reply(ids): # the rest of the reply after what the prompt already ends with (a request continued)
+ ids = [int(x) for x in ids]
+ long_one = any(ids[i:i + len(LONG)] == LONG for i in range(len(ids)))
+ R = list(b"ok, " + b"la " * 15 + b"done." if long_one else b"ok, done.") + [257]
+ k = max(k for k in range(len(R)) if k == 0 or ids[-k:] == R[:k])
+ return R[k:]
+active = {} # slot -> [tokens left, max_new, produced]
+stopped = set() # BSTOPped slots: they end "cancel"
+def window(): # one batch window: every active slot one token
+ for b in sorted(active):
+ left, max_new, produced = active[b]
+ t = left.pop(0) if left else 257
+ produced += 1
+ print(f"BT {b} {t}", flush=True)
+ if t == 257 or produced >= max_new:
+ fin = 'stop' if t == 257 else 'cancel' if b in stopped else 'length'
+ stopped.discard(b)
+ print(f"BDONE {b} {produced} {fin} 1.0", flush=True)
+ del active[b]
+ else:
+ active[b] = [left, max_new, produced]
+log = open(args[args.index("--log") + 1], "a") if "--log" in args else None
+while True:
+ try:
+ line = lines.get(timeout=STEP if active else None)
+ except queue.Empty:
+ line = ""
+ if line is None or line == "QUIT":
+ break
+ if line.startswith("BYIELD "):
+ continue # too late: the read it was for has ended
+ if line.startswith("BSTOP "):
+ b = int(line.split()[1])
+ if b in active:
+ active[b][1] = 0
+ stopped.add(b)
+ continue
+ if line.startswith(("GEN ", "BGEN ")):
+ f = line.split()
+ slot = int(f[1]) if f[0] == "BGEN" else None
+ max_new = int(f[2] if f[0] == "BGEN" else f[1])
+ ids = f[-1].split(",")
+ if log:
+ log.write(f"{f[0]} {slot} {len(ids)} {max(len(active), 0)}\n"); log.flush()
+ toks = reply(ids)
+ stop.clear()
+ # the prompt read: CH tokens a chunk, a PP line each; a BYIELD gives way at a chunk boundary
+ given = None
+ for q in range(CH, len(ids) - 1, CH):
+ time.sleep(STEP / 2)
+ print(f"PP {q} {len(ids)} 1 1", flush=True)
+ held = []
+ while True:
+ try:
+ l2 = lines.get_nowait()
+ except queue.Empty:
+ break
+ if l2 and l2.startswith("BYIELD "):
+ ys = int(l2.split()[1])
+ if len(ids) - 1 - q > CH and (slot is None or ys == slot) and given is None:
+ given = (ys, q)
+ else:
+ held.append(l2)
+ for l2 in held:
+ lines.put(l2)
+ if given:
+ break
+ if active and (q // CH) % 4 == 0: # the slots decode between the prompt's chunks (as the engine)
+ window()
+ if given:
+ if log:
+ log.write(f"YIELD {given[0]} {given[1]}\n"); log.flush()
+ print(f"YIELDED {given[0]} {given[1]}", flush=True)
+ print(f"DONE 0 {len(ids)} 5.0 0.0 cancel 0 0 0", flush=True)
+ if slot is not None:
+ print(f"BADM {slot} 0", flush=True)
+ continue
+ n = 1 if slot is not None else max_new
+ out = 0
+ for t in toks[:n]:
+ if stop.is_set():
+ break
+ print(f"T {t}", flush=True)
+ out += 1
+ time.sleep(STEP)
+ fin = "stop" if out and toks[out - 1] == 257 else ("cancel" if stop.is_set() else "length")
+ print(f"DONE {out} {len(ids)} 5.0 {out * STEP * 1000:.1f} {fin} 0 0 0", flush=True)
+ if slot is not None:
+ cont = fin == "length" and max_new > 1
+ if cont:
+ active[slot] = [toks[1:], max_new, 1]
+ print(f"BADM {slot} {1 if cont else 0}", flush=True)
+ continue
+ if line:
+ print("ERR unknown " + line[:20], flush=True)
+ continue
+ window()
+'''
+
+
+class ParallelArgs(unittest.TestCase):
+ def test_parallel_config_key(self):
+ self.assertEqual(parallel_args({"parallel": 4}, []), ["--batch", "4"])
+ self.assertEqual(parallel_args({"parallel": 1}, []), [])
+ self.assertEqual(parallel_args({}, []), [])
+ self.assertEqual(parallel_args({"parallel": 2}, ["--batch", "3"]), []) # the args' own value wins
+ self.assertEqual(parallel_args({"parallel": "4"}, []), []) # said and ignored
+ self.assertEqual(parallel_args({"parallel": True}, []), [])
+ self.assertEqual(parallel_args({"parallel": 12}, []), ["--batch", "12"]) # the engine warns and caps
+ args = engine_args({"args": ["--pack", "p"], "parallel": 2})
+ self.assertEqual(args[-2:], ["--batch", "2"])
+
+
+class PickSlot(unittest.TestCase):
+ def engine(self, n):
+ e = StrataEngine("missing-executable", [], lazy=True)
+ e.batch = n
+ e.slot_order = list(range(n))
+ e.slot_busy = [False] * n
+ e.slot_held = [[] for _ in range(n)]
+ e.slot_used = [0.0] * n
+ e.slot_live = [None] * n
+ return e
+
+ def test_the_slot_that_holds_the_conversation(self):
+ e = self.engine(3)
+ e.slot_held = [[1, 2, 3], [], [1, 2, 3, 4, 5]]
+ e.slot_used = [5.0, 0.0, 9.0]
+ self.assertEqual(e.pick_slot([1, 2, 3, 4, 5, 6]), 2) # the longest held start
+ self.assertEqual(e.pick_slot([1, 2, 3, 9]), 0)
+ self.assertEqual(e.pick_slot([7, 8]), 1) # no match: the empty slot first
+ e.slot_busy[1] = True
+ self.assertEqual(e.pick_slot([7, 8]), 0) # then the one used longest ago
+ e.slot_busy = [True] * 3
+ self.assertIsNone(e.pick_slot([7, 8]))
+ e.slot_busy = [False] * 3
+ self.assertEqual(e.pick_slot([1, 2, 3]), 1) # a held prompt is never the WHOLE prompt
+
+ def test_slots_view(self):
+ e = self.engine(2)
+ e.slot_held[1] = [1, 2]
+ e.slot_live[0] = {"slot": 0, "state": "decoding", "prompt_tokens": 9, "generated": 4,
+ "started": time.time() - 2, "first_token": time.time() - 1}
+ v = e.slots_view()
+ self.assertEqual(v[0]["state"], "decoding")
+ self.assertEqual(v[0]["generated"], 4)
+ self.assertEqual(v[1], {"slot": 1, "state": "idle", "held_tokens": 2})
+
+
+class ParallelService(unittest.TestCase):
+ """The real StrataEngine and Service over HTTP, the fake engine behind them."""
+
+ def start(self, slots, fit=None, slot_cache=False):
+ import serve.server as server
+ self.tmp = tempfile.TemporaryDirectory()
+ script = Path(self.tmp.name) / "fake_strata.py"
+ script.write_text(FAKE_BATCH, encoding="utf-8")
+ self.log = Path(self.tmp.name) / "requests.log"
+ real = server.subprocess.Popen
+ extra = ["--batch", str(slots)] + (["--fit", str(fit)] if fit is not None else []) + ["--log", str(self.log)]
+ extra += ["--slotcache"] if slot_cache else []
+ with mock.patch.object(server.subprocess, "Popen",
+ lambda cmd, **kw: real([sys.executable, str(script), *cmd[1:]], **kw)):
+ self.engine = StrataEngine("strata", extra)
+ tok = ByteTokenizer()
+ self.svc = Service(self.engine, tok, ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
+ self.httpd = serve(self.svc, port=0)
+ self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
+
+ def tearDown(self):
+ if getattr(self, "httpd", None):
+ self.httpd.shutdown()
+ self.httpd.server_close()
+ if getattr(self, "engine", None):
+ self.engine.unload()
+ if getattr(self, "tmp", None):
+ self.tmp.cleanup()
+
+ def chat(self, text, max_tokens=64):
+ body = {"messages": [{"role": "user", "content": text}], "max_tokens": max_tokens, "reasoning_effort": "none"}
+ req = urllib.request.Request(self.base + "/v1/chat/completions", data=json.dumps(body).encode(),
+ headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=60) as r:
+ return json.loads(r.read().decode())
+
+ def get(self, path):
+ with urllib.request.urlopen(self.base + path, timeout=10) as r:
+ return json.loads(r.read().decode())
+
+ def test_engine_reports_fewer_slots(self):
+ self.start(4, fit=2)
+ self.assertEqual(self.engine.batch, 2)
+ self.assertEqual(self.get("/v1/status")["concurrency"]["serving"], 2)
+
+ def test_engine_turns_batching_off(self):
+ self.start(4, fit=0)
+ self.assertEqual(self.engine.batch, 0)
+ self.assertEqual(self.chat("hi")["choices"][0]["message"]["content"], "ok, done.")
+
+ def test_concurrent_requests_share_the_slots(self):
+ self.start(2)
+ # the first one decodes long enough for the others to arrive while it runs (also on a busy PC)
+ texts = ["first question LONGREPLY", "second question, longer", "a third one waits for a slot"]
+ reply = {t: "ok, " + "la " * 15 + "done." if "LONGREPLY" in t else "ok, done." for t in texts}
+ out, errors, seen = {}, [], []
+
+ def go(t):
+ try:
+ out[t] = self.chat(t)
+ except Exception as e: # noqa: BLE001 - reported below
+ errors.append(e)
+
+ threads = [threading.Thread(target=go, args=(t,)) for t in texts]
+ for th in threads:
+ th.start()
+ time.sleep(0.05)
+ deadline = time.time() + 30
+ while any(th.is_alive() for th in threads) and time.time() < deadline:
+ m = self.get("/metrics")["live"]
+ seen.append(m)
+ time.sleep(0.01)
+ for th in threads:
+ th.join(30)
+ self.assertEqual(errors, [])
+ for t in texts:
+ self.assertEqual(out[t]["choices"][0]["message"]["content"], reply[t])
+ # the slots were reported, and a request was admitted while another decoded in a slot (the fake logs how many
+ # slots were active at each admission)
+ self.assertTrue(any(m.get("parallel") == 2 and len(m.get("slots", [])) == 2 for m in seen))
+ admitted = [int(x.split()[3]) for x in self.log.read_text().splitlines() if x.startswith("BGEN")]
+ self.assertIn(1, admitted)
+ # every request recorded its own row; nothing is left running
+ with self.svc.status_lock:
+ self.assertEqual(len(self.svc.history), 3)
+ self.assertEqual(self.svc.live_reqs, {})
+ self.assertFalse(self.svc.status["busy"])
+ self.assertEqual(sorted(r["output_tokens"] for r in self.svc.history),
+ sorted(len(reply[t]) + 1 for t in texts))
+ # never more than two in the slots at once (the fake logs how many were active at each admission)
+ active = [int(x.split()[3]) for x in self.log.read_text().splitlines() if x.startswith("BGEN")]
+ self.assertTrue(all(a <= 1 for a in active), active)
+
+ def race(self, first, second, delay=0.15, max_tokens=64):
+ """`first` sent, `second` after `delay` seconds; -> {text: (reply, seconds it took)}"""
+ res = {}
+
+ def go(t):
+ t0 = time.time()
+ r = self.chat(t, max_tokens=max_tokens)
+ res[t] = (r["choices"][0]["message"]["content"], time.time() - t0)
+ a = threading.Thread(target=go, args=(first,))
+ a.start()
+ time.sleep(delay)
+ go(second)
+ a.join(30)
+ return res
+
+ def test_a_long_prompt_gives_way_to_a_short_one(self):
+ """#656: a long solo prompt read gives way at a chunk boundary; the short request is answered first, then the
+ long one goes on (in the slot its part waited in) and is answered too."""
+ self.start(2)
+ long_q, short_q = "long " * 600, "short"
+ res = self.race(long_q, short_q)
+ self.assertEqual(res[long_q][0], "ok, done.")
+ self.assertEqual(res[short_q][0], "ok, done.")
+ # the long read takes ~1 s (3,000 tokens, 32 a chunk, 10 ms a chunk): the short one did not wait for it
+ self.assertLess(res[short_q][1], res[long_q][1] - 0.5, res)
+ log = self.log.read_text().splitlines()
+ self.assertTrue(any(x.startswith("YIELD ") for x in log), log)
+ with self.svc.status_lock:
+ self.assertEqual(len(self.svc.history), 2)
+ self.assertFalse(any(self.engine.slot_busy))
+
+ def test_an_admission_gives_way_too(self):
+ """The same while another request decodes in a slot: the long one is being admitted, a short one waits."""
+ self.start(3)
+ bg = threading.Thread(target=lambda: self.chat("background", max_tokens=60))
+ bg.start()
+ time.sleep(0.05)
+ long_q, short_q = "long " * 600, "short"
+ res = self.race(long_q, short_q)
+ bg.join(30)
+ self.assertEqual(res[long_q][0], "ok, done.")
+ self.assertLess(res[short_q][1], res[long_q][1] - 0.5, res)
+ log = self.log.read_text().splitlines()
+ self.assertTrue(any(x.startswith("YIELD ") for x in log), log)
+ self.assertFalse(any(self.engine.slot_busy))
+
+ def test_no_way_given_to_a_long_one(self):
+ """Two prompts of about the same length: no BYIELD (it only goes to a much shorter one)."""
+ self.start(2)
+ res = self.race("long " * 600, "lung " * 600)
+ self.assertEqual(sorted(r[0] for r in res.values()), ["ok, done."] * 2)
+ self.assertFalse(any(x.startswith("YIELD ") for x in self.log.read_text().splitlines()))
+
+ def test_left_alone_it_goes_back_to_the_solo_path(self):
+ """A request decoding in a slot whose neighbour finished goes back to the solo path (MTP drafts), continued
+ from its slot - only with an engine that keeps the slots' conversations (INFO slot_cache=1)."""
+ self.start(2, slot_cache=True)
+ res = {}
+ long = threading.Thread(target=lambda: res.setdefault("long", self.chat("LONGREPLY please", max_tokens=200)))
+ long.start()
+ time.sleep(0.05)
+ short = self.chat("short", max_tokens=200)
+ long.join(30)
+ self.assertEqual(short["choices"][0]["message"]["content"], "ok, done.")
+ self.assertEqual(res["long"]["choices"][0]["message"]["content"], "ok, " + "la " * 15 + "done.")
+ kinds = [x.split()[0] for x in self.log.read_text().splitlines()]
+ # solo, then both in slots, then the long one alone again: a GEN after the BGENs
+ self.assertIn("BGEN", kinds)
+ self.assertEqual(kinds[-1], "GEN", kinds)
+ self.assertFalse(any(self.engine.slot_busy))
+ with self.svc.status_lock:
+ self.assertEqual(len(self.svc.history), 2)
+
+ def test_next_turn_goes_back_to_its_slot(self):
+ self.start(2)
+ a = "conversation A, first turn"
+ r1 = {}
+ th = threading.Thread(target=lambda: r1.setdefault("x", self.chat("blocker LONGREPLY", max_tokens=200)))
+ th.start()
+ time.sleep(0.05)
+ first = self.chat(a)
+ th.join(30)
+ deadline = time.time() + 10 # a slot left at its stop token is freed at its BDONE
+ while not [h for h in self.engine.slot_held if h] and time.time() < deadline:
+ time.sleep(0.02)
+ held = [h for h in self.engine.slot_held if h]
+ self.assertTrue(held, "a finished slot request keeps what its slot holds")
+ # the held tokens are the prompt and every token fed: all of the reply but its last token
+ reply = list(b"ok, done.") + [257]
+ mine = [h for h in held if h[-len(reply) + 1:] == reply[:-1] and bytes(x for x in h if x < 256).find(b"conversation A") >= 0]
+ self.assertEqual(len(mine), 1, held)
+ slot = self.engine.slot_held.index(mine[0])
+ self.assertEqual(self.engine.pick_slot(mine[0] + [10, 11]), slot)
+ self.assertEqual(first["choices"][0]["message"]["content"], "ok, done.")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/serve/test_responses.py b/serve/test_responses.py
new file mode 100644
index 0000000000..2bae986966
--- /dev/null
+++ b/serve/test_responses.py
@@ -0,0 +1,361 @@
+"""serve/test_responses.py - #451: the Responses API (POST /v1/responses) against the mock engine (no GPU, no pack).
+
+ python -m unittest serve.test_responses -v
+"""
+from __future__ import annotations
+
+import base64
+import contextlib
+import http.client
+import io
+import json
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from serve.frontend import ChatTemplate # noqa: E402
+from serve.responses import (ENCRYPTED_PREFIX, ResponsesError, input_messages, request_tools, # noqa: E402
+ template_kwargs, text_format)
+from serve.server import ByteTokenizer, MockEngine, Service, serve # noqa: E402
+
+ROOT = Path(__file__).resolve().parents[1]
+TEMPLATE = ChatTemplate(ROOT / "serve/chat_template.jinja")
+
+CALL = ("Let me look.\n\n\nChecking.\n\n\n\n\ncat a.txt\n"
+ "\n\n")
+ANSWER = "Read it.\n\n\nThe file says before."
+TOOLS = [{"type": "function", "name": "exec_command", "description": "Runs a command.", "strict": False,
+ "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}, "required": ["cmd"]}}]
+
+
+# ------------------------------------------------------------------------------------------------ parsing
+class Parsing(unittest.TestCase):
+ def test_a_string_input_is_one_user_message(self):
+ self.assertEqual(input_messages({"input": "hi", "instructions": "Be brief."}),
+ [{"role": "system", "content": "Be brief."}, {"role": "user", "content": "hi"}])
+
+ def test_leading_developer_messages_join_the_system_message_later_ones_become_user(self):
+ msgs = input_messages({"instructions": "I", "input": [
+ {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "D1"},
+ {"type": "input_text", "text": "D2"}]},
+ {"role": "user", "content": "u"},
+ {"type": "message", "role": "developer", "content": "late"}]})
+ self.assertEqual(msgs, [{"role": "system", "content": "I\n\nD1D2"}, {"role": "user", "content": "u"},
+ {"role": "user", "content": "late"}])
+
+ def test_codex_history_becomes_one_assistant_turn_per_answer(self):
+ items = [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "edit it"}]},
+ {"type": "reasoning", "id": "rs_1", "summary": [],
+ "content": [{"type": "reasoning_text", "text": "look first"}]},
+ {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Looking."}]},
+ {"type": "function_call", "id": "fc_1", "call_id": "c1", "name": "exec_command",
+ "arguments": "{\"cmd\":\"cat a\"}"},
+ {"type": "function_call", "id": "fc_2", "call_id": "c2", "name": "spawn_agent",
+ "namespace": "multi_agent_v1", "arguments": "{}"},
+ # results in the order they finished, not the order of the calls
+ {"type": "function_call_output", "call_id": "c2", "output": "agent"},
+ {"type": "function_call_output", "call_id": "c1",
+ "output": [{"type": "input_text", "text": "before"}]},
+ {"type": "reasoning", "id": "rs_2", "summary": [{"type": "summary_text", "text": "s"}],
+ "content": [{"type": "reasoning_text", "text": "now write"}]},
+ {"type": "function_call", "call_id": "c3", "name": "exec_command", "arguments": "{\"cmd\":\"w\"}"},
+ {"type": "function_call_output", "call_id": "c3", "output": "ok"}]
+ self.assertEqual(input_messages({"input": items}), [
+ {"role": "user", "content": "edit it"},
+ {"role": "assistant", "content": "Looking.", "reasoning_content": "look first", "tool_calls": [
+ {"function": {"name": "exec_command", "arguments": {"cmd": "cat a"}}},
+ {"function": {"name": "multi_agent_v1.spawn_agent", "arguments": {}}}]},
+ {"role": "tool", "content": "before"}, {"role": "tool", "content": "agent"},
+ {"role": "assistant", "content": "", "reasoning_content": "now write", "tool_calls": [
+ {"function": {"name": "exec_command", "arguments": {"cmd": "w"}}}]},
+ {"role": "tool", "content": "ok"}])
+
+ def test_reasoning_comes_back_from_our_encrypted_content_and_foreign_tokens_are_ignored(self):
+ enc = ENCRYPTED_PREFIX + base64.b64encode(json.dumps({"text": "hidden"}).encode()).decode()
+ for item, want in (({"encrypted_content": enc, "summary": []}, "hidden"),
+ ({"encrypted_content": "gAAAAAB-someone-elses", "summary": []}, None)):
+ msgs = input_messages({"input": [{"role": "user", "content": "q"}, {"type": "reasoning", **item},
+ {"role": "assistant", "content": "a"}]})
+ self.assertEqual(msgs[1].get("reasoning_content"), want)
+ with self.assertRaises(ResponsesError) as e:
+ input_messages({"input": [{"type": "reasoning", "encrypted_content": ENCRYPTED_PREFIX + "!!"}]})
+ self.assertEqual(e.exception.param, "input[0].encrypted_content")
+
+ def test_bad_arguments_from_a_cut_off_call_do_not_refuse_the_conversation(self):
+ msgs = input_messages({"input": [{"role": "user", "content": "q"},
+ {"type": "function_call", "call_id": "c", "name": "f", "arguments": "{\"a"},
+ {"type": "function_call_output", "call_id": "c", "output": "x"}]})
+ self.assertEqual(msgs[1]["tool_calls"][0]["function"]["arguments"], {"arguments": "{\"a"})
+
+ def test_unsupported_items_are_named(self):
+ for items, param in (([{"type": "item_reference", "id": "msg_1"}], "input[0]"),
+ ([{"type": "web_search_call"}], "input[0].type"),
+ ([{"role": "user", "content": [{"type": "input_file", "file_id": "f"}]}],
+ "input[0].content[0].type"),
+ ([{"role": "robot", "content": "x"}], "input[0].role")):
+ with self.assertRaises(ResponsesError) as e:
+ input_messages({"input": items})
+ self.assertEqual(e.exception.param, param)
+ with self.assertRaises(ResponsesError):
+ input_messages({})
+
+ def test_tools_namespaces_custom_and_hosted(self):
+ tools, names, skipped = request_tools({"tools": [
+ *TOOLS, {"type": "namespace", "name": "mcp_fs", "description": "Files.",
+ "tools": [{"type": "function", "name": "read", "parameters": {"type": "object"}}]},
+ {"type": "custom", "name": "apply_patch", "description": "Patch.",
+ "format": {"type": "grammar", "syntax": "lark", "definition": "start: \"x\""}},
+ {"type": "web_search"}]})
+ self.assertEqual([t["name"] for t in tools], ["exec_command", "mcp_fs.read", "apply_patch"])
+ self.assertEqual(tools[1]["description"], "Files.")
+ self.assertIn("start: \"x\"", tools[2]["description"])
+ self.assertEqual(names["mcp_fs.read"], ("mcp_fs", "read", "function"))
+ self.assertEqual(names["apply_patch"], (None, "apply_patch", "custom"))
+ self.assertEqual(skipped, ["web_search"])
+ self.assertIsNone(request_tools({"tools": TOOLS, "tool_choice": "none"})[0])
+
+ def test_effort_and_text_format(self):
+ self.assertEqual(template_kwargs({"reasoning": {"effort": "minimal"}}, {}), {"enable_thinking": False})
+ self.assertEqual(template_kwargs({"reasoning": {"effort": "high"}}, {}), {"reasoning_effort": "xhigh"})
+ self.assertEqual(template_kwargs({"reasoning": {"summary": "auto"}}, {}), {}) # Codex: the default
+ self.assertEqual(template_kwargs({}, {"reasoning_effort": "low"}), {"reasoning_effort": "low"})
+ with self.assertRaises(ResponsesError):
+ template_kwargs({"reasoning": {"effort": "huge"}}, {})
+ self.assertEqual(text_format({"text": {"format": {"type": "json_schema", "name": "n", "schema": {}}}}),
+ {"type": "json_schema", "json_schema": {"name": "n", "schema": {}}})
+ self.assertIsNone(text_format({"text": {"format": {"type": "text"}}}))
+
+
+# ------------------------------------------------------------------------------------------------ over HTTP
+class Server(unittest.TestCase):
+ script = ANSWER
+
+ def setUp(self):
+ self.tok = ByteTokenizer()
+ self.engine = MockEngine(self.tok, self.script, max_context=16384)
+ self.svc = Service(self.engine, self.tok, TEMPLATE)
+ self.httpd = serve(self.svc, port=0)
+ self.port = self.httpd.server_address[1]
+
+ def tearDown(self):
+ self.httpd.shutdown()
+ self.httpd.server_close()
+
+ def post(self, body, path="/v1/responses", headers=None):
+ """(status, parsed body: a dict, or the list of SSE events of a stream)."""
+ c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30)
+ try:
+ with contextlib.redirect_stdout(io.StringIO()):
+ c.request("POST", path, body=json.dumps(body).encode(),
+ headers={"Content-Type": "application/json", **(headers or {})})
+ r = c.getresponse()
+ raw = r.read().decode()
+ if r.getheader("Content-Type") == "text/event-stream":
+ events = []
+ for block in raw.split("\n\n"):
+ lines = block.strip().splitlines()
+ if not lines or lines[0].startswith(":"):
+ continue
+ name = lines[0][len("event: "):]
+ data = json.loads(lines[1][len("data: "):])
+ self.assertEqual(name, data["type"])
+ events.append(data)
+ return r.status, events
+ return r.status, json.loads(raw)
+ finally:
+ c.close()
+
+
+
+class OverHttp(Server):
+ def test_non_streaming_text_with_reasoning(self):
+ code, r = self.post({"model": "m", "input": "hi", "store": False})
+ self.assertEqual(code, 200)
+ self.assertEqual((r["object"], r["status"], r["error"]), ("response", "completed", None))
+ rs, msg = r["output"]
+ self.assertEqual(rs["type"], "reasoning")
+ self.assertEqual(rs["content"], [{"type": "reasoning_text", "text": "Read it.\n"}])
+ self.assertEqual(rs["summary"], [])
+ self.assertNotIn("encrypted_content", rs)
+ self.assertEqual(msg["type"], "message")
+ self.assertEqual(msg["content"][0]["text"], "The file says before.")
+ u = r["usage"]
+ self.assertEqual(u["output_tokens"], len(self.tok.encode(ANSWER)) + 1) # + the end-of-turn token
+ self.assertEqual(u["output_tokens_details"]["reasoning_tokens"], len("Read it.\n"))
+ self.assertEqual(u["total_tokens"], u["input_tokens"] + u["output_tokens"])
+ self.assertEqual(r["store"], False)
+
+ def test_stream_event_order(self):
+ code, events = self.post({"model": "m", "input": "hi", "stream": True,
+ "include": ["reasoning.encrypted_content"]})
+ self.assertEqual(code, 200)
+ types = [e["type"] for e in events]
+ deltas_r = types.count("response.reasoning_text.delta")
+ deltas_t = types.count("response.output_text.delta")
+ self.assertGreater(deltas_r, 0)
+ self.assertGreater(deltas_t, 0)
+ self.assertEqual(types, ["response.created", "response.in_progress",
+ "response.output_item.added", *["response.reasoning_text.delta"] * deltas_r,
+ "response.reasoning_text.done", "response.output_item.done",
+ "response.output_item.added", "response.content_part.added",
+ *["response.output_text.delta"] * deltas_t,
+ "response.output_text.done", "response.content_part.done",
+ "response.output_item.done", "response.completed"])
+ self.assertEqual([e["sequence_number"] for e in events], list(range(len(events))))
+ final = events[-1]["response"]
+ self.assertEqual(final["status"], "completed")
+ rs, msg = final["output"]
+ self.assertEqual([e["item"]["id"] for e in events if e["type"] == "response.output_item.done"],
+ [rs["id"], msg["id"]])
+ self.assertEqual("".join(e["delta"] for e in events if e["type"] == "response.output_text.delta"),
+ msg["content"][0]["text"])
+ self.assertTrue(rs["encrypted_content"].startswith(ENCRYPTED_PREFIX))
+ # the encrypted_content alone brings the thinking back into the prompt
+ replay = input_messages({"input": [{"role": "user", "content": "hi"},
+ {"type": "reasoning", "summary": [], "encrypted_content":
+ rs["encrypted_content"]}, msg]})
+ self.assertEqual(replay[1]["reasoning_content"], "Read it.\n")
+
+ def test_max_output_tokens_ends_incomplete(self):
+ code, r = self.post({"model": "m", "input": "hi", "max_output_tokens": 5})
+ self.assertEqual(code, 200)
+ self.assertEqual(r["status"], "incomplete")
+ self.assertEqual(r["incomplete_details"], {"reason": "max_output_tokens"})
+ self.assertEqual(r["output"][0]["status"], "incomplete")
+ code, events = self.post({"model": "m", "input": "hi", "max_output_tokens": 5, "stream": True})
+ self.assertEqual(events[-1]["type"], "response.incomplete")
+
+ def test_effort_none_does_not_think(self):
+ self.engine.scripts = [self.tok.encode("Hello.<|im_end|>", parse_special=True)]
+ self.engine.script = self.engine.scripts[0]
+ code, r = self.post({"model": "m", "input": "hi", "reasoning": {"effort": "none"}})
+ self.assertEqual([o["type"] for o in r["output"]], ["message"])
+ self.assertIn("\n\n", self.tok.decode(self.engine.last_prompt))
+
+ def test_errors_use_the_responses_format(self):
+ for body, param in (({"model": "m"}, "input"), ({"model": "m", "input": "x", "previous_response_id": "r"},
+ "previous_response_id"),
+ ({"model": "m", "input": "x", "reasoning": {"effort": "huge"}}, "reasoning.effort")):
+ code, r = self.post(body)
+ self.assertEqual(code, 400)
+ self.assertEqual(set(r["error"]), {"message", "type", "param", "code"})
+ self.assertEqual(r["error"]["param"], param)
+ code, r = self.post({}, path="/v1/responses/resp_123/cancel")
+ self.assertEqual(code, 404)
+ self.assertEqual(r["error"]["code"], "not_found")
+ c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30)
+ c.request("POST", "/v1/responses", body=b"{not json", headers={"Content-Type": "application/json"})
+ r = c.getresponse()
+ err = json.loads(r.read())["error"]
+ c.close()
+ self.assertEqual((r.status, err["type"], err["param"]), (400, "invalid_request_error", None))
+
+ def test_too_long_for_the_context(self):
+ code, r = self.post({"model": "m", "input": "x" * 20000})
+ self.assertEqual(code, 400)
+ self.assertEqual(r["error"]["code"], "context_length_exceeded")
+
+ def test_api_key_and_foreign_pages(self):
+ self.svc.api_key = "s3cret"
+ self.assertEqual(self.post({"model": "m", "input": "hi"})[0], 401)
+ self.assertEqual(self.post({"model": "m", "input": "hi"}, headers={"Authorization": "Bearer s3cret"})[0], 200)
+ self.svc.api_key = None
+ code, r = self.post({"model": "m", "input": "hi"}, headers={"Origin": "https://evil.example.com"})
+ self.assertEqual(code, 403)
+
+ def test_json_schema_text_format(self):
+ self.engine.scripts = [self.tok.encode("\n\n{\"n\": 3}<|im_end|>", parse_special=True)]
+ self.engine.script = self.engine.scripts[0]
+ fmt = {"type": "json_schema", "name": "num", "strict": True,
+ "schema": {"type": "object", "properties": {"n": {"type": "integer"}}, "required": ["n"]}}
+ code, r = self.post({"model": "m", "input": "a number", "text": {"format": fmt}})
+ self.assertEqual(code, 200, r)
+ self.assertEqual(r["output"][-1]["content"][0]["text"], "{\"n\":3}")
+ code, events = self.post({"model": "m", "input": "a number", "text": {"format": fmt}, "stream": True})
+ self.assertEqual([e["delta"] for e in events if e["type"] == "response.output_text.delta"], ["{\"n\":3}"])
+ self.assertEqual(events[-1]["type"], "response.completed")
+ # an answer that fails the schema
+ bad = {**fmt, "schema": {**fmt["schema"], "properties": {"n": {"type": "string"}}}}
+ code, r = self.post({"model": "m", "input": "a number", "text": {"format": bad}})
+ self.assertEqual((code, r["error"]["code"]), (502, "structured_output_failed"))
+ code, events = self.post({"model": "m", "input": "a number", "text": {"format": bad}, "stream": True})
+ self.assertEqual(events[-1]["type"], "response.failed")
+ self.assertEqual(events[-1]["response"]["error"]["code"], "structured_output_failed")
+
+ def test_monitor_and_status(self):
+ self.svc.api_monitor = True
+ self.post({"model": "m", "input": "hi", "stream": True})
+ rec = self.svc.request_records()[0]
+ self.assertEqual(rec["path"], "/v1/responses")
+ full = self.svc.request_records(rec["id"])
+ self.assertEqual(full["output"], "The file says before.")
+ self.assertEqual(full["reasoning"], "Read it.\n")
+ self.assertIn("/v1/responses", self.svc.v1_status()["dialects"])
+
+
+class ToolRoundTrip(Server):
+ script = [CALL, ANSWER]
+
+ def test_function_call_round_trip(self):
+ user = {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what is in a.txt?"}]}
+ code, events = self.post({"model": "m", "instructions": "You are a coding agent.", "input": [user],
+ "tools": TOOLS, "store": False, "stream": True,
+ "include": ["reasoning.encrypted_content"], "reasoning": {"summary": "auto"},
+ "parallel_tool_calls": True, "tool_choice": "auto"})
+ self.assertEqual(code, 200)
+ first_prompt = self.tok.decode(self.engine.last_prompt)
+ types = [e["type"] for e in events]
+ n_args = types.count("response.function_call_arguments.delta")
+ self.assertGreater(n_args, 0)
+ call_part = types[types.index("response.output_text.done") + 2:]
+ self.assertEqual(call_part, ["response.output_item.done", "response.output_item.added",
+ *["response.function_call_arguments.delta"] * n_args,
+ "response.function_call_arguments.done", "response.output_item.done",
+ "response.completed"])
+ output = events[-1]["response"]["output"]
+ self.assertEqual([o["type"] for o in output], ["reasoning", "message", "function_call"])
+ fc = output[2]
+ self.assertEqual((fc["name"], json.loads(fc["arguments"]), fc["status"]),
+ ("exec_command", {"cmd": "cat a.txt"}, "completed"))
+ self.assertTrue(fc["call_id"])
+ added = next(e for e in events if e["type"] == "response.output_item.added"
+ and e["item"]["type"] == "function_call")
+ self.assertEqual((added["item"]["call_id"], added["item"]["arguments"]), (fc["call_id"], ""))
+ # Codex sends everything back, plus the result: the prompt continues exactly where the model stopped
+ code, r = self.post({"model": "m", "instructions": "You are a coding agent.", "tools": TOOLS, "store": False,
+ "input": [user, *output, {"type": "function_call_output", "call_id": fc["call_id"],
+ "output": "before"}]})
+ self.assertEqual(code, 200, r)
+ second_prompt = self.tok.decode(self.engine.last_prompt)
+ self.assertTrue(second_prompt.startswith(first_prompt + CALL + "<|im_end|>"), second_prompt[-600:])
+ self.assertIn("\nbefore\n", second_prompt)
+ self.assertEqual(r["output"][-1]["content"][0]["text"], "The file says before.")
+
+ def test_namespace_and_custom_calls_come_back_under_their_own_names(self):
+ self.engine.scripts = [self.tok.encode(
+ "\n\n\n\n\nhi\n\n"
+ "\n\n\n\n\n*** Begin Patch\n"
+ "\n\n<|im_end|>", parse_special=True)]
+ self.engine.script = self.engine.scripts[0]
+ tools = [{"type": "namespace", "name": "multi_agent_v1", "description": "Agents.", "tools": [
+ {"type": "function", "name": "spawn_agent", "strict": False,
+ "parameters": {"type": "object", "properties": {"message": {"type": "string"}}}}]},
+ {"type": "custom", "name": "apply_patch", "description": "Edit files."}]
+ code, r = self.post({"model": "m", "input": "go", "tools": tools})
+ self.assertEqual(code, 200, r)
+ ns, custom = r["output"]
+ self.assertEqual((ns["type"], ns["namespace"], ns["name"], json.loads(ns["arguments"])),
+ ("function_call", "multi_agent_v1", "spawn_agent", {"message": "hi"}))
+ self.assertEqual((custom["type"], custom["name"], custom["input"]),
+ ("custom_tool_call", "apply_patch", "*** Begin Patch"))
+ msgs = input_messages({"input": [{"role": "user", "content": "go"}, ns, custom,
+ {"type": "function_call_output", "call_id": ns["call_id"], "output": "1"},
+ {"type": "custom_tool_call_output", "call_id": custom["call_id"],
+ "output": "2"}]})
+ self.assertEqual([c["function"]["name"] for c in msgs[1]["tool_calls"]],
+ ["multi_agent_v1.spawn_agent", "apply_patch"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/serve/test_runconfig.py b/serve/test_runconfig.py
new file mode 100644
index 0000000000..bb17119698
--- /dev/null
+++ b/serve/test_runconfig.py
@@ -0,0 +1,165 @@
+"""serve/test_runconfig.py - #564: the web page's Settings view of the run config (GET / POST /config), against the
+mock engine: only the listed keys change, every other key of the file stays, a bad value changes nothing, and only
+Strata's own page may write it (JSON, no foreign Origin, the API key when one is set).
+
+ python -m unittest serve.test_runconfig
+"""
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import tempfile
+import unittest
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+from serve import runconfig
+from serve.frontend import ChatTemplate
+from serve.server import ByteTokenizer, MockEngine, Service, serve
+
+ROOT = Path(__file__).resolve().parents[1]
+CFG = {"exe": "engine/strata.exe", "args": ["--pack", "data/packs/q2_0", "--kv", "int8", "--spec-min-p", "0.5"],
+ "model_name": "qwen3.8-flash-next-q2_0", "port": 8080, "host": "0.0.0.0", "api_key": "",
+ "sampling": {"temperature": 0.7, "presence_penalty": 1.5},
+ "mcp_servers": {"files": {"command": "npx", "args": ["-y", "server-filesystem", "."]}},
+ "cors_origins": ["http://localhost:3000"]}
+
+
+class Apply(unittest.TestCase):
+ def test_only_the_named_keys_change(self):
+ new, changed = runconfig.apply(CFG, {"sampling.top_p": 0.95, "reasoning_budget_tokens": 4096,
+ "vram_reserve_mib": 2048, "aliases": "qwen, local", "lazy_load": True})
+ self.assertEqual(changed, ["sampling.top_p", "reasoning_budget_tokens", "vram_reserve_mib", "aliases",
+ "lazy_load"])
+ self.assertEqual(new["sampling"], {"temperature": 0.7, "presence_penalty": 1.5, "top_p": 0.95})
+ self.assertEqual(new["args"][-2:], ["--vram-reserve-mib", "2048"])
+ self.assertEqual(new["aliases"], ["qwen", "local"])
+ for k in ("exe", "model_name", "port", "host", "api_key", "mcp_servers", "cors_origins"):
+ self.assertEqual(new[k], CFG[k], k)
+ self.assertEqual(CFG["sampling"], {"temperature": 0.7, "presence_penalty": 1.5}) # the input is left alone
+
+ def test_null_is_the_default_again(self):
+ cfg, _ = runconfig.apply(CFG, {"vram_reserve_mib": 2048, "fit_max_tokens": True})
+ new, changed = runconfig.apply(cfg, {"vram_reserve_mib": None, "fit_max_tokens": None,
+ "sampling.temperature": None, "idle_unload_s": None})
+ self.assertEqual(changed, ["vram_reserve_mib", "fit_max_tokens", "sampling.temperature"])
+ self.assertEqual(new["args"], CFG["args"])
+ self.assertNotIn("fit_max_tokens", new)
+ self.assertEqual(new["sampling"], {"presence_penalty": 1.5})
+ new, _ = runconfig.apply({"sampling": {"top_k": 20}}, {"sampling.top_k": None})
+ self.assertNotIn("sampling", new) # an empty block goes
+
+ def test_refused(self):
+ for changes in ({"api_key": "x"}, {"host": "0.0.0.0"}, {"mcp_servers": {}}, {"before_load": "calc.exe"},
+ {"args": []}, {"exe": "evil.exe"}, {"trusted_origins": ["*"]}, {"allowed_hosts": ["*"]},
+ {"sampling.temperature": -1}, {"sampling.top_p": 0}, {"sampling.top_k": 65},
+ {"sampling.min_p": 2}, {"reasoning_budget_tokens": 1.5}, {"fit_max_tokens": "yes"},
+ {"effort_position": "middle"}, {"vram_reserve_mib": -5}, {"idle_unload_s": True},
+ {"aliases": [1]}, {}, None, ["x"]):
+ with self.subTest(changes=changes), self.assertRaises(ValueError):
+ runconfig.apply(CFG, changes)
+ with self.assertRaises(ValueError): # one bad value: nothing is applied
+ runconfig.apply(CFG, {"fit_max_tokens": True, "sampling.top_k": 0})
+ with self.assertRaises(ValueError):
+ runconfig.apply({**CFG, "vision": {"gpu": True}}, {"lazy_load": True})
+
+ def test_view(self):
+ v = runconfig.view({**CFG, "args": CFG["args"] + ["--vram-reserve-mib", "1500"]}, "x/strata-q2_0.json")
+ self.assertEqual(v["file"], "strata-q2_0.json")
+ got = {k["key"]: k for k in v["keys"]}
+ self.assertEqual(got["sampling.temperature"]["value"], 0.7)
+ self.assertEqual(got["vram_reserve_mib"]["value"], 1500)
+ self.assertIsNone(got["fit_max_tokens"]["value"])
+ self.assertEqual(got["anthropic_thinking"]["choices"], ["model", "on_request"])
+ self.assertNotIn("api_key", got)
+ self.assertNotIn("mcp_servers", got)
+
+
+class Http(unittest.TestCase):
+ def setUp(self):
+ self.dir = tempfile.TemporaryDirectory()
+ self.path = Path(self.dir.name) / "strata-q2_0.json"
+ self.path.write_text(json.dumps(CFG, indent=1), encoding="utf-8")
+ tok = ByteTokenizer()
+ self.svc = Service(MockEngine(tok, "ok", max_context=4096), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
+ self.svc.config_path = str(self.path)
+ self.httpd = serve(self.svc, port=0)
+ self.host = f"127.0.0.1:{self.httpd.server_address[1]}"
+
+ def tearDown(self):
+ self.httpd.shutdown()
+ self.httpd.server_close()
+ self.dir.cleanup()
+
+ def call(self, method, body=None, headers=None):
+ h = {"Content-Type": "application/json", **(headers or {})}
+ req = urllib.request.Request(f"http://{self.host}/config", method=method, headers=h,
+ data=None if body is None else (body if isinstance(body, bytes)
+ else json.dumps(body).encode()))
+ try:
+ with urllib.request.urlopen(req, timeout=10) as r:
+ return r.status, json.loads(r.read())
+ except urllib.error.HTTPError as e:
+ with e:
+ return e.code, json.loads(e.read())
+
+ def saved(self):
+ return json.loads(self.path.read_text(encoding="utf-8"))
+
+ def test_read_and_write_from_the_page(self):
+ status, v = self.call("GET")
+ self.assertEqual(status, 200, v)
+ self.assertEqual({k["key"]: k["value"] for k in v["keys"]}["sampling.temperature"], 0.7)
+ with contextlib.redirect_stdout(io.StringIO()):
+ status, v = self.call("POST", {"set": {"sampling.temperature": 1.0, "open_browser": False}},
+ {"Origin": f"http://{self.host}"})
+ self.assertEqual(status, 200, v)
+ self.assertEqual(v["changed"], ["sampling.temperature", "open_browser"])
+ cfg = self.saved()
+ self.assertEqual(cfg["sampling"]["temperature"], 1.0)
+ self.assertIs(cfg["open_browser"], False)
+ for k in ("mcp_servers", "cors_origins", "host", "args", "exe"):
+ self.assertEqual(cfg[k], CFG[k], k)
+ self.assertEqual(json.loads((self.path.parent / "strata-q2_0.json.bak").read_text(encoding="utf-8")), CFG)
+ status, v = self.call("POST", {"set": {"open_browser": False}}) # no Origin (a script on this PC): allowed
+ self.assertEqual((status, v["changed"]), (200, []))
+
+ def test_only_from_strata_s_own_page(self):
+ before = self.path.read_bytes()
+ status, _ = self.call("POST", {"set": {"fit_max_tokens": True}}, {"Origin": "https://evil.example"})
+ self.assertEqual(status, 403)
+ status, _ = self.call("POST", b'{"set": {"fit_max_tokens": true}}', {"Content-Type": "text/plain"})
+ self.assertEqual(status, 415)
+ for body in ({"set": {"api_key": ""}}, {"set": {"mcp_servers": {"x": {"command": "calc"}}}},
+ {"set": {"sampling.top_k": 0}}, {"nothing": 1}, b"not json"):
+ status, b = self.call("POST", body, {"Origin": f"http://{self.host}"})
+ self.assertEqual(status, 400, (body, b))
+ self.assertEqual(self.path.read_bytes(), before) # nothing was written
+ self.assertFalse((self.path.parent / "strata-q2_0.json.bak").exists())
+
+ def test_the_api_key(self):
+ self.svc.api_key = "secret"
+ self.assertEqual(self.call("GET")[0], 401)
+ self.assertEqual(self.call("POST", {"set": {"fit_max_tokens": True}})[0], 401)
+ with contextlib.redirect_stdout(io.StringIO()):
+ status, _ = self.call("POST", {"set": {"fit_max_tokens": True}}, {"Authorization": "Bearer secret"})
+ self.assertEqual(status, 200)
+ self.assertIs(self.saved()["fit_max_tokens"], True)
+
+ def test_without_a_run_config(self):
+ self.svc.config_path = None
+ self.assertEqual(self.call("GET")[0], 404)
+ self.assertEqual(self.call("POST", {"set": {"fit_max_tokens": True}})[0], 404)
+
+ def test_the_page_has_the_view(self):
+ web = ROOT / "serve" / "web"
+ html, js = (web / "index.html").read_text(encoding="utf-8"), (web / "app.js").read_text(encoding="utf-8")
+ for el in ("cfg-card", "cfg-form", "cfg-save", "cfg-msg"):
+ self.assertIn(f'id="{el}"', html)
+ self.assertIn('fetch("config", {method: "POST", headers: headers(true)', js)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/serve/test_security.py b/serve/test_security.py
index cebd756d12..429c716217 100644
--- a/serve/test_security.py
+++ b/serve/test_security.py
@@ -238,6 +238,31 @@ def test_with_a_key_the_key_decides(self):
{**headers, "Authorization": "Bearer s3cret"})[0], 200)
# --- /unload and /load
+ def test_control_body_arrives_before_the_operation_and_reply(self):
+ self.start()
+ for body, expected in ((b"{}", 200), (b"{", 400)):
+ c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
+ try:
+ with mock.patch.object(self.svc, "unload", return_value="unloaded") as unload:
+ c.putrequest("POST", "/unload")
+ c.putheader("Content-Type", "application/json")
+ c.putheader("Content-Length", "2")
+ c.endheaders()
+ c.sock.settimeout(0.1)
+ with self.assertRaises(socket.timeout):
+ c.sock.recv(1)
+ unload.assert_not_called()
+ c.sock.settimeout(5)
+ c.send(body)
+ if len(body) < 2:
+ c.sock.shutdown(socket.SHUT_WR)
+ r = c.getresponse()
+ self.assertEqual(r.status, expected)
+ r.read()
+ self.assertEqual(unload.call_count, int(expected == 200))
+ finally:
+ c.close()
+
def test_unload_and_load_need_json_from_the_own_page(self):
self.start()
form = {"Content-Type": "application/x-www-form-urlencoded"}
diff --git a/serve/test_server.py b/serve/test_server.py
index 76c1797a3f..0ffe2f6c97 100644
--- a/serve/test_server.py
+++ b/serve/test_server.py
@@ -21,12 +21,14 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from serve.frontend import ChatTemplate # noqa: E402
from serve.server import (CTX_SLACK, ByteTokenizer, EngineDied, GpuBusy, MockEngine, Service, StrataEngine, # noqa: E402
- engine_args, prompt_tokens_seen, request_timings, serve, start_failure_hint)
+ engine_args, layer_split_value, prompt_tokens_seen, request_timings, serve,
+ start_failure_hint)
from types import SimpleNamespace # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
CTX = 4096
-ANSWER = "x" * 2000 # longer than the old 1024 fallback: one token per byte
+ANSWER = "xy" * 1000 # longer than the old 1024 fallback: one token per byte (#606: not
+# one token repeated, which the server ends at 256)
class RecordingEngine(MockEngine):
@@ -241,6 +243,190 @@ def test_literal_marker_with_an_image(self):
svc.embeddings.path.unlink(missing_ok=True)
+class ThinkTokenizer(ByteTokenizer):
+ """The byte tokenizer with the model's reasoning markers as specials that are matched even without parse_special,
+ as the real tokenizer does (GGUF token type 4)."""
+ SPECIALS = ByteTokenizer.SPECIALS + ["", ""]
+ ALWAYS = ("", "")
+
+
+class LiteralThinkTags(unittest.TestCase):
+ """#537: a / written inside a message is text, not the model's reasoning markers: a quoted
+ "" no longer ends the model's reasoning before it starts. The template's own markers stay special."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.tok = ThinkTokenizer()
+ cls.svc = Service(MockEngine(cls.tok, "ok", max_context=CTX), cls.tok,
+ ChatTemplate(ROOT / "serve/chat_template.jinja"))
+ cls.open, cls.close = (cls.tok.encode(t)[0] for t in ("", ""))
+
+ def ids(self, messages, tools=None, **kw):
+ return self.svc.prepare(messages, tools, kw)[0]
+
+ def old_ids(self, messages, tools=None, **kw):
+ return self.tok.encode(self.svc.template.render(messages, tools=tools, **kw), parse_special=True)
+
+ def test_a_quoted_tag_in_a_user_message(self):
+ text = "Quote this exact literal string, then explain it: and "
+ ids = self.ids([{"role": "user", "content": text}])
+ self.assertEqual(ids.count(self.close), 0)
+ self.assertEqual(ids.count(self.open), 1) # the generation prompt's own
+ self.assertEqual(ids[-2:], [self.open, ord("\n")])
+ self.assertIn(text, self.tok.decode(ids)) # the text is all there, as text
+ self.assertEqual(ids.count(self.close), 0)
+ # thinking off: the template's empty block stays two specials, the user's tag is text
+ ids = self.ids([{"role": "user", "content": text}], enable_thinking=False)
+ self.assertEqual((ids.count(self.open), ids.count(self.close)), (1, 1))
+
+ def test_without_a_tag_the_prompt_is_unchanged(self):
+ msgs = [{"role": "system", "content": "Be brief."}, {"role": "user", "content": "1+1?"},
+ {"role": "assistant", "content": "2", "reasoning_content": "easy"}, {"role": "user", "content": "x"}]
+ self.assertEqual(self.ids(msgs), self.old_ids(msgs))
+ self.assertEqual(self.ids(msgs, enable_thinking=False), self.old_ids(msgs, enable_thinking=False))
+
+ def test_history_tool_results_and_tools(self):
+ msgs = [{"role": "user", "content": "go"},
+ {"role": "assistant", "content": "It wrote here.", "reasoning_content": "the tag",
+ "tool_calls": [{"function": {"name": "write", "arguments": {"text": "a b"}}}]},
+ {"role": "tool", "content": "file has in it"},
+ {"role": "user", "content": "why did you write "}]
+ tools = [{"name": "write", "description": "writes text (may contain )", "parameters": {}}]
+ ids = self.ids(msgs, tools)
+ # the template's markers: the history turn's ... and the generation prompt's
+ self.assertEqual((ids.count(self.open), ids.count(self.close)), (2, 1))
+ text = self.tok.decode(ids)
+ for part in ("It wrote here.", "the tag", "a b", "file has in it",
+ "why did you write ", "may contain "):
+ self.assertIn(part, text)
+
+ def test_a_client_that_sends_the_reasoning_inline(self):
+ # an assistant turn whose content opens with its own ... block keeps that block's markers
+ msgs = [{"role": "user", "content": "hi"},
+ {"role": "assistant", "content": "\nplan: say hello\n\n\nHello, is a tag."},
+ {"role": "user", "content": "again"}]
+ ids = self.ids(msgs)
+ self.assertEqual((ids.count(self.open), ids.count(self.close)), (3, 2)) # template's + the inline block's
+ self.assertIn("Hello, is a tag.", self.tok.decode(ids))
+
+ def test_the_real_tokenizer_reads_the_tag_as_text(self):
+ import strata_tokenizer as ST
+ b2u = ST.bytes_to_unicode()
+ tokens = [b2u[b] for b in range(256)] + ["", "", "<|im_end|>"]
+ tok = ST.Tokenizer(tokens, [], [1] * 256 + [4, 4, 3])
+ text = "say now<|im_end|>"
+ self.assertEqual(tok.encode(text, parse_special=True), [*b"say ", 257, *b" now", 258])
+ start = text.index("")
+ plain = tok.encode(text, parse_special=True, plain=[(start, start + len(""))])
+ self.assertEqual(plain, [*b"say now", 258]) # the tag as text, im_end still special
+
+
+class EffortAtTheEnd(unittest.TestCase):
+ """#458 (opt-in "effort_position": "end"): a non-default effort goes in a system turn right before the answer, so
+ a request that changes only the effort keeps the cached conversation. The engine's checkpoint rule is simulated
+ on the token ids (src/program/generate.cpp: the last <|im_start|>, and with --tail-role-token the one in front
+ of a trailing system turn); the default prompt does not change."""
+
+ TURN = 256 # the byte tokenizer's <|im_start|>
+ ROLE = ord("s") # "system"'s first byte stands for its token
+
+ def setUp(self):
+ self.tok = ByteTokenizer()
+ self.svc = Service(MockEngine(self.tok, "ok", max_context=1 << 20), self.tok,
+ ChatTemplate(ROOT / "serve/chat_template.jinja"))
+
+ def ids(self, messages, end, **kw):
+ self.svc.effort_end = end
+ return self.svc.encode_prompt(messages, None, kw)
+
+ def checkpoint(self, ids, tail, resume=0):
+ """Where the engine checkpoints a prompt (the tokens before it), as generate.cpp's serve loop does."""
+ turn_at = next((i for i in range(len(ids) - 1, resume, -1) if ids[i] == self.TURN), -1)
+ if turn_at > 0 and tail:
+ i = next((i for i in range(turn_at - 1, resume, -1) if ids[i] == self.TURN), None)
+ if i is not None and ids[i + 1] == self.ROLE:
+ turn_at = i
+ return ids[:turn_at]
+
+ def session(self, requests, end, tail):
+ """The reused tokens of each request after the first: the longest earlier checkpoint it starts with."""
+ checks, reused = [], []
+ for messages, kw in requests:
+ ids = self.ids(messages, end, **kw)
+ reused.append(max([len(c) for c in checks if ids[:len(c)] == c], default=0))
+ checks.append(self.checkpoint(ids, tail))
+ return reused[1:]
+
+ CHAT = [{"role": "system", "content": "You are a careful assistant. " * 40},
+ {"role": "user", "content": "Explain the conversation cache. " * 20}]
+ REPLY = {"role": "assistant", "content": "It keeps the prompt's state. " * 20, "reasoning_content": "ok"}
+ NEXT = {"role": "user", "content": "And the checkpoints?"}
+
+ def test_the_default_prompt_is_unchanged(self):
+ for kw in ({}, {"reasoning_effort": "xhigh"}):
+ self.assertEqual(self.ids(self.CHAT, True, **kw), self.ids(self.CHAT, False, **kw))
+ self.assertEqual(self.ids(self.CHAT, True, **kw),
+ self.tok.encode(self.svc.template.render(self.CHAT, **kw), parse_special=True))
+ for kw in ({"reasoning_effort": "low"}, {"reasoning_effort": "medium"}, {"enable_thinking": False}):
+ self.assertEqual(self.ids(self.CHAT, False, **kw), # off: every effort renders as before
+ self.tok.encode(self.svc.template.render(self.CHAT, **kw), parse_special=True))
+
+ def test_the_trailing_turn(self):
+ text = self.tok.decode(self.ids(self.CHAT, True, reasoning_effort="low"))
+ self.assertTrue(text.endswith("<|im_end|>\n<|im_start|>system\nReasoning effort is set to low. Keep your "
+ "thinking brief and focused, moving directly to the conclusion without "
+ "unnecessary elaboration.<|im_end|>\n<|im_start|>assistant\n\n"), text[-300:])
+ self.assertIn("Reasoning effort is set to xhigh", text) # the top stays the default's
+ text = self.tok.decode(self.ids(self.CHAT, True, enable_thinking=False))
+ self.assertTrue(text.endswith("<|im_start|>assistant\n\n\n\n\n"))
+ self.assertNotIn("<|im_start|>system\nReasoning effort", text[-200:])
+ default = self.ids(self.CHAT, True)
+ for kw in ({"reasoning_effort": "low"}, {"reasoning_effort": "medium"}, {"enable_thinking": False}):
+ ids = self.ids(self.CHAT, True, **kw)
+ head = len(self.checkpoint(default, False)) # all before the answer's turn
+ self.assertEqual(ids[:head], default[:head], kw) # the same prompt up to the answer
+
+ def test_changing_the_effort_keeps_the_conversation(self):
+ efforts = [{}, {"reasoning_effort": "low"}, {"enable_thinking": False}, {"reasoning_effort": "medium"}, {}]
+ history = len(self.checkpoint(self.ids(self.CHAT, False), False))
+ # at the top (the default): another effort differs a few tokens in, and the whole prompt is read again
+ self.assertEqual(self.session([(self.CHAT, kw) for kw in efforts], False, False)[:2], [0, 0])
+ # at the end, with the engine's rule: every request reuses the whole conversation
+ self.assertEqual(self.session([(self.CHAT, kw) for kw in efforts], True, True), [history] * 4)
+
+ def test_the_next_turn_reuses_the_checkpoint(self):
+ turn1, turn2 = self.CHAT, self.CHAT + [self.REPLY, self.NEXT]
+ low = {"reasoning_effort": "low"}
+ first = len(self.checkpoint(self.ids(turn1, True, **low), True))
+ # the engine's rule: the next turn (the same effort) reuses the first turn's whole conversation
+ self.assertEqual(self.session([(turn1, low), (turn2, low)], True, True), [first])
+ # without it the checkpoint held the effort turn and the next turn reused nothing (what #458 measured)
+ self.assertEqual(self.session([(turn1, low), (turn2, low)], True, False), [0])
+
+ def test_the_config(self):
+ from serve.server import effort_end_args
+
+ class Tok:
+ def encode(self, text, parse_special=False):
+ return [8678] if text == "system" else [1, 2]
+
+ with tempfile.TemporaryDirectory() as d:
+ new, old = Path(d) / "new.exe", Path(d) / "old.exe"
+ new.write_bytes(b"... --tail-role-token ID --serve: ...")
+ old.write_bytes(b"... --turn-token ID ...")
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ self.assertIsNone(effort_end_args({}, str(new), Tok()))
+ self.assertIsNone(effort_end_args({"effort_position": "start"}, str(new), Tok()))
+ self.assertEqual(effort_end_args({"effort_position": "end"}, str(new), Tok()),
+ ["--tail-role-token", "8678"])
+ self.assertIsNone(effort_end_args({"effort_position": "end"}, str(old), Tok()))
+ self.assertIsNone(effort_end_args({"effort_position": "end"}, str(new), ByteTokenizer()))
+ self.assertIn("needs engine 0.1.39 or newer", out.getvalue())
+ with self.assertRaises(ValueError):
+ effort_end_args({"effort_position": "middle"}, str(new), Tok())
+
+
class StatusNeedsTheKey(unittest.TestCase):
"""#212: /status shows the end of the answer being written, so it needs the key like /v1/*."""
@@ -499,6 +685,36 @@ def test_messages_as_a_json_string_over_http(self):
self.assertEqual(status, 400, b)
self.assertIn("messages must be a list of objects", b["error"]["message"])
+ def test_malformed_tools_are_a_400(self):
+ # #592: a "tools" value that is not a list of named tool objects is a 400 naming the field, on both APIs,
+ # not a dropped connection (an AttributeError/TypeError in the request thread)
+ msgs = [{"role": "user", "content": "hi"}]
+ bad = ("auto", ["get_weather"], [{"description": "no name"}], {"name": "x"},
+ [{"type": "function", "function": "get_weather"}], [{"type": "function", "function": {"name": ""}}])
+ for path in ("/v1/chat/completions", "/v1/messages"):
+ for tools in bad:
+ with self.subTest(path=path, tools=tools):
+ status, b = self.post(path, {"model": "x", "max_tokens": 8, "messages": msgs, "tools": tools})
+ self.assertEqual(status, 400, b)
+ self.assertIn("tools", b["error"]["message"])
+ status, b = self.post("/v1/chat/completions", {"model": "x", "max_tokens": 8, "messages": msgs})
+ self.assertEqual(status, 200, b) # the server goes on
+
+ def test_well_formed_tools_still_work(self):
+ msgs = [{"role": "user", "content": "hi"}]
+ fn = {"name": "get_weather", "description": "the weather", "parameters": {"type": "object", "properties": {}}}
+ for path, tools in (("/v1/chat/completions", [{"type": "function", "function": fn}]),
+ ("/v1/chat/completions", [fn]), # the bare shape some clients send
+ ("/v1/chat/completions", json.dumps([{"type": "function", "function": fn}])),
+ ("/v1/chat/completions", []), ("/v1/chat/completions", None),
+ ("/v1/messages", [{"name": "get_weather", "input_schema": {"type": "object"}}]),
+ ("/v1/messages", [])):
+ with self.subTest(path=path, tools=tools):
+ status, b = self.post(path, {"model": "x", "max_tokens": 8, "messages": msgs, "tools": tools})
+ self.assertEqual(status, 200, b)
+ if tools:
+ self.assertIn("get_weather", self.prompt_text())
+
def test_vision_temp_image_removed_when_the_pipe_fails(self):
# #352: the temporary image goes even when the encoder's pipe raises
from serve.server import Vision
@@ -805,6 +1021,26 @@ def test_drafts_in_history_and_totals(self):
self.assertEqual((m["totals"]["drafts_offered"], m["totals"]["drafts_accepted"]), (17, 10))
+class PcieShare(unittest.TestCase):
+ """#588: the hit rate stays the VRAM share of the lookups; the routed experts the GPU read over PCIe (the DONE
+ line's 16th field, engine 0.1.39+) are given as their own share of all routed experts."""
+
+ def test_history(self):
+ tok = ByteTokenizer()
+ engine = DoneLineEngine(tok, "\n\nok", max_context=CTX, done_lines=[
+ "DONE 4 20 40.0 30.0 stop 3 5 0 60 100 0 0 0.0 20 25", # 25 more over PCIe: 20% of 125 routed
+ "DONE 4 20 40.0 30.0 stop 3 5 0 60 100 0 0 0.0 20 0",
+ "DONE 4 20 40.0 30.0 stop 3 5 0 60 100 0 0 0.0 20"]) # an older engine
+ svc = Service(engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
+ with contextlib.redirect_stdout(io.StringIO()) as out:
+ for _ in range(3):
+ list(svc.run(tok.encode("hi"), False, None, 10, {}, threading.Event()))
+ rows = list(svc.history)
+ self.assertEqual([r["hit_rate"] for r in rows], [0.6, 0.6, 0.6])
+ self.assertEqual([r["pcie_share"] for r in rows], [0.2, 0.0, None])
+ self.assertIn("expert cache 60.0% hit (+20.0% of the routed experts over PCIe)", out.getvalue())
+
+
class LearnedProfile(unittest.TestCase):
"""#477: "expert_profile_save" in the config: the engine saves its learned profile there, and the next start
begins from it when it is a profile of the same model; without the key the arguments are unchanged."""
@@ -847,6 +1083,67 @@ def test_no_profile_in_the_args(self):
self.assertEqual(engine_args(cfg), ["--native", "x", "--expert-profile-save", "learned.bin"])
+class RepeatStop(unittest.TestCase):
+ """#606: one token repeated repeat_stop_tokens times in a row ends the reply as "length"; 0 turns it off."""
+
+ def run_reply(self, script, limit=None):
+ tok = ByteTokenizer()
+ svc = Service(MockEngine(tok, script, max_context=CTX), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
+ if limit is not None:
+ svc.repeat_stop_tokens = limit
+ ids = tok.encode("hi")
+ with contextlib.redirect_stdout(io.StringIO()) as out:
+ done = [x for kind, x in svc.run(ids, False, None, 3000, {}, threading.Event()) if kind == "done"][0]
+ return done, out.getvalue()
+
+ def test_a_long_run_is_ended(self):
+ done, log = self.run_reply("ok " + "!" * 1000 + " never")
+ self.assertEqual(done["finish"], "length")
+ self.assertEqual(done["completion_tokens"], 3 + 256)
+ self.assertIn("repeated one token ('!') 256 times", log)
+
+ def test_short_runs_and_off(self):
+ done, _ = self.run_reply("=" * 255 + " fine")
+ self.assertEqual(done["finish"], "stop")
+ done, log = self.run_reply("!" * 1000, limit=0)
+ self.assertEqual((done["finish"], done["completion_tokens"]), ("stop", 1001))
+ self.assertNotIn("repeated one token", log)
+ done, _ = self.run_reply("ab" * 400, limit=8) # alternating tokens are not one run
+ self.assertEqual(done["finish"], "stop")
+
+
+class LayerSplit(unittest.TestCase):
+ """#644: "layer_split" is the first layer of each later GPU; a list is accepted, counts per card are not."""
+
+ def cfg(self, split, gpus=(2, 0, 1, 3)):
+ c = {"args": ["--native", "x"], "gpu": list(gpus)}
+ if split is not ...:
+ c["layer_split"] = split
+ return c
+
+ def test_auto_and_absent(self):
+ for v in (..., None, "", "auto", "AUTO"):
+ self.assertEqual(engine_args(self.cfg(v))[-2:], ["--layer-split", "auto"])
+
+ def test_string_and_list(self):
+ self.assertEqual(engine_args(self.cfg("24,36,42"))[-2:], ["--layer-split", "24,36,42"])
+ self.assertEqual(engine_args(self.cfg(" 24, 36 ,42 "))[-2:], ["--layer-split", "24,36,42"])
+ self.assertEqual(engine_args(self.cfg([24, 36, 42]))[-2:], ["--layer-split", "24,36,42"])
+ self.assertEqual(engine_args(self.cfg(18, gpus=(0, 1)))[-2:], ["--layer-split", "18"])
+ self.assertEqual(engine_args(self.cfg([18], gpus=(0, 1)))[-2:], ["--layer-split", "18"])
+
+ def test_counts_per_card_are_refused_with_the_format(self):
+ for bad in ("24,16,12,12", [24, 16, 12, 12], "24,16,12", "24,36", "x", [24.5, 30, 40], "1,20,30", [True]):
+ with self.assertRaises(ValueError) as e:
+ layer_split_value(self.cfg(bad))
+ self.assertIn("first layer of each later GPU", str(e.exception))
+ self.assertIn('"12,24,36"', str(e.exception)) # the example for 4 GPUs
+
+ def test_one_gpu_ignores_it(self):
+ self.assertEqual(engine_args({"args": ["--native", "x"], "gpu": [0], "layer_split": "24,16"}),
+ ["--native", "x"])
+
+
class DraftHeadHint(unittest.TestCase):
"""#474: a start that stopped at "the draft head does not fit" says what to change, from this start's log lines."""
@@ -994,12 +1291,6 @@ class CancelledRead(unittest.TestCase):
"""#471: a request cancelled while its prompt was read is recorded with the tokens the engine read (the DONE
line's 15th field), not the whole prompt; an older engine's line (no such field) keeps the whole prompt."""
- def test_parse_done_parking_fields(self):
- e = object.__new__(StrataEngine)
- StrataEngine._parse_done(e, "DONE 1 20 40.0 30.0 stop 3 5 7 9 10 11 12 13.0 14 4 123456789 2")
- self.assertEqual((e.last["parked_conversations"], e.last["parked_bytes"], e.last["park_evictions"]),
- (4, 123456789, 2))
-
def test_parse_done_read_field(self):
e = SimpleNamespace()
StrataEngine._parse_done(e, "DONE 0 98179 17565.0 0.0 cancel 0 0 0 0 0 0 0 0.0 12288")
@@ -1961,6 +2252,13 @@ def second():
break
'''
+FAKE_STRATA_FAIL_ONCE = '''import pathlib, sys, time
+fail = pathlib.Path(sys.argv[sys.argv.index("--fail") + 1])
+if fail.exists(): # this start fails before READY (as one next to a dying engine did)
+ fail.unlink()
+ sys.exit(1)
+''' + FAKE_STRATA.split("\n", 1)[1]
+
class RestartWindow(unittest.TestCase):
"""#344: while the engine restarts it is not alive (a request waits for the restart instead of reading
@@ -2012,6 +2310,54 @@ def test_not_alive_until_ready(self):
gate.touch()
eng.unload()
+ def test_restart_retries_a_start_that_fails(self):
+ """A dead engine's VRAM is freed only when its process is gone, so a new engine started at once can exit
+ before READY; restart() tries again (3 times) instead of leaving the server with max_context 0."""
+ from unittest import mock
+ import serve.server as server
+ with tempfile.TemporaryDirectory() as d:
+ script, gate, fail = Path(d) / "fake_strata.py", Path(d) / "ready", Path(d) / "fail_once"
+ script.write_text(FAKE_STRATA_FAIL_ONCE, encoding="utf-8")
+ real = server.subprocess.Popen
+ with mock.patch.object(server.subprocess, "Popen",
+ lambda cmd, **kw: real([sys.executable, str(script), *cmd[1:]], **kw)), \
+ mock.patch.object(StrataEngine, "RESTART_RETRY_S", 0.0):
+ gate.touch()
+ eng = StrataEngine("strata", ["--gate", str(gate), "--fail", str(fail)])
+ try:
+ self.assertEqual(eng.max_context, 4096)
+ self.assertEqual(eng.known_ctx, 4096)
+ fail.touch() # the next start exits before READY, the one after works
+ eng.proc.kill()
+ eng.proc.wait(10)
+ eng.restart()
+ self.assertFalse(fail.exists(), "the failing start never ran")
+ self.assertTrue(eng.alive())
+ self.assertEqual(eng.max_context, 4096)
+ self.assertFalse(eng.starting)
+ finally:
+ gate.touch()
+ eng.unload()
+
+ def test_failed_restart_keeps_the_known_context(self):
+ """After a restart that failed, max_context is 0 but the engine is not starting: a request is checked against
+ the last known context and reaches run() (which starts the engine again), not a 400 about context 0."""
+ tok = ByteTokenizer()
+ eng = MockEngine(tok, "\n\nok", max_context=0)
+ eng.known_ctx, eng.starting = 4096, False
+ svc = Service(eng, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
+ httpd = serve(svc, port=0)
+ base = f"http://127.0.0.1:{httpd.server_address[1]}"
+ try:
+ req = urllib.request.Request(base + "/v1/chat/completions",
+ data=json.dumps({"model": "m", "max_tokens": 16,
+ "messages": [{"role": "user", "content": "hi"}]}).encode(),
+ headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=30) as r:
+ self.assertEqual(r.status, 200)
+ finally:
+ httpd.shutdown()
+
def test_context_zero_is_503(self):
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "\n\nok", max_context=0), tok,
diff --git a/serve/test_vram.py b/serve/test_vram.py
new file mode 100644
index 0000000000..e1abe0b6b6
--- /dev/null
+++ b/serve/test_vram.py
@@ -0,0 +1,136 @@
+"""#533: POST /v1/vram (opt-in hot VRAM resize) - the engine command, the route, the config key, a reserve kept for
+an unloaded engine and applied when it loads again."""
+import io
+import json
+import queue
+import unittest
+import urllib.error
+import urllib.request
+from pathlib import Path
+from unittest import mock
+
+from serve.frontend import ChatTemplate
+from serve.server import ByteTokenizer, EngineDied, Service, StrataEngine, engine_args, serve
+from serve.test_lifecycle import ResidentEngine
+
+
+class VramEngine(ResidentEngine):
+ """The resident test engine with an elastic cache: VRAM n frees what the reserve asks for, None takes the
+ start's (700 MiB)."""
+ def __init__(self, tok, elastic=True):
+ super().__init__(tok)
+ self.elastic, self.calls = elastic, []
+
+ def vram(self, reserve_mib, timeout=120.0):
+ self.calls.append(reserve_mib)
+ if not self.elastic:
+ raise ValueError("VRAM needs an engine started with --vram-elastic (one NVIDIA GPU, --serve)")
+ r = 700 if reserve_mib is None else reserve_mib
+ out = {"reserve_mib": r, "expert_slots": max(100, 4000 - r), "expert_cache_mib": max(500, 6000 - r),
+ "expert_cache_full_mib": 6000, "vram_free_mib": r, "prompt_chunk": 4096}
+ self.info["vram"] = out
+ return out
+
+
+class EngineCommand(unittest.TestCase):
+ def engine(self, replies):
+ e = StrataEngine("missing-executable", [], lazy=True)
+ e.proc = mock.Mock()
+ e.proc.poll.return_value = None
+ e.proc.stdin = io.StringIO()
+ e.ended = False
+ e.lines = queue.Queue()
+ for r in replies:
+ e.lines.put(r)
+ return e
+
+ def test_the_line_and_the_answer(self):
+ e = self.engine(["strata serve: something else\n",
+ "VRAM reserve_mib=6000 expert_slots=1200 expert_slots_full=3700 expert_cache_mib=1536 "
+ "expert_cache_full_mib=4900 vram_free_mib=6100 prompt_chunk=2048\n"])
+ out = e.vram(6000)
+ self.assertEqual(e.proc.stdin.getvalue(), "VRAM 6000\n")
+ self.assertEqual(out["expert_slots"], 1200)
+ self.assertEqual(out["prompt_chunk"], 2048)
+ self.assertEqual(e.info["expert_slots"], 1200)
+ e = self.engine(["VRAM reserve_mib=700 expert_slots=3700\n"])
+ e.vram(None)
+ self.assertEqual(e.proc.stdin.getvalue(), "VRAM\n")
+
+ def test_refused_and_died(self):
+ e = self.engine(["ERR VRAM needs an engine started with --vram-elastic\n"])
+ with self.assertRaisesRegex(ValueError, "--vram-elastic"):
+ e.vram(100)
+ e = self.engine([None])
+ with self.assertRaises(EngineDied):
+ e.vram(100)
+
+ def test_config_key(self):
+ cfg = {"args": ["--max-context", "8192"]}
+ self.assertEqual(engine_args(cfg), ["--max-context", "8192"]) # off unless asked
+ self.assertEqual(engine_args({**cfg, "vram_elastic": True, "vram_segment_mib": 256}),
+ ["--max-context", "8192", "--vram-elastic", "--vram-segment-mib", "256"])
+ self.assertEqual(engine_args({**cfg, "vram_elastic": "yes"}), ["--max-context", "8192"])
+
+
+class Route(unittest.TestCase):
+ def setUp(self):
+ tok = ByteTokenizer()
+ self.engine = VramEngine(tok)
+ self.svc = Service(self.engine, tok, ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
+ self.httpd = serve(self.svc, port=0)
+ self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
+
+ def tearDown(self):
+ self.httpd.shutdown()
+ self.httpd.server_close()
+
+ def request(self, path, body=None, headers=None):
+ req = urllib.request.Request(self.base + path, data=json.dumps(body).encode() if body is not None else None,
+ headers={"Content-Type": "application/json", **(headers or {})})
+ try:
+ response = urllib.request.urlopen(req, timeout=10)
+ except urllib.error.HTTPError as error:
+ response = error
+ with response:
+ return response.status, json.loads(response.read().decode())
+
+ def test_resize_and_status(self):
+ self.engine.loaded = True
+ code, body = self.request("/v1/vram", {"reserve_mib": 4000})
+ self.assertEqual(code, 200, body)
+ self.assertEqual(body["status"], "ok")
+ self.assertEqual(body["expert_cache_mib"], 2000)
+ self.assertEqual(self.request("/v1/status")[1]["vram"]["expert_cache_mib"], 2000)
+ code, body = self.request("/v1/vram", {"reserve_mib": None}) # back to the start's reserve
+ self.assertEqual(code, 200)
+ self.assertEqual(self.engine.calls, [4000, None])
+
+ def test_bad_input_busy_and_foreign_pages(self):
+ self.engine.loaded = True
+ for bad in ({"reserve_mib": -1}, {"reserve_mib": "8 GiB"}, {"reserve_mib": True}, {"reserve_mib": 1.5}):
+ self.assertEqual(self.request("/v1/vram", bad)[0], 400, bad)
+ self.assertEqual(self.request("/v1/vram", {"reserve_mib": 1}, {"Origin": "https://other.example"})[0], 403)
+ self.assertEqual(self.request("/v1/vram", {"reserve_mib": 1}, {"Content-Type": "text/plain"})[0], 415)
+ self.svc.vram_wait_s = 0.2
+ with self.svc.fifo: # a request is running
+ self.assertEqual(self.request("/v1/vram", {"reserve_mib": 1})[0], 409)
+ self.assertEqual(self.engine.calls, [])
+
+ def test_an_engine_without_the_flag_says_so(self):
+ self.engine.loaded, self.engine.elastic = True, False
+ code, body = self.request("/v1/vram", {"reserve_mib": 3000})
+ self.assertEqual(code, 400)
+ self.assertIn("--vram-elastic", body["error"]["message"])
+
+ def test_unloaded_keeps_the_reserve_for_the_next_load(self):
+ code, body = self.request("/v1/vram", {"reserve_mib": 5000})
+ self.assertEqual(code, 200)
+ self.assertEqual(body["status"], "not loaded")
+ self.assertEqual(self.engine.calls, [])
+ self.assertEqual(self.request("/v1/load", {})[0], 200)
+ self.assertEqual(self.engine.calls, [5000])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/serve/web/app.css b/serve/web/app.css
index 4e8363d4a6..dd9a3e1fe3 100644
--- a/serve/web/app.css
+++ b/serve/web/app.css
@@ -115,7 +115,18 @@ main { flex: 1; min-height: 0; display: flex; }
.table-wrap.all { max-height: 420px; overflow-y: auto; }
.req-totals { margin: 0; font-size: var(--st-fs-sm); }
.req-totals:empty { display: none; }
+.cc-card { display: flex; flex-direction: column; gap: var(--st-s-3); }
+.cc-bars { display: grid; grid-template-columns: 1fr 1fr; gap: var(--st-s-2) var(--st-s-6); }
+.cc-bars[hidden] { display: none; }
+.cc-note { margin: 0; }
+.cc-note:empty { display: none; }
.mcp-card { display: flex; flex-direction: column; gap: var(--st-s-3); }
+.cfg-form { display: grid; grid-template-columns: minmax(180px, max-content) minmax(0, 1fr); gap: var(--st-s-3) var(--st-s-4);
+ align-items: center; }
+.cfg-form label { font-size: var(--st-fs-sm); font-weight: var(--st-fw-medium); }
+.cfg-form label code { font: 400 12px var(--st-font-mono); color: var(--st-ink-muted); display: block; }
+.cfg-form .st-input { height: 38px; width: 100%; max-width: 360px; }
+.cfg-actions { display: flex; align-items: center; gap: var(--st-s-3); }
.mcp-server { display: flex; flex-direction: column; gap: var(--st-s-2); padding-top: var(--st-s-3); border-top: 1px solid var(--st-line-soft); }
.mcp-server:first-child { padding-top: 0; border-top: 0; }
.mcp-server__head { display: flex; align-items: center; gap: var(--st-s-2); flex-wrap: wrap; font-size: var(--st-fs-sm); }
@@ -155,6 +166,9 @@ main { flex: 1; min-height: 0; display: flex; }
@media (max-width: 1000px) {
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.monitor-row { grid-template-columns: 1fr; }
+ .cc-bars { grid-template-columns: 1fr; }
+ .cfg-form { grid-template-columns: 1fr; gap: 4px; }
+ .cfg-form .st-input { margin-bottom: var(--st-s-2); }
}
@media (max-width: 640px) {
.st-header { padding: 0 var(--st-s-3); gap: var(--st-s-2); }
diff --git a/serve/web/app.js b/serve/web/app.js
index 6e8fa6b293..ade19016ba 100644
--- a/serve/web/app.js
+++ b/serve/web/app.js
@@ -79,6 +79,7 @@ function showTab(name) {
if (location.hash.slice(1) !== tab) history.replaceState(null, "", tab === "chat" ? location.pathname : `#${tab}`);
if (tab === "chat") $("input").focus();
if (tab === "monitor") loadMcp();
+ if (tab === "about") loadConfig();
if (lastMetrics) render(lastMetrics);
}
for (const b of document.querySelectorAll(".st-tab")) b.onclick = () => showTab(b.dataset.tab);
@@ -190,9 +191,43 @@ function render(m) {
}
if (live.queued > 0) setPill("queued", `${live.queued} queued`);
if (tab === "monitor") renderMonitor(live, hw, st, eng, h, last, m.requests || [], m.totals, m.requests_kept);
+ if (tab === "monitor") renderConvCache(m.conversation_cache);
if (tab === "about") renderAbout(eng, hw, st);
}
+// #596: the conversation cache - the prompt's state the engine keeps between requests (always), and the whole
+// conversations it parks in RAM when "--conversation-cache-mib N" is in the run config's args (opt-in)
+function since(t) {
+ if (!t) return "";
+ const s = Math.max(0, Date.now() / 1000 - t);
+ return s < 60 ? "just now" : s < 3600 ? `${fmt(s / 60)} min ago` : `${fmt(s / 3600, 1)} h ago`;
+}
+function renderConvCache(c) {
+ $("cc-card").hidden = !c;
+ if (!c) return; // an older server
+ const pct = (a, b) => (b ? `${Math.min(100, (100 * a) / b)}%` : "0%");
+ $("cc-bars").hidden = !c.enabled;
+ if (c.enabled) {
+ $("cc-slots-text").textContent = `${fmt(c.parked)} / ${fmt(c.slots)}`;
+ $("cc-slots-bar").style.width = pct(c.parked, c.slots);
+ const budget = c.budget_mib * 1048576;
+ $("cc-mem-text").textContent = `${gb(c.bytes)} / ${gb(budget)} GB`;
+ $("cc-mem-bar").style.width = pct(c.bytes, budget);
+ }
+ $("cc-sum").textContent = c.requests ? `${fmt(c.requests_reused)} of ${fmt(c.requests)} requests reused part of their prompt` : "";
+ const share = c.prompt_tokens ? ` (${fmt((100 * c.reused_tokens) / c.prompt_tokens)}% of all prompt tokens)` : "";
+ const event = c.last_event ? `${c.last_event === "parked" ? "Parked" : "Restored"} ${fmt(c.last_tokens)} tokens, ${since(c.last_at)}` : null;
+ facts($("cc-facts"), [
+ ["Last request", c.last_prompt != null ? `${fmt(c.last_reused || 0)} of ${fmt(c.last_prompt)} prompt tokens reused` : null],
+ ["Reused since start", c.requests ? `${fmt(c.reused_tokens)} tokens${share}` : null],
+ ["Parked / restored", c.enabled ? `${fmt(c.parks)} / ${fmt(c.restores)}${c.evictions ? ` · ${fmt(c.evictions)} evicted` : ""}` : null],
+ ["Last switch", c.enabled ? event : null],
+ ]);
+ $("cc-note").textContent = c.enabled
+ ? "A request that continues a parked conversation gets its state back instead of reading it again; the oldest goes when the slots or the memory are full."
+ : "The engine keeps the last conversation's state, so a follow-up reads only what is new. To keep several conversations (agents taking turns), add \"--conversation-cache-mib\", \"8192\" to the run config's args (docs/DETAILS.md).";
+}
+
function renderTotals(t) {
if (!t || !t.requests) return "";
const since = new Date(t.since * 1000).toLocaleString([], {weekday: "short", hour: "2-digit", minute: "2-digit"});
@@ -302,7 +337,9 @@ function renderMonitor(live, hw, st, eng, h, last, requests, totals, kept) {
const [cls, text] = badge[r.finish] || ["", r.finish || "–"];
const t = new Date(r.time * 1000).toLocaleTimeString([], {hour: "2-digit", minute: "2-digit", second: "2-digit"});
const proj = r.projection == null ? "" : ` ${r.projection ? "ESP" : "stock"}`;
- const hit = r.hit_rate == null ? "–" : `${(r.hit_rate * 100).toFixed(1)}%`;
+ // #588: the VRAM share; the PCIe share (--pcie-frac) beside it when there is one
+ const hit = r.hit_rate == null ? "–" : `${(r.hit_rate * 100).toFixed(1)}%` +
+ (r.pcie_share ? ` +${(r.pcie_share * 100).toFixed(1)}% PCIe` : "");
return `
${esc(t)}
${esc(text)}${proj}
${fmt(r.prompt_tokens)}
${fmt(r.reused)}
${fmt(r.output_tokens)}
${fmt(r.decode_tok_s, 1)}
${hit}
${fmt(r.duration_s, 1)} s
`;
@@ -394,6 +431,62 @@ function renderMcp() {
}).join("");
}
+// ------------------------------------------------------------------ Model settings (GET / POST /config, #564)
+// A few documented keys of the run config (strata-.json), for every client, from the next start on. The
+// server lists them, checks every value and keeps every other key of the file as it is.
+let cfgKeys = [];
+async function loadConfig() {
+ let r;
+ try { r = await fetch("config", {headers: headers()}); } catch (e) { return; }
+ if (!r.ok) { $("cfg-card").hidden = true; return; } // no run config, an older server, or no key yet
+ const c = await r.json();
+ cfgKeys = c.keys || [];
+ $("cfg-file").textContent = c.file || "";
+ $("cfg-form").innerHTML = cfgKeys.map((k, i) => {
+ const id = `cfg-${i}`, v = k.value;
+ let input;
+ if (k.kind === "bool" || k.kind === "enum") {
+ const opts = k.kind === "bool" ? [["true", "on"], ["false", "off"]] : k.choices.map((x) => [x, x]);
+ const cur = v == null ? "" : String(v);
+ input = ``;
+ } else {
+ const text = v == null ? "" : Array.isArray(v) ? v.join(", ") : String(v);
+ input = ``;
+ }
+ return `${input}`;
+ }).join("");
+ $("cfg-card").hidden = false;
+}
+function configValue(k, el) {
+ const s = el.value.trim();
+ if (s === "") return null;
+ if (k.kind === "bool") return s === "true";
+ if (k.kind === "number") return Number(s);
+ return s; // enum, or names (the server splits them at commas)
+}
+$("cfg-save").addEventListener("click", async () => {
+ const set = {};
+ cfgKeys.forEach((k, i) => {
+ const v = configValue(k, $(`cfg-${i}`));
+ const old = Array.isArray(k.value) ? k.value.join(", ") : k.value;
+ if (JSON.stringify(v) !== JSON.stringify(old ?? null)) set[k.key] = v;
+ });
+ if (!Object.keys(set).length) { $("cfg-msg").textContent = "Nothing changed."; return; }
+ try {
+ const r = await fetch("config", {method: "POST", headers: headers(true), body: JSON.stringify({set})});
+ const b = await r.json();
+ if (!r.ok) throw new Error((b.error || {}).message || `HTTP ${r.status}`);
+ $("cfg-msg").textContent = b.changed.length
+ ? `Saved (${b.changed.join(", ")}); the earlier file is ${b.file}.bak. Start the model again to use it.` : "Nothing changed.";
+ loadConfig();
+ } catch (e) {
+ $("cfg-msg").textContent = "";
+ toast("error", "Not saved", String(e.message || e), 6000);
+ }
+});
+
// ------------------------------------------------------------------ Markdown (escaped first, then formatted)
function inline(s) {
const codes = [];
diff --git a/serve/web/index.html b/serve/web/index.html
index d858d2f08a..5c26ad8279 100644
--- a/serve/web/index.html
+++ b/serve/web/index.html
@@ -117,7 +117,7 @@
Time
Status
Prompt
Reused
Output
-
Tok/s
Hit rate
Duration
+
Tok/s
VRAM hit rate
Duration
No requests yet
@@ -125,6 +125,18 @@
+
+
Conversation cache
+
+
Parked conversations–
+
+
Their memory (RAM)–
+
+
+
+
+
+
MCP servers
@@ -161,6 +173,14 @@
Chats, settings and the key are kept in this browser only.
Strata on GitHub
+
+
Model settings
+
Kept in the model's run config for every client. An empty field is the default. They
+ take effect the next time the model starts (close Strata's window and start it again).
+
+
+
+
diff --git a/setup.py b/setup.py
index bc5cdd3002..80d1fba57c 100644
--- a/setup.py
+++ b/setup.py
@@ -28,8 +28,8 @@
--host 0.0.0.0 --api-key KEY (reach it from other devices on your network), --experimental-speed-projection on|off
(EXPERIMENTAL, off by default),
--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), --resident-budget-gib N (UD-Q4_K_XL's experts in RAM), --kv-streaming
-on|off|auto.
+engine), --check (only check this PC), --resident-budget-gib N (UD-Q4_K_XL's or UD-IQ4_XS's experts in RAM),
+--kv-streaming on|off|auto.
Setup recommends, it never forces: the recommended answers are the defaults (--yes, or Enter), and a bigger choice
than it recommends - a longer context, more GPUs, a bigger RAM budget, a size it thinks will not fit - is kept, with
@@ -104,7 +104,18 @@ def hf_unpinned(url: str) -> str:
# 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
-MIN_ENGINE = (0, 1, 38) # v0.1.38: prompts faster (one gather per expert group #372, the first chunk's PLE rows beside layer 0 #374, DeltaNet three heads per thread #413), --kv q4_0 prompts on tensor cores (#452), Q5_0 experts on the GPU (#473), IQ4_XS on AVX-2 (#415), unbuffered expert loading on Windows (#357 #362), --peer-device (#531), a 6 GB card starts (#496), PR batch; v0.1.37: a silent engine is restarted (#481), Windows AMD counts the desktop's VRAM (#380 #377 #497), a steadier PCIe probe (#485), fixes #496 #495 #498 #505 #493; v0.1.36: a cancelled prompt logged as read so far (#471), the draft-head hint (#474), UPDATE.bat (#475), --expert-profile-save (#477); v0.1.35: Windows AMD uses its bundled HIP runtime (#468 #461), the low-RAM resident mode on Windows 32 GB (#467), fixes #460 #459 #446 #447 #457 #448 #444; v0.1.34: AMD on Windows (a ready-made HIP engine), an MCP server for AI assistants (tools/strata_mcp.py), a shorter README; v0.1.33: a portable image encoder again (#411 #412), setup recommends instead of forcing (#406 #403 #364 #384), fixes #352 #365 #369 #371 #375 #393 #408 #414; v0.1.32: split prompts faster (#340), AMD router +12%, Unsloth Q4 in setup, faster Q4 prompts, #326/#327/#342/#344 fixes, PR batch; v0.1.31: Unsloth UD-Q4_K_XL (experimental), GGUF-in-place low-RAM mode, Windows GGUF load 2x, server race + tokenizer fixes, AMD intrinsics; v0.1.30: short prompts faster (streaming from 1024 tokens), resident low-RAM variant, multi-GPU session carve, RDNA4; v0.1.29: sampled answers faster (split top-k), #154 correctness fixes; v0.1.28: the expert cache reserves the draft head, a cancelled request no longer fails the next; v0.1.27: RTX 20 (sm_75) in the ready-made engine, the HIP build without CUDA headers; v0.1.26: the draft layer's prompt pass in batches; v0.1.25: faster prompts (grouping off the copy engine, fused hyper-connection kernels), AMD HIP backend, --kv k8v4; v0.1.24: long prompts faster (QSA select on tensor cores); v0.1.23: image requests honor sampling, 8 GB cards start, batched verify window; v0.1.22: faster prompts (tensor-core attention), multi-GPU across images/steering/KV streaming; v0.1.21: multi-GPU layer split (--gpus); v0.1.20: system-prompt checkpoint, PCIe probe, hit rate; v0.1.19: penalties
+# Older NVIDIA GPUs (experimental): CUDA 13 dropped Pascal (sm_60/61) and Volta (sm_70), so a model whose GPUs include
+# one runs a second engine, built with CUDA 12.9 (-DSTRATA_EXPERIMENTAL_SM60=ON) and kept in its own folder: the
+# ready-made one is CUDA12_ASSET (Windows; on Linux it is compiled here with a CUDA 12.x toolkit). One engine runs per
+# model, so the choice is per model config, by its oldest GPU; --cuda 12|13 overrides it (docs/OLDER_GPUS.md).
+CUDA13_MIN_ARCH = 75 # the oldest compute capability CUDA 13 compiles for (sm_75, RTX 20)
+CUDA12_ASSET = "strata-windows-x64-cuda12.zip" if WIN else "strata-linux-x64-cuda12.zip"
+CUDA12_WHEELS = ["nvidia-cublas-cu12==12.9.1.4", "nvidia-cuda-runtime-cu12==12.9.79"]
+# CUDA 12.x minor-version compatibility (NVIDIA's table: Linux 525.60.13, Windows 527.41); the wheels match the 12.9.1
+# toolkit the CUDA 12 zip is built with (cuBLAS 12.9.1.4, runtime 12.9.79). Not tested on such an old driver here.
+CUDA12_MIN_DRIVER = 528 if WIN else 525
+ENGINE12_DIR = "engine-cuda12"
+MIN_ENGINE = (0, 1, 39) # v0.1.39: the #577 file-tier regression fixed, the OpenAI Responses API (#451, Codex), a reply stuck on one token ended (#606), the head before the arena (#620), effort_position (#458), --vram-reserve hot resize opt-in (#533), PR batch; v0.1.38: prompts faster (one gather per expert group #372, the first chunk's PLE rows beside layer 0 #374, DeltaNet three heads per thread #413), --kv q4_0 prompts on tensor cores (#452), Q5_0 experts on the GPU (#473), IQ4_XS on AVX-2 (#415), unbuffered expert loading on Windows (#357 #362), --peer-device (#531), a 6 GB card starts (#496), PR batch; v0.1.37: a silent engine is restarted (#481), Windows AMD counts the desktop's VRAM (#380 #377 #497), a steadier PCIe probe (#485), fixes #496 #495 #498 #505 #493; v0.1.36: a cancelled prompt logged as read so far (#471), the draft-head hint (#474), UPDATE.bat (#475), --expert-profile-save (#477); v0.1.35: Windows AMD uses its bundled HIP runtime (#468 #461), the low-RAM resident mode on Windows 32 GB (#467), fixes #460 #459 #446 #447 #457 #448 #444; v0.1.34: AMD on Windows (a ready-made HIP engine), an MCP server for AI assistants (tools/strata_mcp.py), a shorter README; v0.1.33: a portable image encoder again (#411 #412), setup recommends instead of forcing (#406 #403 #364 #384), fixes #352 #365 #369 #371 #375 #393 #408 #414; v0.1.32: split prompts faster (#340), AMD router +12%, Unsloth Q4 in setup, faster Q4 prompts, #326/#327/#342/#344 fixes, PR batch; v0.1.31: Unsloth UD-Q4_K_XL (experimental), GGUF-in-place low-RAM mode, Windows GGUF load 2x, server race + tokenizer fixes, AMD intrinsics; v0.1.30: short prompts faster (streaming from 1024 tokens), resident low-RAM variant, multi-GPU session carve, RDNA4; v0.1.29: sampled answers faster (split top-k), #154 correctness fixes; v0.1.28: the expert cache reserves the draft head, a cancelled request no longer fails the next; v0.1.27: RTX 20 (sm_75) in the ready-made engine, the HIP build without CUDA headers; v0.1.26: the draft layer's prompt pass in batches; v0.1.25: faster prompts (grouping off the copy engine, fused hyper-connection kernels), AMD HIP backend, --kv k8v4; v0.1.24: long prompts faster (QSA select on tensor cores); v0.1.23: image requests honor sampling, 8 GB cards start, batched verify window; v0.1.22: faster prompts (tensor-core attention), multi-GPU across images/steering/KV streaming; v0.1.21: multi-GPU layer split (--gpus); v0.1.20: system-prompt checkpoint, PCIe probe, hit rate; v0.1.19: penalties
PY_PACKAGES = ["numpy", "jinja2", "regex", "pyyaml", "tqdm", "requests", "cmake", "ninja", "pillow", "psutil"]
REQUIREMENTS = ROOT / "requirements.txt" # the same packages and their dependencies, pinned (#214)
@@ -127,7 +138,17 @@ def hf_unpinned(url: str) -> str:
# keeps a RAM budget of them (--resident-budget-gib, chosen below) and reads the rest from the GGUF on the SSD
"UD-Q4_K_XL": {"about": "4-bit (Unsloth Dynamic), EXPERIMENTAL: the best quality, but most experts come from the "
"SSD on a 64 GB PC (7-8.5 tokens/s measured)", "download_gb": 111.3, "ram_gb": 48,
- "arena_gb": 77.0, "families": ("unsloth",), "budget": True},
+ "arena_gb": 77.0, "families": ("unsloth",), "budget": True, "nvidia_only": True,
+ "experimental": True},
+ # #621: Unsloth's UD-IQ4_XS - IQ3_S gate/up experts with IQ4_NL (43 layers) or Q8_0 (5) downs, the dense side as
+ # UD-Q4_K_XL's; three shards. A regular choice from 0.1.39 (no longer experimental). Its 59.5 GB of experts: a
+ # RAM budget of them, like UD-Q4_K_XL, but far fewer read from the SSD on a 64 GB PC and none from ~80 GB of RAM.
+ # Images: the vision path has no restriction for this pack (the same base model and image encoder), so setup asks
+ "UD-IQ4_XS": {"about": "~4-bit i-quant (Unsloth Dynamic), between IQ3_S and UD-Q4_K_XL in quality; on a PC with "
+ "less than ~80 GB of RAM part of its experts are read from the SSD",
+ "download_gb": 93.7, "ram_gb": 48, "arena_gb": 59.5, "families": ("unsloth",), "budget": True,
+ "shards": 3, "file": "Qwen3.8-Flash-Next-{q}-0000{i}-of-00003.gguf", "engine": (0, 1, 38),
+ "vision": True},
}
# The experimental Unsloth file's four shards at the pinned revision: name -> (bytes, sha256), checked after the
# download (setup trusts no other model file by name and size alone either: check_shards reads their directories).
@@ -141,6 +162,15 @@ def hf_unpinned(url: str) -> str:
"Qwen3.8-Flash-Next-UD-Q4_K_XL-00004-of-00004.gguf":
(12087983520, "753bda48b98ba4f1636134a90a967de1b2d3908a236c026e464777342e53510a"),
}
+# #621: UD-IQ4_XS's three shards at the same revision (sizes and SHA-256: the Hub's LFS pointers)
+UNSLOTH_IQ4_XS_SHARDS = {
+ "Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf":
+ (10946624, "5ce89370720f8bf90890f439361282104c1aa1482d4013bb9a50923e758e71a4"),
+ "Qwen3.8-Flash-Next-UD-IQ4_XS-00002-of-00003.gguf":
+ (49835229856, "577a38a2392b40ca2193cea502e1d92f60b8cd370675d308e0ec21885d9daaa7"),
+ "Qwen3.8-Flash-Next-UD-IQ4_XS-00003-of-00003.gguf":
+ (43836407744, "d4634e6d84f0ebb0940be15c90d3790bf6464e3dea3a1cddc567dc0e83ad8833"),
+}
UNSLOTH_ENGINE = (0, 1, 32) # the first engine setup configures for UD-Q4_K_XL (0.1.31 ran it by hand)
UNSLOTH_RAM_LEFT_GB = 24 # RAM beside the budget: the OS, the engine, and the file cache the rest is read through
# Contexts past 262144 (the model's trained length) extend it by rope scaling: for the context it will
@@ -173,20 +203,24 @@ def hf_unpinned(url: str) -> str:
"mmproj_hf": hf("ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF"),
"mmproj": "mmproj-Qwen3.8-Flash-Next-BF16.gguf", "name": "qwen3.8-flash-next-coder",
"profile": "expert-profile-coder.bin"},
- # EXPERIMENTAL: Unsloth's UD-Q4_K_XL of the original model (docs/UNSLOTH_Q4.md): four shards, no images yet
- "unsloth": {"title": "Qwen3.8-Flash-Next (Unsloth)", "by": "Unsloth's 4-bit quantization (EXPERIMENTAL)",
- "about": "4-bit, 111 GB download, most experts read from the SSD: slow (7-8.5 tokens/s on a 64 GB PC)",
+ # Unsloth's UD-IQ4_XS (three shards, #621; a regular choice from 0.1.39) and the EXPERIMENTAL UD-Q4_K_XL (four)
+ # of the original model (docs/UNSLOTH_Q4.md); "experimental" and "vision" are per model (MODELS)
+ "unsloth": {"title": "Qwen3.8-Flash-Next (Unsloth)", "by": "Unsloth's ~4-bit quantizations",
+ "about": "UD-IQ4_XS: a 94 GB download; with less than ~80 GB of RAM part of its experts are read from "
+ "the SSD (UD-Q4_K_XL, 111 GB: experimental)",
"hf": hf("unsloth/Qwen3.8-Flash-Next-GGUF") + "{q}/",
"file": "Qwen3.8-Flash-Next-{q}-0000{i}-of-00004.gguf", "shards": 4, "tag": "unsloth-",
"mmproj_hf": hf("ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF"),
"mmproj": "mmproj-Qwen3.8-Flash-Next-BF16.gguf", "name": "qwen3.8-flash-next-unsloth",
- "experimental": True, "vision": False, "pack_args": ["--compat-bf16"], "sha256": UNSLOTH_SHARDS},
+ "vision": False, "pack_args": ["--compat-bf16"],
+ "sha256": {**UNSLOTH_SHARDS, **UNSLOTH_IQ4_XS_SHARDS}},
}
MMPROJ = "mmproj-Qwen3.8-Flash-Next-BF16.gguf"
# EXPERIMENTAL, off by default (setup asks): a control vector shipped with the repository, see its README
ESP_VECTOR = ROOT / "data" / "experimental-speed-projection" / "Qwen3.8-Flash-Next-experimental-speed-projection.gguf"
# the image encoder on the GPU (~1.2 GB at 1024 image tokens) warms up before the engine starts, so the engine
# sizes its expert slots around it and the default reserve (700 MiB) is enough; engines before 0.1.2 need more
+VISION_GPU_SMALL_RESERVE_MIB = 1000 # the tip for images on a <= 12 GB card (the engine's LOW line asked ~1003)
VISION = {"gpu": {"max_tokens": 1024, "reserve_mib": 700},
"cpu": {"max_tokens": 300, "reserve_mib": 700}}
EXE = "strata.exe" if WIN else "strata"
@@ -297,6 +331,74 @@ def page_file_gb():
return max(0.0, (m.ullTotalPageFile - m.ullTotalPhys) / 2**30)
+def cpu_cores():
+ """#642: (performance cores, efficiency cores) of a hybrid CPU (Intel 12th gen+, AMD Zen 5 + Zen 5c), counted
+ as the engine's pool counts them (detect_cpu_topology: physical cores, by Windows' EfficiencyClass or Linux's
+ cpu_capacity); None on a CPU whose cores are all alike, or when the OS does not say."""
+ classes = [] # one entry per physical core: its efficiency/capacity class
+ try:
+ if WIN:
+ k32 = ctypes.windll.kernel32
+ n = ctypes.c_ulong(0)
+ k32.GetLogicalProcessorInformationEx(0, None, ctypes.byref(n)) # RelationProcessorCore: the size
+ if not n.value:
+ return None
+ buf = ctypes.create_string_buffer(n.value)
+ if not k32.GetLogicalProcessorInformationEx(0, buf, ctypes.byref(n)):
+ return None
+ raw, at = buf.raw[:n.value], 0
+ while at + 10 <= len(raw): # SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX: Relationship, Size, then
+ rel, size = struct.unpack_from(" int | None:
+ """#642 (Hardin22's measurement): on a hybrid CPU the expert pool runs best on the P-cores but the host loop's one
+ plus HALF of the E-cores - an E-core runs the expert kernels ~2.2x slower and each layer waits for its slowest
+ part (i9-14900KF, 8P + 16E: 15 workers decoded 165 / 116 tok/s against 106 / 84 with all 23). Only on a CPU with
+ more E-cores than P-cores: on an i7-13700KF (8P + 8E, docs/AMD_HIP.md's gfx1030 report) all 15 workers decoded
+ 38-42 tok/s against 36 with 8, so there the engine's own count stays. None: the engine's own default (one worker
+ per physical core but the host's) stays."""
+ if not cores:
+ return None
+ p, e = cores
+ if e <= p:
+ return None
+ return max(1, p - 1 + e // 2)
+
+
+def recommend_pool_workers(args: list) -> list:
+ """`args` with setup's recommended `--pool-workers` for a hybrid CPU, unless they set one already (a calibration's
+ measured count, or the user's own). A recommendation: the config line can be edited or removed."""
+ n = hybrid_pool_workers(cpu_cores())
+ if n is None or "--pool-workers" in args:
+ return args
+ p, e = cpu_cores()
+ ok(f"hybrid CPU ({p} performance + {e} efficiency cores): {n} CPU expert workers - the performance cores and half "
+ "of the efficiency cores (--pool-workers in the config; START-HERE --calibrate measures it on this PC)")
+ return [*args, "--pool-workers", str(n)]
+
+
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."""
@@ -320,6 +422,48 @@ def cpu_info():
return name, avx2, avx512
+def _cpuid_floor() -> str:
+ """Below AVX2 (Windows): "avx" when the CPU has AVX and the OS saves the YMM registers, "sse4.2" with SSE4.2 and
+ POPCNT, else ""."""
+ try:
+ regs = (ctypes.c_uint32 * 4)()
+ _run_stub(bytes([0x53, 0x49, 0x89, 0xC8, 0x89, 0xD0, 0x31, 0xC9, 0x0F, 0xA2, # push rbx; r8=rcx; eax=edx; ecx=0; cpuid
+ 0x41, 0x89, 0x00, 0x41, 0x89, 0x58, 0x04, 0x41, 0x89, 0x48, 0x08, # [r8]=eax, [r8+4]=ebx, [r8+8]=ecx
+ 0x41, 0x89, 0x50, 0x0C, 0x5B, 0xC3]), # [r8+12]=edx; pop rbx
+ ctypes.addressof(regs), 1)
+ ecx1 = regs[2]
+ if (ecx1 >> 27) & 1 and (ecx1 >> 28) & 1: # OSXSAVE, AVX
+ xcr0 = (ctypes.c_uint32 * 2)()
+ _run_stub(bytes([0x49, 0x89, 0xC8, 0x31, 0xC9, 0x0F, 0x01, 0xD0, # r8=rcx; ecx=0; xgetbv
+ 0x41, 0x89, 0x00, 0x41, 0x89, 0x50, 0x04, 0xC3]), ctypes.addressof(xcr0))
+ if xcr0[0] & 6 == 6:
+ return "avx"
+ return "sse4.2" if (ecx1 >> 20) & 1 and (ecx1 >> 23) & 1 else ""
+ except Exception:
+ return ""
+
+
+def cpu_floor(avx2: bool) -> str:
+ """The experimental older-CPU build this PC needs (#394 #595 #623): "" with AVX2 (the normal engine), "avx" (Sandy /
+ Ivy Bridge, AMD Bulldozer), "none" (SSE4.2 + POPCNT: Nehalem, Westmere), or "unsupported". STRATA_ISA_FLOOR=avx|
+ none asks for that build on any PC (testing it on a newer one)."""
+ forced = os.environ.get("STRATA_ISA_FLOOR", "").strip().lower()
+ if forced in ("avx", "none"):
+ return forced
+ if avx2:
+ return ""
+ if WIN:
+ f = _cpuid_floor()
+ else:
+ try:
+ txt = open("/proc/cpuinfo").read()
+ flags = set(re.search(r"^flags\s*:\s*(.*)$", txt, re.M).group(1).split())
+ except (OSError, AttributeError):
+ flags = set()
+ f = "avx" if "avx" in flags else "sse4.2" if {"sse4_2", "popcnt"} <= flags else ""
+ return {"avx": "avx", "sse4.2": "none"}.get(f, "unsupported")
+
+
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:
@@ -410,22 +554,84 @@ def cc(g) -> str:
return f"{g['arch'][:-1]}.{g['arch'][-1]}"
+OLD_GPUS = None # why Pascal / Volta cards are admitted in this run (old_gpus_opt_in), None: they are not
+
+
def experimental_sm60() -> bool:
- """#295: STRATA_EXPERIMENTAL_SM60=1 admits Pascal (6.x) and Volta (7.0) cards: the community build
- (-DSTRATA_EXPERIMENTAL_SM60=ON, compiled here with a CUDA 12.x toolkit), not the ready-made engine."""
- return os.environ.get("STRATA_EXPERIMENTAL_SM60", "").strip() == "1"
+ """#295: STRATA_EXPERIMENTAL_SM60=1 admits Pascal (6.x) and Volta (7.0) cards, run by the experimental CUDA 12
+ engine (-DSTRATA_EXPERIMENTAL_SM60=ON). So does naming such a card (--gpu N / --gpus), --cuda 12, or a PC that
+ has no newer card (old_gpus_opt_in)."""
+ return os.environ.get("STRATA_EXPERIMENTAL_SM60", "").strip() == "1" or OLD_GPUS is not None
def sm60_card(arch) -> bool:
return 60 <= int(arch) <= 70
+def old_gpus_opt_in(found, named=(), cuda=None, other=False):
+ """Why this run may use Pascal / Volta cards (the experimental CUDA 12 engine), or None. The cards are an opt-in:
+ the user named one (`named`: --gpu / --gpus), asked for --cuda 12, set STRATA_EXPERIMENTAL_SM60=1, or the PC has
+ no card the ready-made engine runs on and no supported AMD card (`other`; it used to stop there). A PC with a
+ newer card keeps recommending it."""
+ old = [g for g in found if sm60_card(g["arch"])]
+ if not old:
+ return None
+ if os.environ.get("STRATA_EXPERIMENTAL_SM60", "").strip() == "1":
+ return "STRATA_EXPERIMENTAL_SM60=1"
+ if str(cuda) == "12":
+ return "--cuda 12"
+ picked = [g for g in old if g["index"] in set(named)]
+ if picked:
+ return "you chose " + ", ".join(f"GPU {g['index']} ({g['name']})" for g in picked)
+ if not other and not any(int(g["arch"]) >= CUDA13_MIN_ARCH for g in found):
+ return "it is the only kind of NVIDIA GPU in this PC"
+ return None
+
+
+def named_gpus(gpu, gpus) -> list:
+ """The card numbers --gpu / --gpus name (an unreadable value: none; parse_gpus says what is wrong later)."""
+ try:
+ if gpus and str(gpus).strip().lower() != "all":
+ return [int(x) for x in str(gpus).split(",") if x.strip()]
+ return [int(gpu)] if gpu is not None else []
+ except ValueError:
+ return []
+
+
+def cuda_choice(archs, cuda=None):
+ """The CUDA toolkit of one model's engine: (12 or 13, why). 13 (the ready-made engine) unless a card is older
+ than CUDA 13 supports (Pascal / Volta: CUDA 13 cannot compile for them) - one engine runs per model, so its oldest
+ card decides. `cuda` (--cuda 12|13) overrides it; setup recommends, it does not refuse (the caller warns)."""
+ archs = sorted({int(x) for x in archs})
+ old = [a for a in archs if a < CUDA13_MIN_ARCH]
+ if str(cuda) == "13":
+ return 13, ("--cuda 13 (as you chose)" + (f"; CUDA 13 has no code for sm_{old[0]}: the engine will not run "
+ "on that card" if old else ""))
+ if str(cuda) == "12":
+ return 12, "--cuda 12 (as you chose" + ("; RTX 50 (sm_120) engines built with CUDA 12.8 crashed on long "
+ "prompts, #220" if archs and archs[-1] >= 120 else "") + ")"
+ if old:
+ return 12, (f"sm_{old[0]} is older than CUDA 13 supports (it dropped Pascal and Volta): this model runs the "
+ "experimental CUDA 12 engine")
+ return 13, None
+
+
+def engine_dir(toolkit=13) -> Path:
+ """The folder of the engine a model runs: engine/ (CUDA 13, or HIP), engine-cuda12/ (the experimental one)."""
+ return ROOT / (ENGINE12_DIR if int(toolkit) == 12 else "engine")
+
+
+def config_toolkit(cfg: dict) -> int:
+ """12 when a model config runs the experimental CUDA 12 engine (its exe is in engine-cuda12/), else 13."""
+ return 12 if cfg.get("cuda") == 12 or Path(str(cfg.get("exe", ""))).parent.name == ENGINE12_DIR else 13
+
+
def gpu_problem(g, together=False):
"""Why Strata cannot use this card, in plain words (None: it can)."""
if int(g["arch"]) < 75 and not (sm60_card(g["arch"]) and experimental_sm60()):
return (f"not supported - older than the RTX 20 series (compute capability {cc(g)}; Strata needs 7.5 or "
- "newer" + ("; STRATA_EXPERIMENTAL_SM60=1 tries the community build for it" if sm60_card(g["arch"])
- else "") + ")")
+ "newer" + ("; experimental: choose it with --gpu " + str(g["index"]) + " (the CUDA 12 engine, "
+ "docs/OLDER_GPUS.md)" if sm60_card(g["arch"]) else "") + ")")
if together and g["vram_gb"] < SPLIT_MIN_VRAM_GB - 0.5:
return (f"not supported together with other GPUs - {g['vram_gb']:.0f} GB of VRAM (a card sharing the model "
f"needs {SPLIT_MIN_VRAM_GB} GB or more)")
@@ -515,9 +721,9 @@ def check_gpus(sel, found, what="", yes=False, named=False) -> None:
fail(f"GPU {i}{'' if g is None else ' (' + g['name'] + ')'} {what}cannot be used: {p}", hint)
-def engine_archs():
+def engine_archs(toolkit=13):
"""The GPU generations the installed engine has code for: (archs, ptx), or None when there is none."""
- info = ROOT / "engine" / "BUILD.json"
+ info = engine_dir(toolkit) / "BUILD.json"
try:
meta = json.loads(info.read_text())
except (OSError, ValueError):
@@ -534,8 +740,8 @@ def engine_archs_hip():
return [str(x) for x in meta.get("archs", [])] if meta.get("backend") == "hip" else None
-def engine_runs_on(g) -> bool:
- ea = engine_archs()
+def engine_runs_on(g, toolkit=13) -> bool:
+ ea = engine_archs(toolkit)
if ea is None or not ea[0]:
return True
archs, ptx = ea
@@ -608,6 +814,23 @@ def split_mmap(cfg: dict) -> bool:
return True
+def model_file(fam: dict, model: str, i: int) -> str:
+ """Shard i's file name: the family's pattern, or the model's own (#621: UD-IQ4_XS has three shards, not four)."""
+ return MODELS.get(model, {}).get("file", fam["file"]).format(q=model, i=i)
+
+
+def model_shards(fam: dict, model: str) -> int:
+ return MODELS.get(model, {}).get("shards", fam.get("shards", 2))
+
+
+def budget_model(cfg: dict) -> str:
+ """The Unsloth model a config with a RAM budget runs, from its --native shard's name (UD-Q4_K_XL by default)."""
+ a = cfg.get("args", [])
+ native = Path(a[a.index("--native") + 1]).name.upper() if "--native" in a and a.index("--native") + 1 < len(a) \
+ else ""
+ return next((m for m, d in MODELS.items() if d.get("budget") and f"-{m}-" in native), "UD-Q4_K_XL")
+
+
def unsloth_split_need_gb(model="UD-Q4_K_XL") -> float:
"""#498: the RAM UD-Q4_K_XL needs on several GPUs, where it has no RAM budget (the engine refuses
--resident-budget-gib with a layer split): its GGUF files and UNSLOTH_RAM_LEFT_GB more (~135 GB). Measured safe
@@ -623,18 +846,42 @@ def split_budget(cfg: dict) -> bool:
a = cfg.get("args", [])
if "--resident-budget-gib" not in a:
return False
- need, ram = unsloth_split_need_gb(), ram_gb()
+ model = budget_model(cfg)
+ need, ram = unsloth_split_need_gb(model), ram_gb()
if ram < need:
- fail(f"UD-Q4_K_XL cannot share its RAM budget across GPUs (the engine has no layer split with it), and without "
+ fail(f"{model} cannot share its RAM budget across GPUs (the engine has no layer split with it), and without "
f"the budget it needs ~{need:.0f} GB of RAM (its GGUF files and {UNSLOTH_RAM_LEFT_GB} GB more); this PC "
f"has {ram:.0f} GB", "start it on one GPU: START-HERE.bat --gpu N (Linux: ./setup.sh --gpu N)")
i = a.index("--resident-budget-gib")
del a[i:i + 2]
- ok("UD-Q4_K_XL on several GPUs: no RAM budget (the engine has none with a layer split) - all its experts are "
+ ok(f"{model} on several GPUs: no RAM budget (the engine has none with a layer split) - all its experts are "
"loaded into RAM from the model files at start, and the files pass through the OS file cache (#498)")
return True
+REMOTE_EXPERT_OPT = "--remote-expert-opt"
+
+
+def recommend_remote_expert_opt(cfg: dict, off: bool = False) -> None:
+ """0.1.39b (#578): a config on two or more GPUs gets --remote-expert-opt - the helper expert caches
+ (--expert-cache-device1..3) then stay complementary to the main GPU's, return their rows already weighted and skip
+ the CPU's activation quantization where no expert is left to it (dual RTX 4090: +63% mixed, +132% code over the
+ plain helper path). The engine uses it only with a helper cache; a layer split runs as before. A recommendation:
+ `off` (setup's --no-remote-expert-opt) or "remote_expert_opt": false in the config keeps it out, and a single-GPU
+ config is not touched."""
+ if not isinstance(cfg.get("gpu"), list) or len(cfg["gpu"]) < 2:
+ return
+ args = cfg.setdefault("args", [])
+ if off or cfg.get("remote_expert_opt") is False:
+ if REMOTE_EXPERT_OPT in args:
+ args.remove(REMOTE_EXPERT_OPT)
+ return
+ if REMOTE_EXPERT_OPT not in args:
+ args.append(REMOTE_EXPERT_OPT)
+ ok("multi-GPU: --remote-expert-opt (helper expert caches complementary to the main GPU's, #578; "
+ "--no-remote-expert-opt leaves it out)")
+
+
def offer_together(cfg_path: Path, cfg: dict, yes: bool) -> dict:
"""Starting a model set up for one card on a PC with two or more that can share it: asked once (the answer is
saved in its config)."""
@@ -646,7 +893,7 @@ def offer_together(cfg_path: Path, cfg: dict, yes: bool) -> dict:
return cfg
# #498: UD-Q4_K_XL's RAM budget has no layer split; without it the RAM must hold the GGUFs and 24 GB more
budget = "--resident-budget-gib" in cfg.get("args", [])
- if budget and ram_gb() < unsloth_split_need_gb():
+ if budget and ram_gb() < unsloth_split_need_gb(budget_model(cfg)):
return cfg
pair = can[:2]
cfg["gpus_asked"] = True
@@ -660,13 +907,14 @@ def offer_together(cfg_path: Path, cfg: dict, yes: bool) -> dict:
say(" use). On both, the experts the GPUs do not hold are read through the OS file cache instead: faster in")
say(" two reports (#364, #384), but RAM can fill up to 0 free during long prompts.")
if budget:
- say(" This model (UD-Q4_K_XL) runs on one GPU with a RAM budget of its experts (recommended: the tested")
+ say(f" This model ({budget_model(cfg)}) runs on one GPU with a RAM budget of its experts (recommended: the tested")
say(" setup). On both it has no budget: all its experts are loaded into RAM at start, which this PC's RAM")
say(" holds - about twice as fast in #498 (2x RTX 3090: 31 -> 64-78 tokens/s).")
short = split_short(pair) # #448: one card recommended (asked "n" by default), as for --resident
for g in short:
say(f" {split_short_note(g)}.")
- missing = [g for g in pair if not engine_runs_on(g)]
+ tk = config_toolkit(cfg)
+ missing = [g for g in pair if not (engine_runs_on(g) if tk == 13 else engine_runs_on(g, tk))]
if missing:
say(" The installed engine has no code for " + ", ".join(g["name"] for g in missing) + ": to use them "
"together, run START-HERE.bat --setup --gpus " + ",".join(str(g["index"]) for g in pair))
@@ -676,6 +924,7 @@ def offer_together(cfg_path: Path, cfg: dict, yes: bool) -> dict:
cfg["layer_split"] = cfg.get("layer_split") or "auto"
split_mmap(cfg)
split_budget(cfg)
+ recommend_remote_expert_opt(cfg)
ok("from now on this model runs on " + " + ".join(gpu_name(g) for g in pair))
else:
ok("staying on one GPU (START-HERE.bat --gpus " + ",".join(str(g["index"]) for g in pair) + " switches)")
@@ -700,10 +949,25 @@ def gpu_info(pick=None):
def find_nvcc(below=None):
- """The newest CUDA toolkit's nvcc and its (major, minor); with `below`, the newest older than that version."""
+ """The newest CUDA toolkit's nvcc and its (major, minor); with `below`, the newest older than that version.
+ #601: STRATA_NVCC= is the only one considered (a newer toolkit beside it that cannot build on this
+ PC - CUDA 12.9 with glibc 2.43 - is not taken instead)."""
+ pick = os.environ.get("STRATA_NVCC")
+ if pick:
+ if not Path(pick).exists():
+ warn(f"STRATA_NVCC={pick}: no such file; looking for a CUDA toolkit as usual")
+ else:
+ v = re.search(r"release (\d+)\.(\d+)", out([pick, "--version"]))
+ ver = (int(v.group(1)), int(v.group(2))) if v else None
+ if ver and below is not None and ver >= below:
+ warn(f"STRATA_NVCC={pick} is CUDA {ver[0]}.{ver[1]}; this build needs one older than "
+ f"{below[0]}.{below[1]}")
+ return (None, None)
+ return (pick, ver) if ver else (None, None)
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")))
+ for var in ("CUDA_PATH", "CUDA_HOME"): # CUDA_HOME: Linux's usual name (#601)
+ if os.environ.get(var):
+ cands.append(str(Path(os.environ[var]) / "bin" / ("nvcc.exe" if WIN else "nvcc")))
if WIN:
base = Path(r"C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA")
if base.exists():
@@ -854,7 +1118,7 @@ def gguf_dir_shards(folder: Path, fam: dict, model: str) -> list[Path]:
engine and tools/iq_pack.py do. The published name first; else the one first shard in the folder whose name has
the size in it (an upload split or named differently: -00001-of-00003, Unsloth's ...-00001-of-00004.gguf). A
missing shard is check_shards' error later, as before."""
- first = folder / fam["file"].format(q=model, i=1)
+ first = folder / model_file(fam, model, 1)
if not first.exists():
found = sorted(p for p in folder.glob("*-00001-of-*.gguf") if SHARD_NAME.search(p.name))
mine = [p for p in found if model.lower() in p.name.lower()]
@@ -873,7 +1137,8 @@ def gguf_dir_shards(folder: Path, fam: dict, model: str) -> list[Path]:
GGUF_QUANT = re.compile(r"(? tuple | None:
the original's sizes: the size tells them apart)."""
for f, d in FAMILIES.items():
for m in MODELS:
- if f in MODELS[m].get("families", ("qwen", "swift")) and name == d["file"].format(q=m, i=1):
+ if f in MODELS[m].get("families", ("qwen", "swift")) and name == model_file(d, m, 1):
return f, m
return None
@@ -1061,38 +1326,46 @@ def pip_install(packages, what):
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*"
+def cuda_lib_dirs(toolkit=13):
+ """Where pip put NVIDIA's CUDA libraries (nvidia/cu13/bin/x86_64 on Windows, nvidia/cu13/lib on Linux).
+ toolkit 12: the CUDA 12 wheels' cuBLAS and runtime, in two folders (nvidia/cublas/bin, nvidia/cuda_runtime/bin)."""
+ if int(toolkit) == 12:
+ patterns = ("cublas64_12.dll", "cudart64_12.dll") if WIN else ("libcublas.so.12*", "libcudart.so.12*")
+ else:
+ patterns = ("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)
+ for pattern in patterns:
+ 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]
# ------------------------------------------------------------------------------------------------ AMD
# The RX 7900 XT / XTX (gfx1100) and the RX 9070 series / Radeon AI PRO R9700 (gfx1201) on Linux, through the HIP
# backend (docs/AMD_HIP.md); the RX 7800 XT / 7700 XT (gfx1101, #254) and the RX 9060 XT (gfx1200, #256) were run by
-# their owners; the RX 6800 / 6900 series (gfx1030, #311) runs but is unvalidated. There is no ready-made AMD engine: ROCm comes from AMD's TheRock Python wheels into .venv (no sudo;
+# their owners; the RX 6800 / 6900 series (gfx1030, #311) and the RX 6700 XT (gfx1031, #524) run but are unvalidated. There is no ready-made AMD engine: ROCm comes from AMD's TheRock Python wheels into .venv (no sudo;
# a system ROCm 7 in /opt/rocm is used when it has hipcc and hipBLAS) and the engine is compiled here for the cards.
# No images yet.
ROCM_INDEXES = {"gfx1100": "https://rocm.nightlies.amd.com/v2/gfx110X-dgpu/", # TheRock's wheels per GPU family
"gfx1101": "https://rocm.nightlies.amd.com/v2/gfx110X-dgpu/",
"gfx1200": "https://rocm.nightlies.amd.com/v2/gfx120X-all/",
"gfx1201": "https://rocm.nightlies.amd.com/v2/gfx120X-all/",
- "gfx1030": "https://rocm.nightlies.amd.com/v2/gfx103X-all/"}
+ "gfx1030": "https://rocm.nightlies.amd.com/v2/gfx103X-all/",
+ "gfx1031": "https://rocm.nightlies.amd.com/v2/gfx103X-all/"}
ROCM_VERSION = os.environ.get("STRATA_ROCM_VERSION", "7.10.0a20251120") # what Strata's HIP build was tested with
ROCM_SYSTEM_MIN = (7, 0) # an older system ROCm is passed over for the wheels (gfx1201 needs ROCm 6.4 or newer)
-AMD_ARCHS = ("gfx1100", "gfx1101", "gfx1200", "gfx1201", "gfx1030")
+AMD_ARCHS = ("gfx1100", "gfx1101", "gfx1200", "gfx1201", "gfx1030", "gfx1031")
AMD_NAMES = {"gfx1100": "AMD Radeon RX 7900 series (gfx1100)", # when sysfs has no product name
"gfx1101": "AMD Radeon RX 7800 XT / 7700 XT (gfx1101)",
"gfx1200": "AMD Radeon RX 9060 series (gfx1200)",
"gfx1201": "AMD Radeon RX 9070 series / AI PRO R9700 (gfx1201)",
- "gfx1030": "AMD Radeon RX 6800 / 6900 series (gfx1030)"}
+ "gfx1030": "AMD Radeon RX 6800 / 6900 series (gfx1030)",
+ "gfx1031": "AMD Radeon RX 6700 XT series (gfx1031)"}
AMD_CARDS = ("the RX 7900 XT / XTX (gfx1100), RX 7800 XT / 7700 XT (gfx1101), RX 9060 XT (gfx1200) and "
- "RX 9070 / 9070 XT / Radeon AI PRO R9700 (gfx1201), and the RX 6800 / 6900 series (gfx1030, unvalidated)")
+ "RX 9070 / 9070 XT / Radeon AI PRO R9700 (gfx1201), and the RX 6800 / 6900 series (gfx1030) and RX 6700 XT "
+ "(gfx1031, #524), both unvalidated")
def rocm_index(arch):
@@ -1587,7 +1860,9 @@ def build_engine_hip(gpu, llama, vision="none") -> Path:
src, vsrc = source_hash(ENGINE_SOURCES), source_hash(VISION_SOURCES)
archs = sorted(set(gpu.get("archs") or [gpu["arch"]]))
has_archs = set(archs) <= set(meta.get("archs", []))
- engine_ok = meta.get("backend") == "hip" and (eng / EXE).exists() and meta.get("src") == src and has_archs
+ floor = cpu_floor(cpu_info()[1]) # "" on an AVX2 CPU: the normal engine
+ engine_ok = meta.get("backend") == "hip" and (eng / EXE).exists() and meta.get("src") == src and has_archs and \
+ (meta.get("isa_floor") or "") == floor
vision_ok = vision == "none" or ((eng / VEXE).exists() and meta.get("vision_src") == vsrc)
if engine_ok and vision_ok:
ok("engine already built for this PC")
@@ -1615,10 +1890,10 @@ def build_engine_hip(gpu, llama, vision="none") -> Path:
f"-DCMAKE_HIP_COMPILER={root / 'llvm' / 'bin' / 'clang++'}", f"-DCMAKE_HIP_COMPILER_ROCM_ROOT={root}",
"-DCMAKE_PREFIX_PATH=" + ";".join([str(root), *libs]),
f"-DCMAKE_HIP_FLAGS=--rocm-path={root} --rocm-device-lib-path={bitcode}",
- f"-DSTRATA_GGML_DIR={llama}"], None, "")
+ f"-DSTRATA_GGML_DIR={llama}", *isa_floor_defs(floor, ROOT / "build-hip", meta)], None, "")
shutil.copy2(ROOT / "build-hip" / EXE, eng / EXE)
meta = {"source": "local-hip", "backend": "hip", "version": source_version(), "archs": archs, "vision": "none",
- "lib_dirs": dirs, "src": src}
+ "lib_dirs": dirs, "src": src, **({"isa_floor": floor} if floor else {})}
if vision != "none":
return build_vision_cpu(eng, stamp, meta, llama, vsrc)
stamp.write_text(json.dumps(meta, indent=1))
@@ -1668,10 +1943,12 @@ def prebuilt_bases(url_base) -> list[str]:
return [PREBUILT_TAG_URL.format(version=source_version()), base]
-def get_prebuilt(url_base, gpu, vision, updating=False) -> Path | None:
+def get_prebuilt(url_base, gpu, vision, updating=False, toolkit=13) -> Path | None:
"""The ready-made engine in engine/ (kept between runs), or None when there is none for this PC.
- updating: called to replace an installed engine, which starts instead when this fails (no compile)."""
- eng = ROOT / "engine"
+ updating: called to replace an installed engine, which starts instead when this fails (no compile).
+ toolkit 12: the experimental CUDA 12 engine (CUDA12_ASSET) in engine-cuda12/."""
+ eng = engine_dir(toolkit)
+ asset = CUDA12_ASSET if int(toolkit) == 12 else PREBUILT_ASSET
info = eng / "BUILD.json"
if info.exists() and (eng / EXE).exists() and json.loads(info.read_text()).get("backend") != "hip":
meta = json.loads(info.read_text())
@@ -1692,13 +1969,14 @@ def get_prebuilt(url_base, gpu, vision, updating=False) -> Path | None:
info.unlink()
if not url_base:
return None
- z = ROOT / "engine" / PREBUILT_ASSET
+ eng.mkdir(exist_ok=True)
+ z = eng / asset
bases = prebuilt_bases(url_base)
for i, base in enumerate(bases):
if not base.startswith(("http://", "https://")):
break
try: # not published (yet), or no internet: compile instead
- req = urllib.request.Request(base + PREBUILT_ASSET, method="HEAD", headers={"User-Agent": "strata-setup"})
+ req = urllib.request.Request(base + asset, method="HEAD", headers={"User-Agent": "strata-setup"})
urllib.request.urlopen(req, timeout=60).close()
break
except OSError as e:
@@ -1707,9 +1985,9 @@ def get_prebuilt(url_base, gpu, vision, updating=False) -> Path | None:
continue
warn(f"no ready-made engine at {base} ({e})" + ("" if updating else ": compiling instead"))
return None
- say(" Downloading the ready-made Strata engine ...")
- download(base + PREBUILT_ASSET, z, "Strata engine")
- tmp = ROOT / "engine" / "_unpack"
+ say(" Downloading the ready-made Strata engine" + (" (CUDA 12, experimental)" if int(toolkit) == 12 else "") + " ...")
+ download(base + asset, z, "Strata engine")
+ tmp = eng / "_unpack"
shutil.rmtree(tmp, ignore_errors=True)
with zipfile.ZipFile(z) as f:
f.extractall(tmp)
@@ -1752,11 +2030,17 @@ def get_prebuilt(url_base, gpu, vision, updating=False) -> Path | None:
return eng
-def update_installed_engine(url_base) -> None:
+def update_installed_engine(url_base, toolkit=None) -> None:
"""An installed ready-made engine older than MIN_ENGINE is replaced before the model starts, so a plain
START-HERE.bat on an existing install picks up a new release. If that cannot happen (no internet, the model
- still running, no ready-made engine for this GPU) the installed engine is kept and starts as before."""
- eng = ROOT / "engine"
+ still running, no ready-made engine for this GPU) the installed engine is kept and starts as before.
+ toolkit None: engine/, then the experimental CUDA 12 engine in engine-cuda12/ when one is installed."""
+ if toolkit is None:
+ update_installed_engine(url_base, 13)
+ if (engine_dir(12) / "BUILD.json").exists():
+ update_installed_engine(url_base, 12)
+ return
+ eng = engine_dir(toolkit)
info = eng / "BUILD.json"
if not info.exists() or not (eng / EXE).exists():
return
@@ -1814,14 +2098,19 @@ def update_installed_engine(url_base) -> None:
if gpu is None:
raise RuntimeError("no NVIDIA GPU found")
gpu = {**gpu, "archs": sorted({int(gpu["arch"]), *(int(x) for x in meta.get("archs", []))})}
- build_engine(gpu, vision, False, get_llama_cpp())
+ if int(toolkit) == 12: # the cards it was compiled for (the main GPU may be newer)
+ gpu["archs"] = sorted({int(x) for x in meta.get("archs", [])}) or gpu["archs"]
+ build_engine(gpu, vision, False, get_llama_cpp(), toolkit=toolkit)
except (Exception, SystemExit) as e:
warn(f"could not compile the updated engine{'' if isinstance(e, SystemExit) else f' ({e})'}: starting the installed one")
return
new = None
if gpu is not None:
try:
- new = get_prebuilt(url_base, gpu, "gpu", updating=True)
+ if int(toolkit) == 12: # the cards the CUDA 12 engine serves, not the newest one
+ gpu = {**gpu, "archs": [x for x in (int(a) for a in meta.get("archs", [])) if x < CUDA13_MIN_ARCH]
+ or [int(gpu["arch"])]}
+ new = get_prebuilt(url_base, gpu, "gpu", updating=True, toolkit=toolkit)
except Exception as e: # a failed download must not stop the model from starting
warn(f"updating the engine failed ({e})")
if new is None:
@@ -1829,25 +2118,36 @@ def update_installed_engine(url_base) -> None:
info.write_text(meta_text) # get_prebuilt drops it before downloading: put it back
warn(f"could not update the engine: starting the installed {meta.get('version')}")
return
- pip_install(CUDA_WHEELS, "NVIDIA CUDA libraries (cuBLAS, CUDA runtime; ~0.4 GB)")
+ pip_cuda_libs(toolkit)
+
+
+def pip_cuda_libs(toolkit=13) -> None:
+ """NVIDIA's cuBLAS and CUDA runtime for a ready-made engine, from pip: CUDA 13's, or the CUDA 12 engine's."""
+ if int(toolkit) == 12:
+ pip_install(CUDA12_WHEELS, "NVIDIA CUDA 12 libraries for the experimental engine (cuBLAS, CUDA runtime; ~0.7 GB)")
+ else:
+ pip_install(CUDA_WHEELS, "NVIDIA CUDA libraries (cuBLAS, CUDA runtime; ~0.4 GB)")
def install_build_tools(gpu, yes):
"""The compiler and the CUDA toolkit, installed for the user (asks once). Returns (nvcc, vcvars)."""
archs = [int(x) for x in gpu.get("archs", [gpu["arch"]])]
- # #295: Pascal / Volta (STRATA_EXPERIMENTAL_SM60=1) need a CUDA 12.x toolkit - CUDA 13 cannot build sm_60/sm_70
- old = min(archs) < 75
+ # #295: Pascal / Volta need a CUDA 12.x toolkit - CUDA 13 cannot build sm_60/sm_70; gpu["toolkit"] = 12: the
+ # experimental CUDA 12 engine for any cards (--cuda 12, docs/OLDER_GPUS.md)
+ old = int(gpu.get("toolkit") or (12 if min(archs) < CUDA13_MIN_ARCH else 13)) == 12
+ need12 = (12, 8) if max(archs) >= 120 else (12, 0) # sm_120 needs CUDA 12.8 or newer
if old and max(archs) >= 120:
- fail("one engine cannot be built for both an RTX 50 card (CUDA 13) and a Pascal/Volta card (CUDA 12.x)",
- "choose the cards of one kind with --gpu / --gpus")
+ warn("an RTX 50 card (sm_120) in a CUDA 12 engine: engines built with CUDA 12.8 crashed on long prompts there "
+ "(#220, #224); the RTX 50 card alone (--gpu N) runs the ready-made CUDA 13 engine")
nvcc, cuda_v = find_nvcc(below=(13, 0)) if old else find_nvcc()
- if old and (nvcc is None or cuda_v < (12, 0)):
- fail("the experimental Pascal/Volta build (STRATA_EXPERIMENTAL_SM60=1) needs the NVIDIA CUDA Toolkit 12.x "
- "(CUDA 13 cannot compile for these cards)",
- "install CUDA 12.9 (or another 12.x; it can sit next to a newer one) from "
- "https://developer.nvidia.com/cuda-toolkit-archive and run it again")
+ if old and (nvcc is None or cuda_v < need12):
+ fail("the experimental CUDA 12 engine (Pascal / Volta, or --cuda 12) is compiled here with the NVIDIA CUDA "
+ f"Toolkit {need12[0]}.{need12[1]} or a newer 12.x (CUDA 13 cannot compile for these cards)" +
+ (f"; found CUDA {cuda_v[0]}.{cuda_v[1]}" if nvcc else ""),
+ "install CUDA 12.9 (it can sit next to a newer one) from https://developer.nvidia.com/cuda-toolkit-archive "
+ "and run it again (STRATA_NVCC= picks one toolkit)")
# RTX 50 (sm_120): CUDA 13.0 - an engine built with 12.8 crashed in the prompt path on Linux (#220)
- need_cuda = (13, 0) if max(archs) >= 120 else (12, 0)
+ need_cuda = need12 if old else (13, 0) if max(archs) >= 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 = []
@@ -1943,9 +2243,18 @@ def source_hash(parts) -> str:
return h.hexdigest()[:16]
-def engine_defs(archs) -> list:
- """Extra CMake definitions for the engine: the experimental Pascal/Volta build (#295) for cards below sm_75."""
- return ["-DSTRATA_EXPERIMENTAL_SM60=ON"] if min(int(x) for x in archs) < 75 else []
+def isa_floor_defs(floor: str, bdir: Path, meta: dict) -> list:
+ """The experimental older-CPU build's CMake definition (STRATA_ISA_FLOOR, CMakeLists.txt), none for the normal
+ build. A build folder configured for another floor is configured afresh: ggml's CPU options are cached there."""
+ if (meta.get("isa_floor") or "") != floor and (bdir / "CMakeCache.txt").exists():
+ (bdir / "CMakeCache.txt").unlink()
+ return [f"-DSTRATA_ISA_FLOOR={floor}"] if floor else []
+
+
+def engine_defs(archs, toolkit=13) -> list:
+ """Extra CMake definitions for the engine: the experimental Pascal/Volta build (#295) for cards below sm_75, and
+ for every CUDA 12 engine (the same build as the ready-made CUDA 12 one: it admits the older cards)."""
+ return ["-DSTRATA_EXPERIMENTAL_SM60=ON"] if min(int(x) for x in archs) < 75 or int(toolkit) == 12 else []
def prebuilt_vision(meta: dict, gpu: dict, vision: str) -> str:
@@ -1962,10 +2271,15 @@ def prebuilt_vision(meta: dict, gpu: dict, vision: str) -> str:
return vision
-def build_engine(gpu, vision, yes, llama) -> Path:
+def build_engine(gpu, vision, yes, llama, toolkit=None) -> Path:
"""Compile the engine (and, for images, the encoder) for this GPU; the results go to engine/. A compiled
- engine whose source files changed since (a `git pull`) is compiled again: only the changed files, a few minutes."""
- eng = ROOT / "engine"
+ engine whose source files changed since (a `git pull`) is compiled again: only the changed files, a few minutes.
+ toolkit 12 (default: 12 for a card older than CUDA 13 supports): the experimental CUDA 12 engine, in
+ engine-cuda12/ with its own build folders."""
+ if toolkit is None:
+ toolkit = 12 if min(int(x) for x in gpu.get("archs", [gpu["arch"]])) < CUDA13_MIN_ARCH else 13
+ t12 = int(toolkit) == 12
+ eng = engine_dir(toolkit)
eng.mkdir(exist_ok=True)
stamp = eng / "BUILD.json"
meta = json.loads(stamp.read_text()) if stamp.exists() else {}
@@ -1977,37 +2291,43 @@ def build_engine(gpu, vision, yes, llama) -> Path:
# a card the engine has no code for (a GPU added with --gpus, #128) needs a compile even when the source is the
# same; the compile keeps the generations it was built for
new_arch = local and not set(archs) <= built
- engine_ok = local and (eng / EXE).exists() and meta.get("src") == src and not new_arch
+ floor = cpu_floor(cpu_info()[1]) # "" on an AVX2 CPU: the normal engine
+ engine_ok = local and (eng / EXE).exists() and meta.get("src") == src and not new_arch and \
+ (meta.get("isa_floor") or "") == floor
vision_ok = not want_vision or ((eng / VEXE).exists() and (not local or meta.get("vision_src") == vsrc))
if engine_ok and vision_ok:
ok("engine already built for this PC")
return eng
if local:
archs = sorted(built | set(archs))
- nvcc, vcvars = install_build_tools({**gpu, "archs": archs}, yes)
+ nvcc, vcvars = install_build_tools({**gpu, "archs": archs, "toolkit": toolkit}, yes)
cuda_archs = ";".join(str(x) for x in archs)
+ bdir, vdir = (ROOT / "build-cuda12", ROOT / "build-vision-cuda12") if t12 else (ROOT / "build", ROOT / "build-vision")
if not engine_ok:
say(" Compiling the engine for " + ", ".join(f"sm_{x}" for x in archs) + " (a card it had no code for; "
"10-20 minutes, once) ..." if new_arch else
" The engine's source changed: compiling it again (only what changed, a few minutes) ..."
if local and (eng / EXE).exists() else " Compiling the Strata engine for your GPU (10-20 minutes, once) ...")
- cmake_build(ROOT, ROOT / "build", "strata",
+ cmake_build(ROOT, bdir, "strata",
["-DSTRATA_ENABLE_CUDA=ON", "-DSTRATA_BUILD_TESTS=OFF", f"-DCMAKE_CUDA_ARCHITECTURES={cuda_archs}",
- f"-DCMAKE_CUDA_COMPILER={nvcc}", f"-DSTRATA_GGML_DIR={llama}", *engine_defs(archs)],
- vcvars, "build-strata.bat")
- shutil.copy2(ROOT / "build" / EXE, eng / EXE)
+ f"-DCMAKE_CUDA_COMPILER={nvcc}", f"-DSTRATA_GGML_DIR={llama}", *engine_defs(archs, toolkit),
+ *isa_floor_defs(floor, bdir, meta)],
+ vcvars, "build-strata-cuda12.bat" if t12 else "build-strata.bat")
+ shutil.copy2(bdir / EXE, eng / EXE)
if not vision_ok:
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={cuda_archs}", 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)
+ cmake_build(ROOT / "tools" / "vision", vdir, "strata-vision", defs, vcvars,
+ "build-vision-cuda12.bat" if t12 else "build-vision.bat")
+ shutil.copy2(vdir / "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", "version": source_version(), "archs": archs,
- "vision": vision,
- "cuda_dirs": dirs, "src": src, "vision_src": vsrc if want_vision else None}, indent=1))
+ "vision": vision, **({"toolkit": 12} if t12 else {}),
+ "cuda_dirs": dirs, "src": src, "vision_src": vsrc if want_vision else None,
+ **({"isa_floor": floor} if floor else {})}, indent=1))
ok(f"engine compiled: {eng / EXE}")
return eng
@@ -2369,6 +2689,86 @@ def write_config(path: Path, cfg: dict):
os.replace(tmp, path)
+# #629: the run config's keys setup writes itself (and rewrites on every setup run); any other key is the user's - a
+# "sampling" or "mcp_servers" block, "allowed_hosts", "cors_origins", "open_browser" - and is kept when setup runs again
+SETUP_KEYS = frozenset({"exe", "args", "cwd", "tokenizer", "model_name", "log", "lib_dirs", "port", "backend", "env",
+ "gpu", "gpus_asked", "layer_split", "host", "api_key", "draft_vocab", "vision"})
+SETUP_ENV = frozenset({"STRATA_HIPBLASLT_TUNING", "STRATA_RESIDENT_PIN"}) # the "env" entries setup writes
+SETUP_VISION = frozenset({"exe", "mmproj", "model", "gpu", "max_tokens", "threads"})
+
+
+def carry_over(old: dict, cfg: dict) -> list[str]:
+ """#629: setup run again for an installed model keeps what the user added to its run config: every key setup does
+ not write (`SETUP_KEYS`), the "env" entries setup does not write, and in "vision" the keys setup does not write
+ plus an mmproj of their own (a Q8_0 one, #625) that still exists. `cfg` (the new config) is updated in place;
+ the names of what was kept are returned. Engine options added by hand to "args" are not merged (setup chooses
+ those): `args_dropped` names them."""
+ kept = []
+ for k, v in old.items():
+ if k not in SETUP_KEYS and k not in cfg:
+ cfg[k] = v
+ kept.append(k)
+ env = {k: v for k, v in (old.get("env") or {}).items() if k not in SETUP_ENV and k not in (cfg.get("env") or {})} \
+ if isinstance(old.get("env"), dict) else {}
+ if env:
+ cfg["env"] = {**(cfg.get("env") or {}), **env}
+ kept += [f"env {k}" for k in env]
+ ov, nv = old.get("vision"), cfg.get("vision")
+ if isinstance(ov, dict) and isinstance(nv, dict):
+ for k, v in ov.items():
+ if k not in SETUP_VISION and k not in nv:
+ nv[k] = v
+ kept.append(f"vision {k}")
+ mm = ov.get("mmproj") # a file of the user's own: not the one setup downloads
+ if isinstance(mm, str) and Path(mm).name != Path(str(nv.get("mmproj"))).name and Path(mm).is_file():
+ nv["mmproj"] = mm
+ kept.append("vision mmproj")
+ return kept
+
+
+def args_dropped(old: dict, cfg: dict) -> list[str]:
+ """#629: the engine options of the earlier run config that the new one has no more (by flag name): options added
+ by hand, which a setup run does not carry over - the start of the line that names them."""
+ def flags(c):
+ a = c.get("args") if isinstance(c.get("args"), list) else []
+ return [str(x) for x in a if str(x).startswith("--")]
+ new = set(flags(cfg))
+ return list(dict.fromkeys(f for f in flags(old) if f not in new))
+
+
+def write_setup_config(cfg_path: Path, cfg: dict, source: Path | None = None) -> None:
+ """#629: setup's run config, written over an earlier one for the same model without losing what the user added
+ to it: the keys setup does not write are carried over (carry_over), and the earlier file is kept as
+ strata-.json.bak when it changes. `source`: an earlier install's config to carry the keys over from when
+ this folder has none yet (a copy set up like the last one). A line says what was kept, one what was not."""
+ old_path = cfg_path if cfg_path.is_file() else source
+ old = None
+ if old_path is not None and old_path.is_file():
+ try:
+ old = json.loads(old_path.read_text(encoding="utf-8-sig"))
+ except (OSError, ValueError):
+ pass
+ if not isinstance(old, dict):
+ old = None
+ kept = carry_over(old, cfg) if old is not None else []
+ bak = None
+ if cfg_path.is_file() and old != cfg:
+ bak = cfg_path.with_name(cfg_path.name + ".bak")
+ try:
+ shutil.copyfile(cfg_path, bak)
+ except OSError as e:
+ warn(f"could not keep a copy of the earlier {cfg_path.name} ({e.strerror or e})")
+ bak = None
+ write_config(cfg_path, cfg)
+ if kept:
+ ok(f"kept from your earlier {old_path.name}: " + ", ".join(kept))
+ if bak is not None:
+ dropped = args_dropped(old, cfg) if old is not None else []
+ say(f" the earlier run config is kept as {bak.name}" + (
+ f"; engine options it had that this one has not (setup chooses those): {' '.join(dropped)}"
+ if dropped else ""))
+
+
def readable_config(path: Path) -> bool:
"""#459: a config that parses as a JSON object; any other gets a one-line warning naming it."""
text = None
@@ -2415,7 +2815,7 @@ def choices_from_config(cfg_path: Path) -> dict:
"vision": ("gpu" if vis.get("gpu") else "cpu") if isinstance(vis, dict) else "none",
"esp": ("on" if Path(esp_path).name == ESP_VECTOR.name else esp_path) if esp_path else "off",
"host": cfg.get("host"), "api_key": cfg.get("api_key"), "port": cfg.get("port"), "gpu": cfg.get("gpu"),
- "layer_split": cfg.get("layer_split"),
+ "layer_split": cfg.get("layer_split"), "cuda": 12 if config_toolkit(cfg) == 12 else None,
# #493: --vram-reserve-mib given at setup (images write the default 700 themselves)
"vram_reserve_mib": int(val("--vram-reserve-mib")) if (val("--vram-reserve-mib") or "").isdigit() and (
vis is None or int(val("--vram-reserve-mib")) != VISION["gpu"]["reserve_mib"]) else None}
@@ -2479,11 +2879,53 @@ def is_wsl() -> bool:
return sys.platform.startswith("linux") and "microsoft" in platform.uname().release.lower()
+def rotational_disk(path) -> str | None:
+ """#605 (Linux): the disk's name when `path` is on a rotational disk (sysfs queue/rotational), else None."""
+ if WIN:
+ return None
+ try:
+ st = os.stat(path)
+ p = Path(f"/sys/dev/block/{os.major(st.st_dev)}:{os.minor(st.st_dev)}").resolve()
+ for q in (p, p.parent): # a partition has its disk's queue
+ f = q / "queue" / "rotational"
+ if f.exists():
+ return q.name if f.read_text().strip() == "1" else None
+ except (OSError, ValueError, AttributeError):
+ pass
+ return None
+
+
+def hip_config_cards(sel) -> list[dict]:
+ """#566: the AMD cards a HIP config runs on - its "gpu" (one index or a list) in HIP's numbering, as amd_gpus
+ lists them; no "gpu": the supported card with the most VRAM, as setup picks it. Each card's name carries its
+ architecture (an RX 7900 XTX and an RX 7900 XT are both gfx1100; a card without a product name in sysfs is known
+ by its arch only). A card that is not found is {} (the key then says "?")."""
+ amd = amd_gpus()
+ if sel is None:
+ usable = [g for g in amd if amd_problem(g) is None]
+ sel = max(usable, key=lambda x: (round(x["vram_gb"]), -x["index"]))["index"] if usable else None
+ byid = {g["index"]: g for g in amd}
+ cards = []
+ for i in (sel if isinstance(sel, list) else [sel]):
+ g = byid.get(i)
+ if g is None:
+ cards.append({})
+ continue
+ arch = g.get("arch") or ""
+ name = g.get("name") or "?"
+ cards.append({**g, "name": name if not arch or arch in name else f"{name} ({arch})"})
+ return cards
+
+
def hardware_key(cfg: dict) -> str:
"""What a calibration is valid for: this GPU, CPU and RAM, and the model with its context and images setting
- (the context's KV cache and the image encoder take VRAM from the expert cache)."""
+ (the context's KV cache and the image encoder take VRAM from the expert cache). #566: a HIP config's cards are
+ AMD's (hip_config_cards) - nvidia-smi's list named them "?" (or another card with that number) before."""
sel = cfg.get("gpu")
- gl = [gpu_info(i) or {} for i in sel] if isinstance(sel, list) else [gpu_info(sel) or {}]
+ if cfg.get("backend") == "hip":
+ gl = hip_config_cards(sel)
+ else:
+ gl = [gpu_info(i) or {} for i in sel] if isinstance(sel, list) else [gpu_info(sel) or {}]
g = {"name": " + ".join(x.get("name", "?") for x in gl), "vram_gb": sum(x.get("vram_gb", 0) for x in gl)}
a = cfg.get("args", [])
ctx = a[a.index("--max-context") + 1] if "--max-context" in a else "?"
@@ -2532,6 +2974,16 @@ def saved_calibration(cfg: dict) -> dict | None:
return (load_settings().get("calibration") or {}).get(hardware_key(cfg))
+def setup_calibration(cfg: dict, hip: bool) -> dict | None:
+ """The calibration a (re-)install applies to its new config: the one saved for this PC and model. #566: on Linux
+ HIP too - hardware_key now names the AMD cards, so a `./setup.sh --calibrate` run is matched to its card and
+ model. Windows HIP keeps the defaults for now (not tried there). Setup still offers the tuning itself on NVIDIA
+ only: each control is verified on HIP first."""
+ if hip and WIN:
+ return None
+ return saved_calibration(cfg)
+
+
def upgrade_config(cfg_path: Path, cfg: dict) -> dict:
"""Configs written before v0.1.13 read prompts in fixed 2048-token chunks; the engine now picks the chunk
itself (`--prefill auto`: up to 8192, as the free VRAM allows - about 2x faster on long prompts). Under WSL,
@@ -2602,6 +3054,8 @@ def settings_summary(cfg: dict, port=None) -> str:
srv = [f"{cfg.get('host', '127.0.0.1')}:{port or cfg.get('port', 8080)}"]
if cfg.get("api_key"):
srv.append("api key set")
+ if cfg.get("open_browser") is False: # #609
+ srv.append("no browser")
for k in ("gpu", "layer_split", "draft_vocab", "fit_max_tokens", "reasoning_budget_tokens", "anthropic_thinking"):
if cfg.get(k) is not None:
v = cfg[k]
@@ -2630,8 +3084,9 @@ def start(cfg_path: Path, port: int | None, gpu: int | list | None = None, open_
if keep and any(cfg.get(k) != v for k, v in keep.items()): # #179: a --host/--api-key on a start was ignored
cfg.update(keep)
write_config(cfg_path, cfg)
- ok("saved for this model: " + ", ".join("api key" if k == "api_key" else f"{k.replace('_', ' ')} {v}"
- for k, v in keep.items()))
+ ok("saved for this model: " + ", ".join(
+ "api key" if k == "api_key" else ("the browser opens" if v else "no browser") if k == "open_browser"
+ else f"{k.replace('_', ' ')} {v}" for k, v in keep.items()))
cfg_path.touch() # the most recently used model
if "--mtp" in cfg["args"][:-1]:
refresh_draft_vocab(Path(cfg["args"][cfg["args"].index("--mtp") + 1]), cfg.get("draft_vocab", "cjk"))
@@ -2668,6 +3123,10 @@ def start(cfg_path: Path, port: int | None, gpu: int | list | None = None, open_
ok(f"GPU: {g['name']} ({g['vram_gb']:.0f} GB, AMD)")
else:
found = gpus()
+ global OLD_GPUS # Pascal / Volta: named on this start, or this model's CUDA 12 engine
+ OLD_GPUS = OLD_GPUS or old_gpus_opt_in(found, gpu if isinstance(gpu, list) else [gpu] if gpu is not None else
+ cfg.get("gpu") if isinstance(cfg.get("gpu"), list) else [cfg.get("gpu")],
+ 12 if config_toolkit(cfg) == 12 else None)
if cfg.get("backend") == "hip":
pass
elif isinstance(gpu, list): # --gpus: saved, this model runs on these cards from now on
@@ -2675,6 +3134,7 @@ def start(cfg_path: Path, port: int | None, gpu: int | list | None = None, open_
cfg["gpu"], cfg["gpus_asked"] = gpu, True
cfg["layer_split"] = layer_split or cfg.get("layer_split") or "auto"
split_budget(cfg) # #498: before it is saved (it stops when the RAM is short)
+ recommend_remote_expert_opt(cfg)
write_config(cfg_path, cfg)
gpu = None
elif gpu is not None: # --gpu N: this start only, on that card
@@ -2699,7 +3159,8 @@ def start(cfg_path: Path, port: int | None, gpu: int | list | None = None, open_
if g is not None:
cfg = ensure_engine_for([g], cfg_path, cfg, yes)
ok("GPU: " + gpu_name(g))
- if open_browser:
+ browser = open_browser and cfg.get("open_browser") is not False # #609: "open_browser": false, --no-browser
+ if browser:
cmd.append("--open")
gb = 0.0
if "--native" in cfg["args"]:
@@ -2719,7 +3180,8 @@ def start(cfg_path: Path, port: int | None, gpu: int | list | None = None, open_
say(f" Starting {cfg.get('model_name', 'the model')}: it loads {size} into RAM and locks part of it for the "
"GPU.")
say(" While it does, YOUR PC CAN BE SLOW OR STOP RESPONDING FOR 1-3 MINUTES (longer the first time after a")
- say(" restart). That is normal: please wait and don't close this window - the browser opens when it is ready.")
+ say(" restart). That is normal: please wait and don't close this window - " + (
+ "the browser opens when it is ready." if browser else "the server says when it is ready."))
say(" Later, closing this window stops the model.")
say(" " + "-" * 100)
for n, line in enumerate(textwrap.wrap(f"Settings ({cfg_path.name}): {settings_summary(cfg, port)}", 100,
@@ -2737,7 +3199,8 @@ def start(cfg_path: Path, port: int | None, gpu: int | list | None = None, open_
OLD_DRAFT_VOCABS = {"369151522226a5edaa5f12cfd1e2ae7db8f4fbdbd222f3dcf327dced9597fb25"} # to 0.1.26: 27 Han tokens
-DRAFT_VOCABS = {"cjk": "draft_vocab.bin", "en": "draft_vocab_en.bin", "cyrillic": "draft_vocab_cyrillic.bin"}
+DRAFT_VOCABS = {"cjk": "draft_vocab.bin", "en": "draft_vocab_en.bin", "cyrillic": "draft_vocab_cyrillic.bin",
+ "fr": "draft_vocab_fr.bin"}
def saved_draft_vocab(cfg_path: Path) -> str | None:
@@ -2750,7 +3213,32 @@ def saved_draft_vocab(cfg_path: Path) -> str | None:
return v if v in DRAFT_VOCABS else None
-DRAFT_VOCAB_MIB = {"cjk": 348, "cyrillic": 193, "en": 133} # the draft head's VRAM per subset (IQ3_S: the largest)
+def vision_tokens(asked: int | None, vision: str, earlier: Path | None) -> int:
+ """#625: the most image tokens a picture becomes (the config's vision.max_tokens): --vision-tokens N, else what an
+ earlier config of this model chose for the same encoder device (a setup run again keeps it), else the device's
+ default (VISION). More is allowed with a note on the time it takes: setup recommends, it does not cap."""
+ default = VISION[vision]["max_tokens"]
+ if asked is None and earlier is not None:
+ try:
+ v = json.loads(earlier.read_text(encoding="utf-8-sig")).get("vision")
+ except (OSError, ValueError, AttributeError):
+ v = None
+ mt = v.get("max_tokens") if isinstance(v, dict) and bool(v.get("gpu")) == (vision == "gpu") else None
+ if isinstance(mt, int) and mt > 0 and mt != default:
+ asked = mt
+ if asked is None:
+ return default
+ note = ""
+ if vision == "cpu" and asked > default:
+ note = (" - on the CPU a picture takes longer to encode the more tokens it gets (several seconds more at "
+ "1,024 than at 300)")
+ elif asked > VISION["gpu"]["max_tokens"]:
+ note = " - more than the encoder's default needs more VRAM and context per picture"
+ ok(f"images: up to {asked} image tokens per picture (--vision-tokens; default {default}){note}")
+ return asked
+
+
+DRAFT_VOCAB_MIB = {"cjk": 348, "cyrillic": 193, "fr": 151, "en": 133} # the draft head's VRAM per subset (IQ3_S: the largest)
SMALL_DRAFT_VRAM_GB = 14 # #474: below this the default subset's head can be what does not fit
@@ -2792,6 +3280,60 @@ def small_card_note(ctx: int, draft_vocab: str | None) -> list[str]:
return lines
+PARALLEL_MAX = 8 # #465: the engine's batch window holds at most 8 requests
+PARALLEL_SHARE = 0.2 # #465: the slots' sessions may take this share of the VRAM the expert cache would hold
+PARALLEL_HELD = 0.5 # #465: ... and only where the cache still holds this share of the experts beside them
+PARALLEL_COST_NOTE = ("parallel N reduces waiting for several users but costs about 10-25% speed per request on this "
+ "card")
+
+
+def parallel_slot_gb(ctx: int, kv: str, streaming: bool) -> float:
+ """#465: the VRAM one batch slot's session takes: its KV cache (12 QSA layers; with KV streaming only the 32K
+ positions the attention reads stay in VRAM) and the DeltaNet state (~0.17 GB). Measured: 0.56 GiB at 32K int8."""
+ kv_tok = 12 * (576 if kv == "q4_0" else 1056)
+ return (min(ctx, 32768) if streaming else ctx) * kv_tok / 1e9 + 0.17
+
+
+def parallel_recommend(vram_gbs, arena_gb: float, ctx: int, kv: str, streaming: bool) -> int:
+ """#465: how many requests at once ("parallel") to recommend: 0 = none (one at a time). Only where the experts
+ mostly fit in VRAM - the expert cache (each card's VRAM less ~5 GB, every card of a layer split) still holds
+ PARALLEL_HELD of the model's experts beside the slots' sessions, which take at most PARALLEL_SHARE of it, up to 4.
+ Where the experts mostly run on the CPU a batch reads about as many experts as the requests one by one and every
+ slot's VRAM is expert cache lost: measured on a 12 GB RTX 5070 (Q2_0, 32K), a request alone 11-24% slower with 2-4
+ slots, 4 requests together 63 tok/s against 71 one after the other (docs/BATCHING.md)."""
+ if isinstance(vram_gbs, (int, float)):
+ vram_gbs = [vram_gbs]
+ cache_gb = sum(max(0.0, v - 5) for v in vram_gbs)
+ slot = parallel_slot_gb(ctx, kv, streaming)
+ best = 0
+ for n in (2, 3, 4):
+ if n * slot <= PARALLEL_SHARE * cache_gb and (cache_gb - n * slot) >= PARALLEL_HELD * arena_gb:
+ best = n
+ return best
+
+
+def parallel_note(asked: int | None, vram_gbs, arena_gb: float, ctx: int, kv: str, streaming: bool) -> list[str]:
+ """#465: what setup says about "parallel": the recommendation (or, where it would cost speed, why it is left at
+ one), or how the asked count compares with it (kept as asked: recommend, never force)."""
+ rec = parallel_recommend(vram_gbs, arena_gb, ctx, kv, streaming)
+ slot = parallel_slot_gb(ctx, kv, streaming)
+ if asked is None or asked <= 1:
+ if not rec:
+ return [f"Several requests at once: left at one at a time - {PARALLEL_COST_NOTE} (docs/BATCHING.md)."]
+ return [f"Several requests at once (opt-in): --parallel {rec} decodes up to {rec} together instead of one "
+ f"after the other (each takes ~{slot:.1f} GB of VRAM from the expert cache; docs/BATCHING.md)."]
+ lines = [f"parallel requests: {asked} at once (each takes ~{slot:.1f} GB of VRAM from the expert cache, "
+ f"{asked * slot:.1f} GB in all)"]
+ if asked > PARALLEL_MAX:
+ lines.append(f"the engine runs at most {PARALLEL_MAX} at once; it will use {PARALLEL_MAX}")
+ if not rec:
+ lines.append(f"recommended for this card: one at a time - {PARALLEL_COST_NOTE}; kept as you chose")
+ elif asked > rec:
+ lines.append(f"recommended for this card: {rec} - more slots leave fewer experts in VRAM, which can make every "
+ "request slower; kept as you chose")
+ return lines
+
+
DESKTOP_RESERVE_MIB = 3072 # #560 #516: what kept a KDE/Wayland desktop alive beside a full expert cache
@@ -2825,7 +3367,7 @@ def refresh_draft_vocab(rt: Path, choice: str = "cjk") -> None:
"""The draft layer's token subset in the MTP folder: `cjk` (data/draft_vocab.bin, since 0.1.27, #137), `en`
(data/draft_vocab_en.bin, the English/code subset before it: ~110 MiB less VRAM, English answers 1-2% faster) or
`cyrillic` (data/draft_vocab_cyrillic.bin: English/code and the whole Cyrillic script, for Ukrainian, Russian,
- Bulgarian, Serbian... answers).
+ Bulgarian, Serbian... answers) or `fr` (data/draft_vocab_fr.bin: English/code and the tokens of French text, #597).
Copied when missing or when a shipped subset other than the chosen one is there; a subset made by hand is kept."""
new, dst = ROOT / "data" / DRAFT_VOCABS.get(choice, "draft_vocab.bin"), rt / "draft_vocab.bin"
if not new.exists() or not rt.is_dir():
@@ -2837,7 +3379,8 @@ def refresh_draft_vocab(rt: Path, choice: str = "cjk") -> None:
if old not in shipped or old == hashlib.sha256(new.read_bytes()).hexdigest():
return
ok("draft layer: the token subset " + {"cjk": "with Chinese, Japanese and Korean",
- "cyrillic": "with the Cyrillic script"}.get(choice,
+ "cyrillic": "with the Cyrillic script",
+ "fr": "for French"}.get(choice,
"for English and code (less VRAM)"))
shutil.copyfile(new, dst)
@@ -2845,28 +3388,72 @@ def refresh_draft_vocab(rt: Path, choice: str = "cjk") -> None:
def ensure_engine_for(cards, cfg_path: Path, cfg: dict, yes: bool) -> dict:
"""The installed engine must have code for every card the model starts on: a card added later (--gpus with an
older or newer generation, #128) or a new GPU in the PC otherwise stops the start with 'no kernel image'. Such a
- card gets the engine compiled for all of them, before the start."""
- missing = [g for g in cards if not engine_runs_on(g)]
+ card gets the engine compiled for all of them, before the start. A Pascal / Volta card added to a model on the
+ CUDA 13 engine moves the model to the experimental CUDA 12 engine (CUDA 13 has no code for it)."""
+ tk = config_toolkit(cfg)
+ if tk == 13 and any(int(g["arch"]) < CUDA13_MIN_ARCH for g in cards):
+ return use_cuda12(cards, cfg_path, cfg, yes)
+ missing = [g for g in cards if not (engine_runs_on(g) if tk == 13 else engine_runs_on(g, tk))]
if not missing:
return cfg
- info = ROOT / "engine" / "BUILD.json"
+ eng = engine_dir(tk)
+ info = eng / "BUILD.json"
meta = json.loads(info.read_text())
say()
say(" The installed engine has no code for " + ", ".join(f"{g['name']} (sm_{g['arch']})" for g in missing) +
": it is compiled for " + ("these cards" if len(cards) > 1 else "it") + " now.")
main = gpu_info(cards[0]["index"])
archs = sorted({int(x) for x in meta.get("archs", [])} | {int(g["arch"]) for g in cards})
- vision = meta.get("vision") or ("gpu" if (ROOT / "engine" / VEXE).exists() else "none")
- build_engine({**main, "archs": archs}, vision, yes, get_llama_cpp())
+ vision = meta.get("vision") or ("gpu" if (eng / VEXE).exists() else "none")
+ build_engine({**main, "archs": archs}, vision, yes, get_llama_cpp(), toolkit=tk)
dirs = json.loads(info.read_text()).get("cuda_dirs") or []
cfg["lib_dirs"] = dirs + [d for d in cfg.get("lib_dirs") or [] if d not in dirs]
write_config(cfg_path, cfg)
return cfg
-def write_run_script(model, cfg_path, port):
+def get_cuda12_engine(url_base, gpu, vision, yes, build=False) -> Path:
+ """The experimental CUDA 12 engine for these cards (gpu["archs"]): the ready-made one (Windows) with NVIDIA's
+ CUDA 12 libraries, or compiled here with a CUDA 12.x toolkit (Linux, --build, or no ready-made one)."""
+ eng = None if build else get_prebuilt(url_base, gpu, vision, toolkit=12)
+ if eng is not None and json.loads((eng / "BUILD.json").read_text()).get("source") != "local":
+ pip_cuda_libs(12)
+ if vision != "none" and not (eng / VEXE).exists():
+ eng = None
+ return eng if eng is not None else build_engine(gpu, vision, yes, get_llama_cpp(), toolkit=12)
+
+
+def engine_lib_dirs(eng: Path, toolkit=13) -> list:
+ """The library folders a CUDA engine loads from: its own (a compiled one: the toolkit's), else pip's wheels."""
+ meta = json.loads((eng / "BUILD.json").read_text())
+ return meta.get("lib_dirs") or meta.get("cuda_dirs") or cuda_lib_dirs(toolkit)
+
+
+def use_cuda12(cards, cfg_path: Path, cfg: dict, yes: bool) -> dict:
+ """A model on the CUDA 13 engine now has a Pascal / Volta card (a --gpus at start): the model's config moves to
+ the experimental CUDA 12 engine (one engine per model; its other models keep theirs)."""
+ old = min(int(g["arch"]) for g in cards)
+ say()
+ warn(f"sm_{old} is older than CUDA 13 supports (it dropped Pascal and Volta): this model moves to the experimental "
+ "CUDA 12 engine (docs/OLDER_GPUS.md; START-HERE.bat --setup --cuda 13 and newer cards only moves it back)")
+ main = gpu_info(cards[0]["index"]) or cards[0]
+ vision = "gpu" if cfg.get("vision") else "none"
+ eng = get_cuda12_engine(os.environ.get("STRATA_PREBUILT_URL", PREBUILT_URL),
+ {**main, "archs": sorted({int(g["arch"]) for g in cards})}, vision, yes)
+ cfg["exe"] = str(eng / EXE)
+ cfg["cuda"] = 12
+ cfg["lib_dirs"] = engine_lib_dirs(eng, 12)
+ if cfg.get("vision") and (eng / VEXE).exists():
+ cfg["vision"]["exe"] = str(eng / VEXE)
+ write_config(cfg_path, cfg)
+ ok(f"engine: {eng / EXE} (CUDA 12, experimental)")
+ return cfg
+
+
+def write_run_script(model, cfg_path, port, open_browser=True):
+ """run-.bat / .sh: the server with this config; `open_browser` False (#609: --no-browser) leaves --open out."""
serve = [sys.executable, str(ROOT / "serve" / "server.py"), "--engine", "strata", "--config", str(cfg_path),
- "--port", str(port), "--open"]
+ "--port", str(port)] + (["--open"] if open_browser else [])
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" +
@@ -2917,6 +3504,32 @@ def resolve_rope(ctx: int, scaling, scale, trained: int = 262144):
# ------------------------------------------------------------------------------------------------ main
+def sycl_setup(argv) -> int:
+ """--backend sycl: the Intel Arc engine (the SYCL port in sycl/, PR #423), experimental. There is no ready-made
+ Intel engine: it is compiled from source on the PC (docs/INTEL_ARC.md), then sycl/setup_intel.py runs this setup
+ with the Intel steps swapped in. Nothing of the CUDA / HIP paths is used or changed."""
+ say()
+ warn("Intel Arc (--backend sycl) is EXPERIMENTAL: a community port of the engine, not tested by the Strata "
+ "maintainers (no Intel card here). Expect rough edges; issues with your card and driver versions help.")
+ if WIN:
+ fail("the Intel Arc engine has no Windows setup yet (no ready-made Intel engine either)",
+ "run it on Linux (Ubuntu 24.04 with Intel's GPU driver and oneAPI): docs/INTEL_ARC.md")
+ say(" There is no ready-made Intel engine: it is built from source with Intel oneAPI (icpx + oneMKL),")
+ say(" docs/INTEL_ARC.md. Setup continues with sycl/setup_intel.py.")
+ rest, skip = [], False
+ for x in argv: # setup_intel.py drives this setup through its AMD path
+ if skip:
+ skip = False
+ elif x == "--backend":
+ skip = True
+ elif not x.startswith("--backend="):
+ rest.append(x)
+ script = ROOT / "sycl" / "setup_intel.py"
+ if not script.exists():
+ fail(f"{script} is missing", "use a full Strata checkout (git clone) - docs/INTEL_ARC.md")
+ return subprocess.call([sys.executable, str(script), *rest])
+
+
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--family", choices=list(FAMILIES), help="qwen = Qwen3.8-Flash-Next, swift = Swift 1.5")
@@ -2934,6 +3547,10 @@ def main() -> int:
"precise) or k8v4 (hybrid: INT8 K + 4-bit V, 816 B/cell)")
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("--vision-tokens", type=int, metavar="N",
+ help="the most image tokens a picture becomes (default 1024 with the encoder on the GPU, 300 on "
+ "the CPU): more reads small text and charts better, and takes longer to encode; remembered "
+ "for this model")
ap.add_argument("--experimental-speed-projection", metavar="on|off|GGUF",
help="EXPERIMENTAL, off by default: the control vector in data/experimental-speed-projection "
"(or another GGUF) as a projection on layers 4-44; see docs/DETAILS.md")
@@ -2943,11 +3560,19 @@ def main() -> int:
ap.add_argument("--gpus", help="several GPUs sharing one model, as nvidia-smi numbers them (AMD: as setup lists "
"them): \"0,2\", or \"all\" (every card that can); the first is the main one. "
"Saved, also when starting (see docs/MULTI_GPU.md)")
- ap.add_argument("--layer-split", help="with --gpus: where each later GPU's layers start (\"18\", \"16,32\"); "
- "default auto, placed from each GPU's free VRAM")
+ ap.add_argument("--layer-split", help="with --gpus: where each later GPU's layers start (\"18\", \"16,32\"), one "
+ "rising number per GPU after the first - not layers per card; default "
+ "auto, placed from each GPU's free VRAM")
+ ap.add_argument("--no-remote-expert-opt", action="store_true",
+ help="with two or more GPUs: leave out --remote-expert-opt, which setup adds there (#578)")
ap.add_argument("--host", help="where the server listens: 127.0.0.1 = this PC only (default), 0.0.0.0 = also other "
"devices on your network (issue #26; set --api-key too)")
ap.add_argument("--api-key", help="require this key from clients (recommended with --host 0.0.0.0)")
+ ap.add_argument("--no-browser", dest="browser", action="store_false", default=None,
+ help="do not open the chat page in the browser when the model is ready (for a harness or an app "
+ "that uses the API; remembered for this model, also in run-.bat/.sh)")
+ ap.add_argument("--browser", dest="browser", action="store_true",
+ help="open the chat page again when the model is ready (the default; undoes --no-browser)")
ap.add_argument("--data-dir", help="where the model files go (~70-120 GB): default Strata-data next to this folder, "
"remembered for every Strata folder on this PC")
ap.add_argument("--models-dir", help="where the GGUF files go (default: /models)")
@@ -2960,6 +3585,10 @@ def main() -> int:
help="update the installed engine, Python packages and model settings as a start would, without "
"starting the model (UPDATE.bat / update.sh run it after a git pull)")
ap.add_argument("--build", action="store_true", help="compile the engine instead of using the ready-made one")
+ ap.add_argument("--cuda", choices=["12", "13", "auto"], default=os.environ.get("STRATA_CUDA") or None,
+ help="NVIDIA: the CUDA toolkit of this model's engine. auto (default): CUDA 13, the ready-made "
+ "engine; CUDA 12 (experimental) when a chosen card is older than CUDA 13 supports (Pascal, "
+ "Volta). 12 also runs with an older driver (Windows 528+, Linux 525+). docs/OLDER_GPUS.md")
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")
@@ -2968,28 +3597,37 @@ def main() -> int:
ap.add_argument("--draft-vocab", choices=list(DRAFT_VOCABS),
help="the draft layer's tokens: cjk = with Chinese, Japanese and Korean (default), en = English "
"and code only (~110 MiB less VRAM, English answers 1-2%% faster), cyrillic = English, code "
- "and the Cyrillic script (Ukrainian, Russian... answers decode ~30%% faster)")
+ "and the Cyrillic script (Ukrainian, Russian... answers decode ~30%% faster), fr = English, "
+ "code and French (French answers: 18%% more drafts accepted)")
ap.add_argument("--low-ram", choices=["auto", "on", "off", "resident", "mmap"], default="auto",
help="read the model's experts from one file in its folder instead of copying them all into RAM "
"(for a PC with a big GPU and little RAM); auto: when the experts would not fit the RAM. In "
"this mode the experts the GPU does not hold are copied into RAM once when they fit (resident), "
"else read through the OS file cache (mmap); resident / mmap force one of the two")
ap.add_argument("--resident-budget-gib", type=float, metavar="N",
- help="UD-Q4_K_XL: the GiB of its experts kept in RAM (default: the RAM less 24 GB, 40 on 64 GB; "
+ help="UD-Q4_K_XL, UD-IQ4_XS: the GiB of its experts kept in RAM (default: the RAM less 24 GB, 40 on 64 GB; "
"more is kept as you choose, with a note)")
ap.add_argument("--vram-reserve-mib", type=int, metavar="N",
help="VRAM in MiB the engine leaves free for other programs (a game, another model; the engine's "
"default: 700); the expert cache takes that much less")
+ ap.add_argument("--parallel", type=int, metavar="N",
+ help="up to N requests decode together (batch slots, opt-in; default: one at a time, the others "
+ "wait). Each slot takes VRAM from the expert cache; setup says what it recommends")
ap.add_argument("--kv-streaming", choices=["auto", "on", "off"], default="auto",
help="from a 64K context: keep the KV cache in RAM and only the attention's window in VRAM (more "
"experts fit on the GPU); auto: when the RAM has room for it")
- ap.add_argument("--backend", choices=["cuda", "hip"],
+ ap.add_argument("--backend", choices=["cuda", "hip", "sycl"],
help="cuda = NVIDIA (default), hip = AMD RX 7900 / 7800 / 7700 XT, RX 9060 XT / 9070 / AI PRO R9700 on "
- "Linux or Windows (chosen by itself when the PC has no NVIDIA card Strata can use)")
+ "Linux or Windows (chosen by itself when the PC has no NVIDIA card Strata can use), "
+ "sycl = Intel Arc, EXPERIMENTAL: Linux, built from source (docs/INTEL_ARC.md)")
ap.add_argument("--skip-build", action="store_true", help=argparse.SUPPRESS)
a = ap.parse_args()
+ if a.backend == "sycl": # Intel Arc: the SYCL port's own setup (sycl/setup_intel.py)
+ return sycl_setup(sys.argv[1:])
if a.resident_budget_gib is not None and not a.resident_budget_gib > 0:
ap.error("--resident-budget-gib takes a number of GiB above 0, e.g. --resident-budget-gib 32")
+ if a.vision_tokens is not None and a.vision_tokens < 1:
+ ap.error("--vision-tokens takes a number of image tokens, 1 or more, e.g. --vision-tokens 768")
if a.vram_reserve_mib is not None and a.vram_reserve_mib < 0:
ap.error("--vram-reserve-mib takes a number of MiB, 0 or more, e.g. --vram-reserve-mib 2048")
if a.gpu is not None: # --gpu 0,2 means --gpus 0,2 (a user tried it: issue report)
@@ -3010,6 +3648,7 @@ def main() -> int:
if a.update: # #475: UPDATE.bat / update.sh - never starts the model
return update_install(have, a)
explicit = a.setup or a.model or a.family or a.check or a.no_start
+ adopted = None # #629: the earlier install this copy is set up like
if not have and not explicit: # a new copy of Strata (an update unzipped elsewhere): set it
prev = previous_config(elsewhere, load_settings()) # up like the last one, from the files already here
if prev is not None:
@@ -3018,6 +3657,7 @@ def main() -> int:
say(f" Found your earlier install in {prev.parent} ({prev.stem[len('strata-'):]}): setting up this "
"copy the same way - the model files are reused, nothing big is downloaded.")
a.family, a.model, a.context = ch["family"], ch["model"], a.context or ch["context"]
+ adopted = prev
a.kv = a.kv or ch["kv"]
a.vision = a.vision or ch["vision"]
a.experimental_speed_projection = a.experimental_speed_projection or ch["esp"]
@@ -3025,13 +3665,15 @@ def main() -> int:
a.port = a.port or ch["port"]
if a.vram_reserve_mib is None: # #493: an explicit reserve set up before
a.vram_reserve_mib = ch.get("vram_reserve_mib")
+ if a.cuda is None and ch.get("cuda") == 12: # the experimental CUDA 12 engine, as before
+ a.cuda = "12"
if isinstance(ch.get("gpu"), list): # a layer split: set up across the same cards again
a.gpus = a.gpus or ",".join(str(g) for g in ch["gpu"])
a.layer_split = a.layer_split or ch.get("layer_split")
else:
a.gpu = a.gpu if a.gpu is not None else ch.get("gpu")
a.yes = True
- global GPU_PICK
+ global GPU_PICK, OLD_GPUS
# starting an installed model: --gpus 0,2 (or all) saves those cards for it and starts on them (it used to start
# on the first one alone unless given with --setup), --gpu N runs this start on one card; neither: the saved
# choice, and asked once when the PC has cards that could share the model
@@ -3052,14 +3694,14 @@ def main() -> int:
+ ("keeps" if a.no_start else "starts with") + " the default settings")
return 0 if a.no_start else start(pick_cfg, a.port, run_gpu, yes=a.yes, layer_split=a.layer_split,
keep={"host": a.host, "api_key": a.api_key, "draft_vocab": a.draft_vocab,
- "vram_reserve_mib": a.vram_reserve_mib})
+ "vram_reserve_mib": a.vram_reserve_mib, "open_browser": a.browser})
if have and not (a.setup or a.model or a.family or a.check or a.no_start):
if not a.build:
update_installed_engine(a.prebuilt)
if len(have) == 1:
return start(have[0], a.port, run_gpu, yes=a.yes, layer_split=a.layer_split,
keep={"host": a.host, "api_key": a.api_key, "draft_vocab": a.draft_vocab,
- "vram_reserve_mib": a.vram_reserve_mib})
+ "vram_reserve_mib": a.vram_reserve_mib, "open_browser": a.browser})
say()
for i, c in enumerate(have, 1):
say(f" {i}) {json.loads(c.read_text(encoding='utf-8-sig')).get('model_name', c.stem)}")
@@ -3068,14 +3710,20 @@ def main() -> int:
if pick <= len(have):
return start(have[pick - 1], a.port, run_gpu, yes=a.yes, layer_split=a.layer_split,
keep={"host": a.host, "api_key": a.api_key, "draft_vocab": a.draft_vocab,
- "vram_reserve_mib": a.vram_reserve_mib})
+ "vram_reserve_mib": a.vram_reserve_mib, "open_browser": a.browser})
# ---- 1. the PC
step(1, "checking your PC")
found = gpus()
amd = amd_gpus()
- nv_ok = any(gpu_problem(g) is None for g in found)
amd_ok = [g for g in amd if amd_problem(g) is None]
+ # older NVIDIA GPUs (Pascal / Volta): the experimental CUDA 12 engine, when chosen (docs/OLDER_GPUS.md)
+ OLD_GPUS = OLD_GPUS or old_gpus_opt_in(found, named_gpus(a.gpu, a.gpus), a.cuda,
+ other=bool(amd_ok) or a.backend == "hip")
+ if OLD_GPUS and a.backend != "hip":
+ warn(f"older NVIDIA GPUs (Pascal / Volta) can be used ({OLD_GPUS}): experimental, through a second engine "
+ "built with CUDA 12 (docs/OLDER_GPUS.md)")
+ nv_ok = any(gpu_problem(g) is None for g in found)
hip = a.backend == "hip" or (a.backend is None and not nv_ok and bool(amd_ok))
if a.backend is None and nv_ok and amd_ok:
# both kinds of card: asked (a first run on such a PC used to take NVIDIA without mentioning the Radeon)
@@ -3089,6 +3737,7 @@ def main() -> int:
hip = ask("Which cards?", ["1", "2"], "1", a.yes or a.check) == "2"
if a.check and not hip:
say(f" (the AMD card: {'START-HERE.bat' if WIN else './setup.sh'} --backend hip)")
+ cuda_tk = 13 # NVIDIA: the toolkit of this model's engine (cuda_choice)
if hip: # AMD: compiled here; Windows: ready-made
if WIN and a.gpus:
fail("several AMD cards sharing one model (--gpus) is Linux-only for now", "use one card: --gpu N")
@@ -3136,9 +3785,15 @@ def main() -> int:
if multi:
ok("GPUs: " + " + ".join(gpu_name(x) for x in chosen) + " together (the model's layers are split across them)")
ok(f"GPU: {gpu['name']}, {gpu['vram_gb']:.1f} GB VRAM, compute capability {cc(gpu)}, driver {gpu['driver']}")
- 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")
+ cuda_tk, why = cuda_choice(gpu["archs"], a.cuda) # one engine per model: its oldest card decides
+ if why:
+ (warn if cuda_tk == 13 or str(a.cuda) == "12" else ok)(f"CUDA {cuda_tk}: {why}")
+ min_driver = CUDA12_MIN_DRIVER if cuda_tk == 12 else MIN_DRIVER
+ 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 cuda_tk == 12 else f" (or --cuda 12: the experimental CUDA 12 engine runs with driver "
+ f"{CUDA12_MIN_DRIVER} or newer, docs/OLDER_GPUS.md)"))
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()
@@ -3165,14 +3820,28 @@ def main() -> int:
"the model may not start or may use less VRAM. Set it to \"System managed\": System > About > "
"Advanced system settings > Performance > Advanced > Virtual memory")
ok(f"CPU: {cpu} ({'AVX-512' if avx512 else 'AVX2' if avx2 else 'no AVX2'})")
+ floor = cpu_floor(avx2)
+ if floor == "unsupported":
+ fail("this CPU has neither AVX2 nor SSE4.2; Strata needs at least SSE4.2 (Intel Nehalem, 2008, or newer)")
if not avx2:
- fail("this CPU has no AVX2; Strata needs at least AVX2")
+ # #394 #595 #623: the ready-made engine is AVX2; an older CPU gets one compiled here, whose CPU experts run on
+ # ggml-cpu's kernels for this CPU. Experimental: measured only on newer CPUs with the older path forced, and by
+ # users on a few Xeons. A warning, not a stop.
+ warn(f"this CPU has no AVX2: Strata support for it is EXPERIMENTAL and slow. Setup compiles the engine on this "
+ f"PC for {'AVX' if floor == 'avx' else 'SSE4.2'} (STRATA_ISA_FLOOR={floor}; 10-20 minutes, once), and the "
+ "CPU's share of the experts runs on ggml-cpu's kernels, a few times slower than on an AVX2 CPU. "
+ "See \"Older CPUs\" in docs/INSTALL.md")
+ if hip and WIN:
+ fail("the older-CPU engine is compiled from source, and setup compiles the AMD engine on Linux only",
+ "use Linux for an AMD card on this CPU, or an NVIDIA card")
+ a.build = True
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"
if d.get("budget"):
- verdict = (f"EXPERIMENTAL, fits with {resident_budget_gib(m, ram)} GiB of its experts in RAM, the rest "
+ verdict = (("EXPERIMENTAL, " if d.get("experimental") else "") +
+ f"fits with {resident_budget_gib(m, ram)} GiB of its experts in RAM, the rest "
"read from the SSD" if ram >= d["ram_gb"] else "does not fit")
if hip: # #429: not run on AMD yet (its prompt kernels are CUDA-only)
verdict += " - NVIDIA only so far, untested on AMD"
@@ -3200,6 +3869,7 @@ def main() -> int:
say(f" Its license: {fam['license']}")
say()
names = [m for m in MODELS if family in MODELS[m].get("families", ("qwen", "swift"))]
+ names.sort(key=lambda m: bool(MODELS[m].get("experimental"))) # an experimental size last, never the default
if a.model and a.model not in names:
# #444: say which family has that size, and (with --gguf-dir) which files Strata can run at all
elsewhere_fams = [f for f in FAMILIES if f in MODELS[a.model].get("families", ("qwen", "swift"))]
@@ -3225,9 +3895,10 @@ def main() -> int:
# Unsloth's UD-Q4_K_XL: a RAM budget of experts, the rest from the GGUF on the SSD - not the low-RAM mode (no
# experts.bin: it would be another 77 GB on the disk), and one GPU (the budget mode has no layer split) unless
# the RAM holds the GGUFs and 24 GB more: then several, without the budget, if asked for (#498)
- warn(f"{model} is EXPERIMENTAL (docs/UNSLOTH_Q4.md): most of its experts are read from the SSD while it "
- "answers, so it is several times slower than the 2-3-bit models; quality checked against llama.cpp")
- if hip:
+ if MODELS[model].get("experimental"):
+ warn(f"{model} is EXPERIMENTAL (docs/UNSLOTH_Q4.md): most of its experts are read from the SSD while it "
+ "answers, so it is several times slower than the 2-3-bit models; quality checked against llama.cpp")
+ if hip and MODELS[model].get("nvidia_only"):
# #429 (jkuepker): checked before the 111 GB download. The HIP engine has no prompt kernels for its
# Q4_K / Q5_K experts (STRATA_MMQ_KQUANTS is CUDA-only) and it has not been run on AMD: asked, not refused
confirm_risk(f"{model} has not been run on AMD cards yet: its prompt kernels are NVIDIA-only, so on "
@@ -3252,7 +3923,8 @@ def main() -> int:
if a.low_ram not in ("auto", "off"):
warn(f"--low-ram {a.low_ram} does not apply to {model}: it always reads part of its experts from the files")
elif a.resident_budget_gib is not None:
- warn(f"--resident-budget-gib is for UD-Q4_K_XL: {model} keeps all of its experts in RAM or in the low-RAM mode")
+ warn(f"--resident-budget-gib is for UD-Q4_K_XL and UD-IQ4_XS: {model} keeps all of its experts in RAM or in "
+ "the low-RAM mode")
low_ram = budget is None and (a.low_ram in ("on", "resident", "mmap") or
(a.low_ram == "auto" and low_ram_needed(model, ram)))
if low_ram and multi and not low_ram_together(a, model, ram, gpu, chosen):
@@ -3321,7 +3993,7 @@ def main() -> int:
kv = ["int8", "q4_0"][int(ask("KV cache?", ["1", "2"], "1", a.yes)) - 1]
if ctx > 8192:
ok(f"KV cache: {'8-bit' if kv == 'int8' else '4-bit (Hadamard-rotated)'}")
- if fam.get("vision") is False:
+ if MODELS[model].get("vision", fam.get("vision")) is False: # UD-IQ4_XS: images, unlike UD-Q4_K_XL
vision = "none"
if a.vision not in (None, "no", "none"):
warn(f"images are not available with {model} yet: off")
@@ -3388,7 +4060,7 @@ def main() -> int:
if family == "swift" else f"the experimental speed projection is not tested with {model}: left off")
models_dir = Path(a.gguf_dir) if a.gguf_dir else Path(a.models_dir) / tag
shards = gguf_dir_shards(models_dir, fam, model) if a.gguf_dir else \
- [models_dir / fam["file"].format(q=model, i=i) for i in range(1, fam.get("shards", 2) + 1)]
+ [models_dir / model_file(fam, model, i) for i in range(1, model_shards(fam, model) + 1)]
problem = gguf_dir_problem(models_dir, shards[0], fam, model) if a.gguf_dir else None
if problem: # #444: files Strata cannot run, or another choice's files
fail(*problem)
@@ -3433,24 +4105,26 @@ def main() -> int:
gpu = hip_card(eng, gpu, amd)
a.gpu = gpu["index"] if gpu["count"] > 1 else a.gpu
else:
- eng = None if a.build or hip else get_prebuilt(a.prebuilt, gpu, vision)
+ eng = None if a.build or hip else get_prebuilt(a.prebuilt, gpu, vision, **({"toolkit": 12} if cuda_tk == 12
+ else {}))
if eng is not None and not hip and json.loads((eng / "BUILD.json").read_text()).get("source") != "local":
- pip_install(CUDA_WHEELS, "NVIDIA CUDA libraries (cuBLAS, CUDA runtime; ~0.4 GB)")
+ pip_cuda_libs(cuda_tk)
if vision != "none" and not (eng / VEXE).exists():
warn("the ready-made engine has no image encoder: compiling it")
eng = None
else:
vision = prebuilt_vision(json.loads((eng / "BUILD.json").read_text()), gpu, vision)
if eng is None:
- eng = build_engine_hip(gpu, llama, vision) if hip else build_engine(gpu, vision, a.yes, llama)
+ eng = build_engine_hip(gpu, llama, vision) if hip else build_engine(gpu, vision, a.yes, llama, toolkit=cuda_tk)
meta = json.loads((eng / "BUILD.json").read_text())
if hip and WIN: # the ready-made engine's rocm/bin, first on the engine's PATH
lib_dirs = [str(d) for d in hip_lib_dirs(eng)]
else:
- lib_dirs = meta.get("lib_dirs") or meta.get("cuda_dirs") or cuda_lib_dirs()
+ lib_dirs = meta.get("lib_dirs") or meta.get("cuda_dirs") or cuda_lib_dirs(cuda_tk)
engine_ver = tuple(int(x) for x in str(meta.get("version", "0")).split(".")[:3] if x.isdigit())
- if budget is not None and engine_ver < UNSLOTH_ENGINE: # checked before the 111 GB download
- fail(f"{model} needs engine {'.'.join(map(str, UNSLOTH_ENGINE))} or newer; this one is {meta.get('version')}",
+ need_engine = MODELS[model].get("engine", UNSLOTH_ENGINE)
+ if budget is not None and engine_ver < need_engine: # checked before the 94-111 GB download
+ fail(f"{model} needs engine {'.'.join(map(str, need_engine))} or newer; this one is {meta.get('version')}",
"update Strata (or compile the engine with --build) and run setup again")
ok(f"engine: {eng / EXE}")
@@ -3480,7 +4154,7 @@ def main() -> int:
pass
download(fam["hf"].format(q=model) + s.name, s)
check_shards(shards)
- for s in shards: # the experimental Unsloth file: pinned sizes and SHA-256
+ for s in shards: # the Unsloth files: pinned sizes and SHA-256
if s.name in fam.get("sha256", {}):
verify_sha256(s, *fam["sha256"][s.name])
ok("model files present")
@@ -3532,7 +4206,7 @@ def main() -> int:
"--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)
- # a setup run again without --draft-vocab keeps the subset this model's config chose before (cyrillic, en)
+ # a setup run again without --draft-vocab keeps the subset this model's config chose before (cyrillic, fr, en)
draft_vocab = a.draft_vocab or saved_draft_vocab(ROOT / f"strata-{tag.lower()}.json")
refresh_draft_vocab(rt, draft_vocab or "cjk")
ok(f"MTP draft layer: {rt}")
@@ -3561,6 +4235,19 @@ def main() -> int:
"the OS file cache (run setup again after the next engine update)")
if low_ram: # the experts from the pack's experts.bin: the ones the GPU does not hold copied into RAM, or mapped
args += ["--resident-experts" if resident else "--mmap-experts"]
+ disk = None if is_wsl() else rotational_disk(ple) # #605 (WSL's virtual disk says rotational)
+ if disk:
+ tensor = next((t for t in GGUFFile(ple).tensors if t.name == "per_layer_token_embd.weight"), None)
+ size = getattr(tensor, "expected_bytes", lambda: None)()
+ table_gb = size / 1e9 if size else 28.8
+ if ram >= MODELS[model]["ram_gb"] + table_gb + 4:
+ args += ["--ple-io", "ram"]
+ ok(f"the model is on a rotational disk ({disk}): its {table_gb:.0f} GB n-gram table is kept in RAM "
+ "(--ple-io ram) - read from the disk at random, it can stall prompts for minutes (#605)")
+ else:
+ warn(f"the model is on a rotational disk ({disk}): its n-gram table is read from it at random, which can "
+ f"stall prompts for minutes (#605). An SSD is recommended; with ~{table_gb:.0f} GB more RAM, "
+ "--ple-io ram in the config's args keeps the table in RAM instead")
# KV streaming: from 64K up the whole KV cache lives in RAM and only the part the attention reads (32K positions
# per layer) stays in VRAM; the VRAM it frees holds more experts (+6% at 128K, +23% at 262K with Q2_0). It
# costs ~13.7 KB of RAM per context token with 8-bit KV (1.7 GB at 128K), 7.5 KB with 4-bit, so only when it fits.
@@ -3597,12 +4284,20 @@ def main() -> int:
elif budget is not None and budget > resident_budget_gib(model, ram, kv_ram_gb):
warn(f"the KV cache's {kv_ram_gb:.1f} GB of RAM come on top of your {budget:g} GiB RAM budget (setup "
f"would take them out of it: {resident_budget_gib(model, ram, kv_ram_gb)} GiB); kept as you chose")
+ elif ctx >= 65536: # #620: say why, so a regenerated config that lost --kv-resident is not a surprise
+ ok(f"KV streaming off: it needs ~{kv_ram_gb:.1f} GB of RAM beside the ~{MODELS[model]['ram_gb']} GB {model} "
+ f"uses, and this PC has {ram:.0f}; the KV cache stays in VRAM (fewer cached experts). --kv-streaming on "
+ "turns it on anyway")
elif a.kv_streaming == "on":
warn("--kv-streaming on: a context under 64K is not streamed (the attention's window holds all of it): off")
if budget is not None and not q4_split: # UD-Q4_K_XL: the experts read from the GGUF in place, the most-used N
args += ["--resident-budget-gib", f"{budget:g}"] # GiB kept in RAM (#498: a layer split has no budget)
if vision != "none":
args += ["--vision", "--vram-reserve-mib", str(VISION[vision]["reserve_mib"])]
+ if vision == "gpu" and a.vram_reserve_mib is None and 0 < gpu.get("vram_gb", 0.0) <= 12.5:
+ # a tip only (recommend, never force): on a 12 GB card the encoder's 700 MiB can leave ~200 MiB free
+ print(f" tip: images on a {gpu['vram_gb']:.0f} GB card can leave little VRAM free; if a request stalls, "
+ f"run setup again with --vram-reserve-mib {VISION_GPU_SMALL_RESERVE_MIB}")
if a.vram_reserve_mib is not None: # #493: VRAM left free for other programs (only when given)
if "--vram-reserve-mib" in args:
i = args.index("--vram-reserve-mib") + 1
@@ -3629,6 +4324,8 @@ def main() -> int:
cfg = {"exe": str(eng / EXE), "args": args, "cwd": str(ROOT), "tokenizer": str(pack / "tokenizer"),
"model_name": f"{fam['name']}-{model.lower()}", "log": str(ROOT / f"strata-{tag.lower()}.log"),
"lib_dirs": lib_dirs, "port": port}
+ if cuda_tk == 12: # the experimental CUDA 12 engine (engine-cuda12/)
+ cfg["cuda"] = 12
if hip:
cfg["backend"] = "hip"
# the dense prompt GEMMs through hipBLASLt with kernels measured on this GPU generation (tools/hip; +40-60%
@@ -3646,26 +4343,46 @@ def main() -> int:
cfg["gpu"] = multi
cfg["layer_split"] = a.layer_split or "auto"
ok(f"layer split across GPUs {multi} ({cfg['layer_split']})")
+ recommend_remote_expert_opt(cfg, off=a.no_remote_expert_opt)
if a.host:
cfg["host"] = a.host
if a.api_key:
cfg["api_key"] = a.api_key
if draft_vocab:
cfg["draft_vocab"] = draft_vocab
+ if a.browser is not None: # #609: only when given (else an earlier choice is carried over)
+ cfg["open_browser"] = a.browser
+ # #465: requests at once - written only when given (else an earlier "parallel" is carried over); a recommendation
+ streaming = "--kv-resident" in args
+ if a.parallel is not None:
+ if a.parallel >= 2:
+ cfg["parallel"] = a.parallel
+ for i, line in enumerate(parallel_note(a.parallel, [g.get("vram_gb", 0.0) for g in chosen],
+ MODELS[model]["arena_gb"], ctx, kv, streaming)):
+ (ok if i == 0 else warn)(line)
+ else:
+ cfg["parallel"] = 1
+ ok("parallel requests: one at a time (--parallel 1)")
if vision != "none":
+ old_cfg = ROOT / f"strata-{tag.lower()}.json"
+ vt = vision_tokens(a.vision_tokens, vision, old_cfg if old_cfg.is_file() else adopted)
cfg["vision"] = {"exe": str(eng / VEXE), "mmproj": str(mmproj), "model": str(shards[0]),
- "gpu": vision == "gpu", "max_tokens": VISION[vision]["max_tokens"]}
+ "gpu": vision == "gpu", "max_tokens": vt}
if vision == "cpu":
cfg["vision"]["threads"] = max(1, (os.cpu_count() or 8) // 2)
+ elif a.vision_tokens is not None:
+ warn("--vision-tokens: images are off for this model, so it is not used")
cfg_path = ROOT / f"strata-{tag.lower()}.json"
- cal = None if hip else saved_calibration(cfg) # tools/calibrate.py is NVIDIA-only for now
+ cal = setup_calibration(cfg, hip) # #566: Linux HIP too; the tuning is offered on NVIDIA only
if cal is not None:
sys.path.insert(0, str(ROOT / "tools"))
import calibrate as CAL
cfg["args"] = CAL.apply(cfg["args"], cal.get("settings") or {})
ok("the settings tuned for this PC earlier are used" + (f" ({cal['date']})" if cal.get("date") else ""))
- write_config(cfg_path, cfg)
- script = write_run_script(tag, cfg_path, port)
+ else: # #642: measured counts (a calibration) win over the rule
+ cfg["args"] = recommend_pool_workers(cfg["args"])
+ write_setup_config(cfg_path, cfg, adopted if adopted is not None and adopted.name == cfg_path.name else None)
+ script = write_run_script(tag, cfg_path, port, cfg.get("open_browser") is not False)
# offered only when someone answers: --yes installs and adopted earlier installs are not held up by it
if cal is None and not hip and not a.no_start and not a.yes and ask(
"Tune Strata for this PC now? It measures a few engine settings (about 5-10 minutes; the PC is busy "
@@ -3685,6 +4402,10 @@ def main() -> int:
say(f" Next time: just run {'START-HERE.bat' if WIN else './setup.sh'} (or {script.name}) - it starts right away")
if vision != "none":
say(" Images: send them in the chat page, in chat.py (/image ) or over the API")
+ if a.parallel is None: # #465: the opt-in, said once (nothing changes)
+ for line in parallel_note(None, [g.get("vram_gb", 0.0) for g in chosen], MODELS[model]["arena_gb"], ctx, kv,
+ "--kv-resident" in cfg["args"]):
+ say(" " + line)
if tuned is False: # #447: a failed tuning is repeated here, not only above
say(" Tuning: FAILED (the reason is above): the default settings stay - "
f"{'START-HERE.bat' if WIN else './setup.sh'} --calibrate tries again")
diff --git a/src/core/conversation_cache_test.cpp b/src/core/conversation_cache_test.cpp
index f10b729b5a..f544dd743e 100644
--- a/src/core/conversation_cache_test.cpp
+++ b/src/core/conversation_cache_test.cpp
@@ -2,6 +2,7 @@
#include
#include
+#include
using namespace strata::core;
@@ -209,5 +210,96 @@ int main() {
check(!disabled.put(image({1,2,3})) && !no_slots.put(image({1,2,3})), "disabled cache stores nothing");
check(disabled.best(a,{},true).tokens == 0, "disabled cache has no matches");
}
+ {
+ // layer-split parking: checkpoints moved apart into stage parts and put back, no running state copied
+ auto running = [](std::initializer_list ids, uint8_t salt, size_t parts) {
+ ConversationCheckpoint c;
+ c.ids = ids;
+ c.gdn.assign(256, salt);
+ c.tails.assign(32, uint8_t(salt + 1));
+ c.used = salt;
+ for (size_t k = 0; k < parts; ++k) {
+ ConversationCheckpoint p;
+ p.gdn.assign(128, uint8_t(salt + 10 + k));
+ c.stage_parts.push_back(std::move(p));
+ }
+ return c;
+ };
+ std::vector checks;
+ checks.push_back(running({1, 2}, 3, 2));
+ checks.push_back(running({1, 2, 3}, 5, 1)); // an incomplete set: kept aside, put back in place
+ checks.push_back(running({1, 2, 3, 4}, 7, 2));
+ const auto original = checks;
+ const uint8_t* gdn0 = checks[0].gdn.data();
+ const uint8_t* part1 = checks[2].stage_parts[1].gdn.data();
+ auto split = conversation_checkpoints_split(std::move(checks), 2);
+ check(checks.empty() && split.stage0.size() == 2 && split.rest.size() == 1 && split.parts.size() == 2 &&
+ split.parts[0].size() == 2 && split.parts[1].size() == 2, "split: complete checkpoints apart, the other aside");
+ check(split.stage0[0].stage_parts.empty() && split.parts[1][1].ids == original[2].ids &&
+ split.parts[1][1].used == original[2].used && split.parts[0][0].gdn == original[0].stage_parts[0].gdn,
+ "split: every stage part carries its checkpoint's ids and LRU stamp");
+ check(split.stage0[0].gdn.data() == gdn0 && split.parts[1][1].gdn.data() == part1,
+ "split moves the running state (same buffers, no copy)");
+ std::vector back;
+ check(conversation_checkpoints_merge(std::move(split), back) && back.size() == 3, "merge puts them back");
+ bool same = true;
+ for (size_t j = 0; j < back.size(); ++j) {
+ same = same && back[j].ids == original[j].ids && back[j].gdn == original[j].gdn &&
+ back[j].tails == original[j].tails && back[j].used == original[j].used &&
+ back[j].stage_parts.size() == original[j].stage_parts.size();
+ for (size_t k = 0; same && k < back[j].stage_parts.size(); ++k)
+ same = back[j].stage_parts[k].gdn == original[j].stage_parts[k].gdn;
+ }
+ check(same, "merge(split(x)) == x, in the original order");
+ check(back[0].gdn.data() == gdn0 && back[2].stage_parts[1].gdn.data() == part1, "merge moves them back too");
+
+ auto torn = conversation_checkpoints_split(std::vector(original), 2);
+ torn.parts[1].pop_back();
+ check(!conversation_checkpoints_merge(std::move(torn), back) && back.empty(), "a stage short of a part is refused");
+ auto mixed = conversation_checkpoints_split(std::vector(original), 2);
+ mixed.parts[0][0].ids = {9, 9};
+ check(!conversation_checkpoints_merge(std::move(mixed), back) && back.empty(), "a part of another checkpoint is refused");
+ // a restored image: stage 0's checkpoints and each stage's, with no record of an incomplete one
+ ConversationCheckpointSplit restored;
+ restored.stage0.push_back(running({4, 5}, 11, 0));
+ restored.parts.resize(2);
+ for (auto& p : restored.parts) { auto c = running({4, 5}, 12, 0); p.push_back(std::move(c)); }
+ check(conversation_checkpoints_merge(std::move(restored), back) && back.size() == 1 &&
+ back[0].stage_parts.size() == 2, "a restored image's parts are all whole");
+ auto none = conversation_checkpoints_split({}, 3);
+ check(conversation_checkpoints_merge(std::move(none), back) && back.empty(), "no checkpoints: nothing to do");
+ }
+ {
+ // the later stages' retained K/V, kept with the first stage's and limited together
+ auto kv = [](size_t n) {
+ std::vector v(2);
+ for (auto& l : v) l.k.resize(n, 1);
+ return v;
+ };
+ ConversationCache cache(1 << 20, 4);
+ std::vector> stage_kv;
+ stage_kv.push_back(kv(1000));
+ stage_kv.push_back(kv(2000));
+ cache.retain(kv(500), 40, std::move(stage_kv));
+ check(cache.retained_bytes() >= 2 * (500 + 1000 + 2000), "retained bytes count every stage");
+ cache.limit_reuse(30);
+ auto r = cache.take_reuse();
+ check(r.kv.size() == 2 && r.stages.size() == 2 && r.unchanged_tokens == 30 &&
+ r.stages[0].unchanged_tokens == 30 && r.stages[1].unchanged_tokens == 30 &&
+ r.stages[1].captured_tokens == 40, "every stage's reuse is limited to the first rewrite");
+ check(cache.retained_bytes() == 0, "taken once");
+ std::vector> big;
+ big.push_back(kv(1 << 20));
+ cache.retain(kv(500), 40, std::move(big));
+ check(cache.retained_bytes() == 0, "a stage's K/V over the budget drops the whole reuse");
+ std::vector> one;
+ one.push_back(kv(100));
+ cache.retain(kv(500), 40, std::move(one));
+ cache.limit_reuse(0);
+ check(cache.retained_bytes() == 0, "a rewrite from the start drops every stage's reuse");
+ cache.retain(kv(500), 40);
+ r = cache.take_reuse();
+ check(r.kv.size() == 2 && r.stages.empty(), "no layer split: no stage reuse, as before");
+ }
std::printf("conversation_cache_test: %d checks passed\n", checks);
}
diff --git a/src/core/conversation_snapshot_test.cpp b/src/core/conversation_snapshot_test.cpp
index da95113c65..1f1de53c04 100644
--- a/src/core/conversation_snapshot_test.cpp
+++ b/src/core/conversation_snapshot_test.cpp
@@ -187,6 +187,47 @@ void full_session(int fmt, int mode, int experts) {
check(conversation_kv_verify(incremental.kv.back(),draft.state,g,70,false,fingerprint,err),"incremental draft authoritative and ring read-back");
ids.resize(65);
}
+ {
+ // a layer split's later stage: the same image WITHOUT the draft layer's K/V (draft == nullptr)
+ check(conversation_snapshot_restore(a,ss,g,draft.state,err)==ConversationRestore::restored,"restore A before stage images");
+ size_t with_draft=0,without=0;
+ check(conversation_snapshot_bytes(view,ss,g,draft.state,with_draft,err) &&
+ conversation_snapshot_bytes(view,ss,g,nullptr,without,err) && without0 && incremental.bytes()<=peak && equal(incremental.kv[0],fresh.kv[0]) &&
+ incremental.live.gdn==fresh.live.gdn,"an incremental stage capture reuses pages and equals a full one");
+ SavedConversation split_image=stage;
+ split_image.stage_images.push_back(stage);
+ check(!conversation_snapshot_validate(split_image,ss,g,draft.state,err),
+ "a layer split's image is refused by the whole-session form");
+ ConversationKvReuse wrong{a.kv,65,65,{}};
+ check(!conversation_snapshot_capture_bytes(wrong,view,ss,g,nullptr,peak,err),"a draft image's K/V is not a stage's reuse");
+ }
+ check(conversation_snapshot_restore(a,ss,g,draft.state,err)==ConversationRestore::restored,"restore A after stage images");
check(conversation_checkpoint_restore(a.checkpoints[0],ss,g,err),"restore early running checkpoint");
std::vector spare(sizes.dead);
cuda_check(cudaMemcpy(spare.data(),main.state.idx_pooled,sizes.dead,cudaMemcpyDeviceToHost));
diff --git a/src/core/conversation_state.cpp b/src/core/conversation_state.cpp
index 3e97827cb1..981beca16b 100644
--- a/src/core/conversation_state.cpp
+++ b/src/core/conversation_state.cpp
@@ -193,7 +193,7 @@ bool conversation_checkpoint_restore(const ConversationCheckpoint& c, SessionSta
}
bool conversation_snapshot_bytes(const ConversationView& view, const SessionState& ss,
- const ModelGeometry& g, const QsaState& draft, size_t& bytes, std::string& error) {
+ const ModelGeometry& g, const QsaState* draft, size_t& bytes, std::string& error) {
bytes = 0;
if (!view_validate(view, ss, g, error)) return false;
ConversationStateSizes z;
@@ -203,7 +203,7 @@ bool conversation_snapshot_bytes(const ConversationView& view, const SessionStat
if (!product(ids, {view.ids.size(), sizeof(int32_t)}) ||
!product(images, {view.images.size(), sizeof(ConversationImageKey)}) ||
!product(checkpoints, {view.checkpoints.size(), sizeof(ConversationCheckpoint)}) ||
- !product(layers, {qsa + 1, sizeof(ConversationKv)}) ||
+ !product(layers, {qsa + (draft ? 1 : 0), sizeof(ConversationKv)}) ||
!product(tails, {qsa, z.tail}) || !product(dead, {qsa, z.dead}) ||
!product(positions, {qsa, z.block_pos})) return fail(error, "snapshot metadata byte count overflow");
for (size_t n : {ids, images, checkpoints, layers, tails, dead, positions, z.gdn, ss.ple_hist ? z.ple : 0})
@@ -211,8 +211,8 @@ bool conversation_snapshot_bytes(const ConversationView& view, const SessionStat
for (const auto& c : view.checkpoints)
if (!metadata_bytes(c, bytes)) return fail(error, "checkpoint byte count overflow");
const int64_t upto = (int64_t) view.ids.size(); // view_validate bounds this by signed max_cells
- for (uint64_t i = 0; i <= qsa; ++i) {
- const auto& st = i == qsa ? draft : owned(ss, (size_t) i);
+ for (uint64_t i = 0; i < qsa + (draft ? 1 : 0); ++i) {
+ const auto& st = i == qsa ? *draft : owned(ss, (size_t) i);
const size_t n = conversation_kv_bytes(st, g, upto, i != qsa);
if (!n || !add(bytes, n)) return fail(error, "invalid or overflowing K/V byte estimate");
}
@@ -221,16 +221,16 @@ bool conversation_snapshot_bytes(const ConversationView& view, const SessionStat
bool conversation_snapshot_capture_bytes(const ConversationKvReuse& reuse, const ConversationView& view,
const SessionState& ss, const ModelGeometry& g,
- const QsaState& draft, size_t& bytes, std::string& error) {
+ const QsaState* draft, size_t& bytes, std::string& error) {
if (!conversation_snapshot_bytes(view, ss, g, draft, bytes, error)) return false;
if (reuse.kv.empty()) return true;
- const size_t layers = owned_qsa(ss) + 1;
+ const size_t layers = owned_qsa(ss) + (draft ? 1 : 0);
if (reuse.kv.size() != layers || reuse.unchanged_tokens < 0 ||
reuse.unchanged_tokens > reuse.captured_tokens || reuse.unchanged_tokens > int64_t(view.ids.size()))
return fail(error, "invalid retained K/V prefix");
for (size_t i = 0; i < layers; ++i) {
- const bool index = i + 1 != layers;
- const auto& st = index ? owned(ss, i) : draft;
+ const bool index = i < owned_qsa(ss);
+ const auto& st = index ? owned(ss, i) : *draft;
if (!conversation_kv_validate(reuse.kv[i], st, g, reuse.captured_tokens, index, error)) return false;
const size_t fresh = conversation_kv_bytes(st, g, int64_t(view.ids.size()), index);
size_t retained = 0;
@@ -246,7 +246,7 @@ bool conversation_snapshot_capture_bytes(const ConversationKvReuse& reuse, const
bool conversation_snapshot_save(SavedConversation& image, const ConversationView& view,
const SessionState& ss, const ModelGeometry& g,
- const QsaState& draft, std::string& error,
+ const QsaState* draft, std::string& error,
ConversationKvReuse reuse, size_t* reused_bytes) {
size_t estimate = 0;
if (!conversation_snapshot_capture_bytes(reuse, view, ss, g, draft, estimate, error) || !sync(error)) return false;
@@ -259,7 +259,7 @@ bool conversation_snapshot_save(SavedConversation& image, const ConversationView
const int64_t unchanged = reuse.kv.empty() ? 0 : reuse.unchanged_tokens;
captured.kv = std::move(reuse.kv);
const size_t layers = owned_qsa(ss);
- captured.kv.resize(layers + 1);
+ captured.kv.resize(layers + (draft ? 1 : 0));
if (!conversation_checkpoint_save(captured.live, ss, g, error)) return false;
const int64_t upto = (int64_t) view.ids.size();
for (size_t j = 0; j < layers; ++j)
@@ -267,15 +267,16 @@ bool conversation_snapshot_save(SavedConversation& image, const ConversationView
unchanged, reused_bytes)) return false;
// The draft's final cell may not have been computed when the output cap was
// reached. Refresh that page even when the main prefix continued unchanged.
- if (!conversation_kv_save(captured.kv.back(), draft, g, upto, false, error,
- std::max(0, unchanged - 1), reused_bytes)) return false;
+ if (draft && !conversation_kv_save(captured.kv.back(), *draft, g, upto, false, error,
+ std::max(0, unchanged - 1), reused_bytes)) return false;
image = std::move(captured);
return true;
}
bool conversation_snapshot_validate(const SavedConversation& image, const SessionState& ss,
- const ModelGeometry& g, const QsaState& draft, std::string& error) {
- if (!image.live.stage_parts.empty()) return fail(error, "layer-split parking is not supported");
+ const ModelGeometry& g, const QsaState* draft, std::string& error) {
+ if (!image.live.stage_parts.empty())
+ return fail(error, "a running checkpoint's stage parts belong in the stage images, not in live.stage_parts");
if (image.geometry != geometry_key(g)) return fail(error, "incompatible runtime geometry");
// an image holds exactly one carve's running state and K/V: same layer range or nothing
if (image.layer_lo != ss.layer_lo || image.layer_hi != ss.layer_hi)
@@ -283,23 +284,49 @@ bool conversation_snapshot_validate(const SavedConversation& image, const Sessio
const ConversationView view{image.live.ids, image.live.imgs, image.checkpoints, image.cvec};
if (!view_validate(view, ss, g, error) || !conversation_checkpoint_validate(image.live, ss, g, error)) return false;
const size_t layers = owned_qsa(ss);
- if (image.kv.size() != layers + 1) return fail(error, "invalid K/V layer count");
+ if (image.kv.size() != layers + (draft ? 1 : 0)) return fail(error, "invalid K/V layer count");
const int64_t upto = (int64_t) image.live.ids.size();
for (size_t j = 0; j < layers; ++j)
if (!conversation_kv_validate(image.kv[j], owned(ss, j), g, upto, true, error)) return false;
- return conversation_kv_validate(image.kv.back(), draft, g, upto, false, error);
+ return !draft || conversation_kv_validate(image.kv.back(), *draft, g, upto, false, error);
}
ConversationRestore conversation_snapshot_restore(const SavedConversation& image, SessionState& ss,
- const ModelGeometry& g, const QsaState& draft, std::string& error) {
+ const ModelGeometry& g, const QsaState* draft, std::string& error) {
if (!conversation_snapshot_validate(image, ss, g, draft, error)) return ConversationRestore::invalid;
if (!sync(error)) return ConversationRestore::transfer_failed;
const int64_t upto = (int64_t) image.live.ids.size();
for (size_t j = 0; j < owned_qsa(ss); ++j)
if (!conversation_kv_restore(image.kv[j], owned(ss, j), g, upto, true, error))
return ConversationRestore::transfer_failed;
- if (!conversation_kv_restore(image.kv.back(), draft, g, upto, false, error) ||
+ if ((draft && !conversation_kv_restore(image.kv.back(), *draft, g, upto, false, error)) ||
!conversation_checkpoint_restore(image.live, ss, g, error)) return ConversationRestore::transfer_failed;
return ConversationRestore::restored;
}
+
+// the draft layer's K/V included (the stage that owns the draft head, or no layer split)
+bool conversation_snapshot_bytes(const ConversationView& view, const SessionState& ss, const ModelGeometry& g,
+ const QsaState& draft, size_t& bytes, std::string& error) {
+ return conversation_snapshot_bytes(view, ss, g, &draft, bytes, error);
+}
+bool conversation_snapshot_capture_bytes(const ConversationKvReuse& reuse, const ConversationView& view,
+ const SessionState& ss, const ModelGeometry& g, const QsaState& draft,
+ size_t& bytes, std::string& error) {
+ return conversation_snapshot_capture_bytes(reuse, view, ss, g, &draft, bytes, error);
+}
+bool conversation_snapshot_save(SavedConversation& image, const ConversationView& view, const SessionState& ss,
+ const ModelGeometry& g, const QsaState& draft, std::string& error,
+ ConversationKvReuse reuse, size_t* reused_bytes) {
+ return conversation_snapshot_save(image, view, ss, g, &draft, error, std::move(reuse), reused_bytes);
+}
+bool conversation_snapshot_validate(const SavedConversation& image, const SessionState& ss, const ModelGeometry& g,
+ const QsaState& draft, std::string& error) {
+ // the whole-session form: a layer split's image (stage images) is restored stage by stage, never through here
+ if (!image.stage_images.empty()) return fail(error, "a layer split's image restored as a single session");
+ return conversation_snapshot_validate(image, ss, g, &draft, error);
+}
+ConversationRestore conversation_snapshot_restore(const SavedConversation& image, SessionState& ss,
+ const ModelGeometry& g, const QsaState& draft, std::string& error) {
+ return conversation_snapshot_restore(image, ss, g, &draft, error);
+}
} // namespace strata::core
diff --git a/src/core/device.cu b/src/core/device.cu
index 98fc071714..fbc7030731 100644
--- a/src/core/device.cu
+++ b/src/core/device.cu
@@ -212,7 +212,7 @@ std::string gpu_arch_problem(int ordinal) {
}
std::string device_code_error() {
-#if defined(STRATA_USE_HIP)
+#if defined(STRATA_USE_HIP) || defined(STRATA_HIP_GFX906)
return ""; // gpu_arch_problem() checks the HIP architectures against STRATA_HIP_ARCHS, before this point
#else
// every .cu of the engine is compiled for the same CMAKE_CUDA_ARCHITECTURES, so this kernel stands for all
@@ -263,7 +263,17 @@ DeviceInfo device_info(int ordinal) {
// supported arch is enforced by CMake; RUNNING on an older card is caught here, because a binary can be carried
// to a machine with an older card and would otherwise silently take whatever path the driver chose. The HIP
// backend checks the card against the architectures the binary was compiled for (and wave32).
-#if defined(STRATA_USE_HIP)
+#if defined(STRATA_HIP_GFX906)
+ // AMD gfx906 (the STRATA_HIP_GFX906 compat build, not STRATA_USE_HIP): the build targets one gfx arch
+ // (CMAKE_HIP_ARCHITECTURES, wave64); a card of another arch fails at the first kernel launch with
+ // hipErrorInvalidDeviceFunction. gcnArchName says which it is.
+ {
+ std::string arch(p.gcnArchName);
+ if (const size_t colon = arch.find(':'); colon != std::string::npos) arch.resize(colon);
+ d.arch = arch;
+ }
+ if (p.gcnArchName[0] != 0) d.name += std::string(" (") + p.gcnArchName + ")";
+#elif defined(STRATA_USE_HIP)
d.arch = base_arch(p.gcnArchName);
if (const std::string why = arch_problem(p, ordinal); !why.empty()) throw CudaError(why, -1);
#else
diff --git a/src/core/expert_cache.cpp b/src/core/expert_cache.cpp
index dc64443679..b8df2faf4b 100644
--- a/src/core/expert_cache.cpp
+++ b/src/core/expert_cache.cpp
@@ -1,7 +1,16 @@
// src/core/expert_cache.cpp - R4's slot storage and residency table. Read the header first.
#include "strata/core/expert_cache.hpp"
+// #533's segmented cache uses CUDA's virtual memory management (cuMem*): not on HIP, neither the RDNA backend nor
+// the gfx906 compat build (PR #638), which compiles this file as HIP without STRATA_USE_HIP
+#if defined(STRATA_USE_HIP) || defined(STRATA_HIP_GFX906)
+#define STRATA_EC_NO_VMM 1
+#endif
+
#include
+#if !defined(STRATA_EC_NO_VMM)
+#include // #533: the virtual memory management types (the functions come through the runtime's entry points)
+#endif
#include
#include
@@ -142,6 +151,239 @@ bool write_expert_profile(const std::string& path, int64_t n_layers, int64_t n_e
ExpertCache::~ExpertCache() { close(); }
+// ---- #533: the segmented arena (--vram-elastic). The driver API's virtual memory functions, looked up through the
+// runtime (no link against the driver library): one address range for the whole arena, backed by physical segments,
+// and the tail's segments unmapped / mapped again later. Nothing here runs unless a segment size was set.
+#if !defined(STRATA_EC_NO_VMM)
+namespace {
+struct Vmm {
+ CUresult (CUDAAPI* device_get)(CUdevice*, int) = nullptr;
+ CUresult (CUDAAPI* attribute)(int*, CUdevice_attribute, CUdevice) = nullptr;
+ CUresult (CUDAAPI* granularity)(size_t*, const CUmemAllocationProp*, CUmemAllocationGranularity_flags) = nullptr;
+ CUresult (CUDAAPI* reserve)(CUdeviceptr*, size_t, size_t, CUdeviceptr, unsigned long long) = nullptr;
+ CUresult (CUDAAPI* address_free)(CUdeviceptr, size_t) = nullptr;
+ CUresult (CUDAAPI* create)(CUmemGenericAllocationHandle*, size_t, const CUmemAllocationProp*,
+ unsigned long long) = nullptr;
+ CUresult (CUDAAPI* release)(CUmemGenericAllocationHandle) = nullptr;
+ CUresult (CUDAAPI* map)(CUdeviceptr, size_t, size_t, CUmemGenericAllocationHandle, unsigned long long) = nullptr;
+ CUresult (CUDAAPI* unmap)(CUdeviceptr, size_t) = nullptr;
+ CUresult (CUDAAPI* set_access)(CUdeviceptr, size_t, const CUmemAccessDesc*, size_t) = nullptr;
+ bool ok = false;
+};
+
+template bool entry(const char* name, F& f) {
+ void* p = nullptr;
+ cudaDriverEntryPointQueryResult q{};
+#if CUDART_VERSION >= 12050
+ const cudaError_t e = cudaGetDriverEntryPointByVersion(name, &p, 12000, cudaEnableDefault, &q);
+#else
+ const cudaError_t e = cudaGetDriverEntryPoint(name, &p, cudaEnableDefault, &q);
+#endif
+ if (e != cudaSuccess || q != cudaDriverEntryPointSuccess || p == nullptr) {
+ (void) cudaGetLastError();
+ return false;
+ }
+ f = reinterpret_cast(p);
+ return true;
+}
+
+const Vmm& vmm() {
+ static const Vmm v = [] {
+ Vmm x;
+ x.ok = entry("cuDeviceGet", x.device_get) && entry("cuDeviceGetAttribute", x.attribute) &&
+ entry("cuMemGetAllocationGranularity", x.granularity) && entry("cuMemAddressReserve", x.reserve) &&
+ entry("cuMemAddressFree", x.address_free) && entry("cuMemCreate", x.create) &&
+ entry("cuMemRelease", x.release) && entry("cuMemMap", x.map) && entry("cuMemUnmap", x.unmap) &&
+ entry("cuMemSetAccess", x.set_access);
+ return x;
+ }();
+ return v;
+}
+
+CUmemAllocationProp device_prop(int dev) {
+ CUmemAllocationProp prop{};
+ prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
+ prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
+ prop.location.id = dev;
+ return prop;
+}
+
+// one segment: a physical allocation mapped at `va`, readable and writable by this device
+bool map_segment(const Vmm& v, int dev, CUdeviceptr va, size_t bytes, unsigned long long& handle) {
+ const CUmemAllocationProp prop = device_prop(dev);
+ CUmemGenericAllocationHandle h = 0;
+ if (v.create(&h, bytes, &prop, 0) != CUDA_SUCCESS) return false;
+ if (v.map(va, bytes, 0, h, 0) != CUDA_SUCCESS) {
+ v.release(h);
+ return false;
+ }
+ CUmemAccessDesc access{};
+ access.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
+ access.location.id = dev;
+ access.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
+ if (v.set_access(va, bytes, &access, 1) != CUDA_SUCCESS) {
+ v.unmap(va, bytes);
+ v.release(h);
+ return false;
+ }
+ handle = (unsigned long long) h;
+ return true;
+}
+} // namespace
+#endif
+
+bool ExpertCache::open_segmented(uint64_t want, std::string& err) {
+#if defined(STRATA_EC_NO_VMM)
+ (void) want;
+ err = "ExpertCache: --vram-elastic (a segmented expert cache) is CUDA-only for now";
+ return false;
+#else
+ const Vmm& v = vmm();
+ int dev = 0, supported = 0;
+ CUdevice cu = 0;
+ if (!v.ok || cudaGetDevice(&dev) != cudaSuccess || v.device_get(&cu, dev) != CUDA_SUCCESS ||
+ v.attribute(&supported, CU_DEVICE_ATTRIBUTE_VIRTUAL_MEMORY_MANAGEMENT_SUPPORTED, cu) != CUDA_SUCCESS ||
+ !supported) {
+ err = "ExpertCache: --vram-elastic needs the driver's virtual memory management, which this GPU or driver "
+ "does not offer";
+ return false;
+ }
+ const CUmemAllocationProp prop = device_prop(dev);
+ size_t gran = 0;
+ if (v.granularity(&gran, &prop, CU_MEM_ALLOC_GRANULARITY_RECOMMENDED) != CUDA_SUCCESS || gran == 0) {
+ err = "ExpertCache: cannot read the driver's allocation granularity";
+ return false;
+ }
+ const uint64_t g = (uint64_t) gran;
+ const uint64_t total = (want + g - 1) / g * g;
+ seg_ = (int64_t) (((uint64_t) seg_req_ + g - 1) / g * g);
+ CUdeviceptr va = 0;
+ if (v.reserve(&va, (size_t) total, 0, 0, 0) != CUDA_SUCCESS) {
+ err = "ExpertCache: cannot reserve the address range of the segmented expert cache";
+ return false;
+ }
+ base_ = reinterpret_cast(va);
+ reserved_ = total;
+ for (uint64_t at = 0; at < total; at += (uint64_t) seg_) {
+ segs_.push_back(0);
+ seg_size_.push_back((int64_t) std::min((uint64_t) seg_, total - at));
+ }
+ for (size_t i = 0; i < segs_.size(); ++i) {
+ if (!map_segment(v, dev, va + (CUdeviceptr) ((uint64_t) i * (uint64_t) seg_), (size_t) seg_size_[i],
+ segs_[i])) {
+ char buf[200];
+ std::snprintf(buf, sizeof buf, "ExpertCache: cudaMalloc failed: segment %zu of %zu (%.2f GiB) of the "
+ "segmented cache could not be allocated", i + 1, segs_.size(),
+ (double) seg_size_[i] / 1073741824.0);
+ err = buf; // "cudaMalloc failed": the auto cache's smaller-retry path reads it as an allocation failure
+ release_segmented();
+ return false;
+ }
+ mapped_segs_ = (int64_t) i + 1;
+ }
+ return true;
+#endif
+}
+
+void ExpertCache::release_segmented() {
+#if !defined(STRATA_EC_NO_VMM)
+ const Vmm& v = vmm();
+ if (base_ != nullptr) cudaDeviceSynchronize();
+ const CUdeviceptr va = reinterpret_cast(base_);
+ for (size_t i = 0; i < segs_.size(); ++i)
+ if (segs_[i] != 0) {
+ v.unmap(va + (CUdeviceptr) ((uint64_t) i * (uint64_t) seg_), (size_t) seg_size_[i]);
+ v.release((CUmemGenericAllocationHandle) segs_[i]);
+ }
+ if (base_ != nullptr && reserved_ > 0) v.address_free(va, (size_t) reserved_);
+#endif
+ segs_.clear();
+ seg_size_.clear();
+ mapped_segs_ = 0;
+ reserved_ = 0;
+ base_ = nullptr;
+}
+
+int64_t ExpertCache::mapped_bytes() const {
+ if (segs_.empty()) return base_ != nullptr ? full_bytes() : 0;
+ int64_t b = 0;
+ for (int64_t i = 0; i < mapped_segs_; ++i) b += seg_size_[(size_t) i];
+ return b;
+}
+
+int64_t ExpertCache::slots_within(int64_t bytes) const {
+ if (bytes >= full_bytes()) return slots_;
+ if (bytes <= 0) return 0;
+ if (off_.empty()) return blob_ > 0 ? bytes / blob_ : 0;
+ // off_[i + 1] is slot i's end: count the slots whose end is at most `bytes`
+ return (int64_t) (std::upper_bound(off_.begin() + 1, off_.end(), (uint64_t) bytes) - (off_.begin() + 1));
+}
+
+bool ExpertCache::shrink(int64_t keep_bytes, std::string& err) {
+ if (segs_.empty()) {
+ err = "the expert cache is not segmented (the engine needs --vram-elastic)";
+ return false;
+ }
+#if defined(STRATA_EC_NO_VMM)
+ (void) keep_bytes;
+ return false;
+#else
+ const Vmm& v = vmm();
+ int64_t keep = 0, at = 0; // the segments [0, keep) hold the first keep_bytes
+ while (keep < (int64_t) segs_.size() && at < keep_bytes) at += seg_size_[(size_t) keep++];
+ if (keep >= mapped_segs_) return true;
+ if (cudaDeviceSynchronize() != cudaSuccess) {
+ err = std::string("the device failed before the cache shrank: ") + cudaGetErrorString(cudaGetLastError());
+ return false;
+ }
+ const CUdeviceptr va = reinterpret_cast(base_);
+ for (int64_t i = mapped_segs_ - 1; i >= keep; --i) {
+ const CUdeviceptr p = va + (CUdeviceptr) ((uint64_t) i * (uint64_t) seg_);
+ if (v.unmap(p, (size_t) seg_size_[(size_t) i]) != CUDA_SUCCESS ||
+ v.release((CUmemGenericAllocationHandle) segs_[(size_t) i]) != CUDA_SUCCESS) {
+ err = "the driver refused to release an expert-cache segment";
+ live_slots_ = slots_within(mapped_bytes());
+ return false;
+ }
+ segs_[(size_t) i] = 0;
+ mapped_segs_ = i;
+ }
+ live_slots_ = slots_within(mapped_bytes());
+ return true;
+#endif
+}
+
+bool ExpertCache::grow(int64_t want_bytes, std::string& err) {
+ if (segs_.empty()) {
+ err = "the expert cache is not segmented (the engine needs --vram-elastic)";
+ return false;
+ }
+#if defined(STRATA_EC_NO_VMM)
+ (void) want_bytes;
+ return false;
+#else
+ const Vmm& v = vmm();
+ int dev = 0;
+ cudaGetDevice(&dev);
+ const CUdeviceptr va = reinterpret_cast(base_);
+ int64_t at = mapped_bytes();
+ while (mapped_segs_ < (int64_t) segs_.size() && at + seg_size_[(size_t) mapped_segs_] <= want_bytes) {
+ const size_t i = (size_t) mapped_segs_;
+ if (!map_segment(v, dev, va + (CUdeviceptr) ((uint64_t) i * (uint64_t) seg_), (size_t) seg_size_[i],
+ segs_[i])) {
+ (void) cudaGetLastError();
+ err = "the driver has no VRAM for another expert-cache segment";
+ live_slots_ = slots_within(mapped_bytes());
+ return false;
+ }
+ at += seg_size_[i];
+ ++mapped_segs_;
+ }
+ live_slots_ = slots_within(mapped_bytes());
+ return true;
+#endif
+}
+
#if defined(STRATA_USE_HIP)
bool ExpertCache::ensure_blocking_staging(std::size_t bytes, std::string& err) {
if (bytes <= blocking_staging_bytes_) return true;
@@ -193,7 +435,9 @@ bool ExpertCache::open(int64_t n_slots, int64_t n_layers, int64_t n_expert, int6
}
}
- if (cudaMalloc((void**) &base_, (size_t) want) != cudaSuccess) {
+ if (seg_req_ > 0) { // #533: --vram-elastic: physical segments behind one address range (zeroed below)
+ if (!open_segmented(want, err)) return false;
+ } else if (cudaMalloc((void**) &base_, (size_t) want) != cudaSuccess) {
base_ = nullptr;
char buf[256];
std::snprintf(buf, sizeof buf, "ExpertCache: cudaMalloc(%.2f GiB) failed: %s",
@@ -211,6 +455,7 @@ bool ExpertCache::open(int64_t n_slots, int64_t n_layers, int64_t n_expert, int6
residency_.assign((size_t) (n_layers * n_expert), kNotResident);
slots_ = n_slots;
+ live_slots_ = n_slots;
n_layers_ = n_layers;
n_expert_ = n_expert;
blob_ = blob_bytes;
@@ -247,6 +492,7 @@ bool ExpertCache::open_sized(const std::vector& slot_bytes, int64_t n_l
// one allocation of the summed size, through the uniform path's checks: n "slots" of 1 byte
if (!open((int64_t) off.back(), n_layers, n_expert, 1, err)) return false;
slots_ = (int64_t) slot_bytes.size();
+ live_slots_ = slots_;
blob_ = mx;
off_ = std::move(off);
#if defined(STRATA_USE_HIP)
@@ -273,12 +519,15 @@ void ExpertCache::close() {
blocking_staging_bytes_ = 0;
#endif
off_.clear();
- if (base_ != nullptr) {
+ if (!segs_.empty()) {
+ release_segmented();
+ } else if (base_ != nullptr) {
cudaFree(base_);
base_ = nullptr;
}
residency_.clear();
slots_ = 0;
+ live_slots_ = 0;
n_layers_ = 0;
n_expert_ = 0;
blob_ = 0;
diff --git a/src/core/expert_source.cpp b/src/core/expert_source.cpp
index 56072fc5f6..ceef0285ff 100644
--- a/src/core/expert_source.cpp
+++ b/src/core/expert_source.cpp
@@ -10,6 +10,7 @@
#include "strata/kernels/elementwise.hpp"
#include "strata/kernels/quantize_act.hpp"
#include "strata/kernels/s2_expert_grouped.hpp"
+#include "strata/kernels/cpu/kq_avx1.hpp"
#include "strata/kernels/cpu/kq_avx2.hpp"
#include
@@ -217,18 +218,25 @@ bool read_cgroup_memory_stat(const std::filesystem::path& path, uint64_t current
}
#endif
-bool available_memory_bytes(uint64_t& bytes) {
+} // namespace
+
+namespace detail {
+
+bool host_available_memory(HostMemory& m, const std::string& meminfo, const std::string& self_cgroup,
+ const std::string& cgroup_root) {
+ m = HostMemory{};
#if defined(_WIN32)
+ (void) meminfo; (void) self_cgroup; (void) cgroup_root;
MEMORYSTATUSEX status{};
status.dwLength = sizeof(status);
if (!GlobalMemoryStatusEx(&status)) return false;
- bytes = (uint64_t) status.ullAvailPhys;
- return bytes > 0;
+ m.available = (uint64_t) status.ullAvailPhys;
+ return m.available > 0;
#elif defined(__linux__)
// MemAvailable includes reclaimable page cache, unlike _SC_AVPHYS_PAGES.
- std::ifstream info("/proc/meminfo");
+ std::ifstream info(meminfo);
std::string line;
- bytes = 0;
+ uint64_t bytes = 0;
while (std::getline(info, line)) {
std::istringstream fields(line);
std::string key, unit;
@@ -237,17 +245,26 @@ bool available_memory_bytes(uint64_t& bytes) {
value <= std::numeric_limits::max() / 1024) bytes = value * 1024;
}
if (bytes == 0) return false;
- // Account for the tightest cgroup-v2 ancestor limit when its normal mount is visible.
+ // Account for the tightest cgroup ancestor limit when its normal mount is visible.
// This is a point-in-time guard, not a reservation against concurrent allocations.
- std::ifstream groups("/proc/self/cgroup");
+ std::ifstream groups(self_cgroup);
if (!groups) return false;
- bool resolved_v2 = false;
+ const std::filesystem::path root(cgroup_root);
+ bool v2 = false;
+ std::string v1_path; // the memory controller's group (cgroup v1), when there is no v2 line
while (std::getline(groups, line)) {
- if (line.rfind("0::/", 0) != 0) continue;
- const std::filesystem::path root("/sys/fs/cgroup");
+ if (line.rfind("0::/", 0) != 0) {
+ // v1: "N:controller[,controller]:/path"
+ const size_t c1 = line.find(':'), c2 = c1 == std::string::npos ? c1 : line.find(':', c1 + 1);
+ if (c2 == std::string::npos) continue;
+ std::istringstream ctl(line.substr(c1 + 1, c2 - c1 - 1));
+ for (std::string c; std::getline(ctl, c, ',');)
+ if (c == "memory") v1_path = line.substr(c2 + 1);
+ continue;
+ }
auto path = (root / line.substr(4)).lexically_normal();
if (path.string().rfind(root.string(), 0) != 0 || !std::filesystem::is_directory(path)) return false;
- resolved_v2 = true;
+ v2 = true;
while (path.string().rfind(root.string(), 0) == 0) {
std::ifstream limit_file(path / "memory.max"), current_file(path / "memory.current");
std::string limit;
@@ -261,28 +278,59 @@ bool available_memory_bytes(uint64_t& bytes) {
size_t consumed = 0;
const uint64_t cap = std::stoull(limit, &consumed);
if (consumed != limit.size()) return false;
- detail::CgroupMemoryStat stat;
+ CgroupMemoryStat stat;
if (!read_cgroup_memory_stat(path, current, stat)) return false;
uint64_t cgroup_available = 0;
- if (!detail::cgroup_available_bytes(cap, stat, cgroup_available)) return false;
+ if (!cgroup_available_bytes(cap, stat, cgroup_available)) return false;
bytes = std::min(bytes, cgroup_available);
+ m.cgroup_limit = std::min(m.cgroup_limit, cap);
} catch (...) { return false; }
}
if (path == root) break;
path = path.parent_path();
}
}
- return resolved_v2;
+ if (!v2 && !v1_path.empty()) {
+ // #633: cgroup v1 (older Docker hosts, RHEL 7/8): the memory controller's group and its ancestors. An
+ // unlimited group says a number near 2^63 (rounded to its page size); a group whose files are not
+ // visible (no mount in this namespace) is skipped - MemAvailable alone, as with no cgroup at all.
+ const std::filesystem::path mroot = root / "memory";
+ auto path = (mroot / v1_path.substr(v1_path.rfind('/', 0) == 0 ? 1 : 0)).lexically_normal();
+ while (path.string().rfind(mroot.string(), 0) == 0) {
+ std::ifstream limit_file(path / "memory.limit_in_bytes"), usage_file(path / "memory.usage_in_bytes");
+ uint64_t cap = 0, usage = 0;
+ if (limit_file >> cap && usage_file >> usage && cap < (1ull << 62)) {
+ bytes = std::min(bytes, usage < cap ? cap - usage : 0);
+ m.cgroup_limit = std::min(m.cgroup_limit, cap);
+ }
+ if (path == mroot) break;
+ path = path.parent_path();
+ }
+ }
+ m.available = bytes;
+ return true;
#else
+ (void) meminfo; (void) self_cgroup; (void) cgroup_root;
const long pages = sysconf(_SC_AVPHYS_PAGES);
const long page_bytes = sysconf(_SC_PAGESIZE);
if (pages <= 0 || page_bytes <= 0 ||
(uint64_t) pages > std::numeric_limits::max() / (uint64_t) page_bytes) return false;
- bytes = (uint64_t) pages * (uint64_t) page_bytes;
- return bytes > 0;
+ m.available = (uint64_t) pages * (uint64_t) page_bytes;
+ return m.available > 0;
#endif
}
+} // namespace detail
+
+namespace {
+
+bool available_memory_bytes(uint64_t& bytes) {
+ detail::HostMemory m;
+ if (!detail::host_available_memory(m)) return false;
+ bytes = m.available;
+ return bytes > 0;
+}
+
} // namespace
// ================================ THE FILE-BACKED SOURCE ================================
@@ -872,13 +920,14 @@ void FileExpertSource::fill_many(const std::vector& todo) {
for (auto& t : th) t.join();
}
-bool FileExpertSource::set_unbuffered(uint64_t ram_bytes, std::string& why) {
+uint64_t FileExpertSource::expert_bytes() const {
+ uint64_t total = 0;
+ for (uint64_t b : layer_blob_bytes_) total += b * (uint64_t) n_expert_;
+ return total;
+}
+
+bool FileExpertSource::open_direct(std::string& why) {
#if defined(_WIN32)
- if (base_ == nullptr || paths_.empty() || !direct_.empty()) {
- why = !direct_.empty() ? "already unbuffered" : "no expert files open";
- return !direct_.empty();
- }
- if (!experts_unbuffered(paths_, ram_bytes, why, /*cache_counts=*/false)) return false;
for (const std::string& path : paths_) {
const int wide = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0);
std::vector w((size_t) (wide > 0 ? wide : 1), L'\0');
@@ -899,6 +948,24 @@ bool FileExpertSource::set_unbuffered(uint64_t ram_bytes, std::string& why) {
for (uint64_t b : layer_blob_bytes_) stage_blob_ = std::max(stage_blob_, b);
}
return true;
+#else
+ (void) why;
+ return false;
+#endif
+}
+
+bool FileExpertSource::set_unbuffered(uint64_t ram_bytes, std::string& why) {
+#if defined(_WIN32)
+ if (base_ == nullptr || paths_.empty() || !direct_.empty()) {
+ why = !direct_.empty() ? "already unbuffered" : "no expert files open";
+ return !direct_.empty();
+ }
+ // #577: the file tier reads the experts outside the RAM copy, not every byte of the shards (dense weights, the
+ // PLE table), and the copy holds at most every expert - the budget asked for can be more than that
+ const uint64_t experts = expert_bytes();
+ const uint64_t arena = std::min(ram_bytes, experts);
+ if (!experts_unbuffered(paths_, arena, why, /*cache_counts=*/false, experts - arena)) return false;
+ return open_direct(why);
#else
(void) ram_bytes;
why = "through the file cache (not Windows)";
@@ -906,6 +973,39 @@ bool FileExpertSource::set_unbuffered(uint64_t ram_bytes, std::string& why) {
#endif
}
+bool FileExpertSource::recheck_unbuffered(std::string& why) {
+#if defined(_WIN32)
+ if (const char* env = std::getenv("STRATA_UNBUFFERED_LOAD"); env != nullptr && env[0] != '\0') {
+ why = std::string("STRATA_UNBUFFERED_LOAD=") + env;
+ return !direct_.empty();
+ }
+ if (base_ == nullptr || paths_.empty()) {
+ why = "no expert files open";
+ return false;
+ }
+ // #577: the RAM copy is built (and already out of the available RAM), so what the file cache would have to keep
+ // is exactly the experts outside it - the GPU cache's (refilled after a prompt borrowed their slots) and the
+ // ones neither holds
+ const uint64_t experts = expert_bytes();
+ const uint64_t read = experts > complement_bytes_ ? experts - complement_bytes_ : 0;
+ std::string w;
+ const bool ub = experts_unbuffered(paths_, 0, w, /*cache_counts=*/false, read);
+ char head[96];
+ std::snprintf(head, sizeof head, "re-checked with the RAM copy built (%.2f GiB): ",
+ (double) complement_bytes_ / 1073741824.0);
+ why = head + w;
+ if (ub == !direct_.empty()) return ub;
+ if (ub) return open_direct(why);
+ // through the file cache after all: the mapped reads take over (staged() stays true for the GGUF in place)
+ for (void* d : direct_) CloseHandle((HANDLE) d);
+ direct_.clear();
+ return false;
+#else
+ why = "through the file cache (not Windows)";
+ return false;
+#endif
+}
+
bool FileExpertSource::read_direct(const Fill* fills, size_t n) const {
#if defined(_WIN32)
// NTFS runs the unbuffered reads of a file one at a time while the file is mapped or cached anywhere (the engine
@@ -1120,8 +1220,35 @@ void RouterLookahead::run() {
}
const auto t0 = std::chrono::steady_clock::now();
want.clear();
- strata::kernels::cpu::bf16_rows_dot_multi(routers_[(size_t) layer].data(), (int) n_expert_, (int) n_embd_,
- x_.data(), (int) nt, logits.data());
+ // The router dot is AVX2 (kq_avx2.cpp); a CPU without AVX2 (the experimental older-CPU builds) takes the AVX1
+ // one (kq_avx1.cpp) or, without AVX, the same sums in plain C++. From the Strata_Dirigo fork (rwkeyes): an
+ // AVX-only Xeon E5-2687W died here (vpmovzxwd) on its first request. An estimate only (which experts to
+ // prefetch); the order of the additions differs between the three, the output does not depend on it.
+ if (strata::kernels::cpu::cpu_avx2_ok()) {
+ strata::kernels::cpu::bf16_rows_dot_multi(routers_[(size_t) layer].data(), (int) n_expert_, (int) n_embd_,
+ x_.data(), (int) nt, logits.data());
+ } else if (strata::kernels::cpu::cpu_avx1_ok()) {
+ strata::kernels::cpu::bf16_rows_dot_multi_avx1(routers_[(size_t) layer].data(), (int) n_expert_,
+ (int) n_embd_, x_.data(), (int) nt, logits.data());
+ } else {
+ // a mul and an add, not std::fma: without an FMA instruction that is a libm call per element (74x
+ // slower on a Xeon E5-2665, measured by the fork)
+ const uint16_t* rw = routers_[(size_t) layer].data();
+ for (int64_t r = 0; r < n_expert_; ++r) {
+ const uint16_t* wr = rw + (size_t) r * (size_t) n_embd_;
+ for (int64_t t = 0; t < nt; ++t) {
+ const float* xr = x_.data() + (size_t) t * (size_t) n_embd_;
+ float acc = 0.0f;
+ for (int64_t c = 0; c < n_embd_; ++c) {
+ const uint32_t bits = (uint32_t) wr[c] << 16;
+ float wf;
+ std::memcpy(&wf, &bits, sizeof wf);
+ acc += wf * xr[c];
+ }
+ logits[(size_t) t * (size_t) n_expert_ + (size_t) r] = acc;
+ }
+ }
+ }
for (int64_t t = 0; t < nt; ++t) {
const float* lt = logits.data() + (size_t) (t * n_expert_);
for (int64_t e = 0; e < n_expert_; ++e) order[(size_t) e] = (int32_t) e;
@@ -1883,17 +2010,9 @@ void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32
pt("begin");
d.src->begin_layer(d.layers, ids, n_tok * k);
pt("begun");
- if (!d.usage.empty()) {
- const uint64_t trace_now = d.first_miss_ns.empty() ? 0 : (uint64_t) std::chrono::duration_cast(
- std::chrono::steady_clock::now().time_since_epoch()).count();
- for (int64_t i = 0; i < n_tok * k; ++i) {
- if (ids[i] < 0 || ids[i] >= d.n_expert) continue;
- const size_t ri = (size_t) d.layers * (size_t) d.n_expert + (size_t) ids[i];
- if (trace_now && d.host_res != nullptr && d.host_res[ri] < 0 && d.first_miss_ns[ri] == 0)
- d.first_miss_ns[ri] = trace_now;
- d.usage[ri] += 1.0f;
- }
- }
+ if (!d.usage.empty())
+ for (int64_t i = 0; i < n_tok * k; ++i)
+ if (ids[i] >= 0 && ids[i] < d.n_expert) d.usage[(size_t) d.layers * (size_t) d.n_expert + (size_t) ids[i]] += 1.0f;
// ---- plan v0.3 P6: the GPU's share, decided and published FIRST so the GPU starts while the CPU works.
// Distinct experts in routing order; resident ones and the last pcie_num/256 of the missed ones go to the GPU.
const int64_t n = n_tok * k;
@@ -1924,22 +2043,9 @@ void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32
int kd = -1;
unsigned long long ptr = 0;
if (e >= 0 && e < d.n_expert) {
- const size_t ri = (size_t) d.layers * (size_t) d.n_expert + (size_t) e;
- const int32_t slot = d.host_res[ri];
+ const int32_t slot = d.host_res[(size_t) d.layers * (size_t) d.n_expert + (size_t) e];
if (slot >= 0) {
kd = 0;
- if (!d.first_resident_ns.empty() && d.first_resident_ns[ri] != 0 && !d.traced_hit_reported[ri]) {
- const uint64_t now = (uint64_t) std::chrono::duration_cast(
- std::chrono::steady_clock::now().time_since_epoch()).count();
- const uint64_t miss = d.first_miss_ns[ri];
- std::fprintf(stderr,
- "ADAPT_TRACE gpu_hit layer=%lld expert=%d slot=%d resident_to_hit_us=%.3f miss_to_hit_us=%.3f\n",
- (long long) d.layers, (int) e, (int) slot,
- (double) (now - d.first_resident_ns[ri]) / 1000.0,
- miss ? (double) (now - miss) / 1000.0 : -1.0);
- std::fflush(stderr);
- d.traced_hit_reported[ri] = 1;
- }
ptr = (unsigned long long) (d.cache_base + (d.cache_slot_off ? (size_t) d.cache_slot_off[slot]
: (size_t) slot * (size_t) d.cache_blob));
} else if (d.peer != nullptr && d.peer->has(d.layers, e)) {
@@ -2021,16 +2127,30 @@ void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32
for (int64_t i = 0; i < n; ++i) if (d.remote[r]->owns(i)) kind[i] = 2;
}
}
+ static const bool dec_batch = [] { const char* v = std::getenv("STRATA_DEC_BATCH"); return v == nullptr || std::atoi(v) != 0; }();
+ // the GPU's copy_rows_from_mapped zeroes the rows it computed itself (dec_batch); STRATA_VERIFY_DEVICE_PLAN's
+ // copy_or_zero_from_mapped copies every row, so there the host still zeroes them
+ static const bool device_plan = [] { const char* v = std::getenv("STRATA_VERIFY_DEVICE_PLAN"); return v != nullptr && std::atoi(v) != 0; }();
+ const bool gpu_zeroes_hits = (d.plan != nullptr && n <= kMaxWindowEntries && n <= d.plan->cap && dec_batch && !device_plan);
+ bool any_cpu = false;
+ for (int64_t i = 0; i < n; ++i)
+ if (kind[i] < 0) { any_cpu = true; break; }
const auto c1 = std::chrono::steady_clock::now();
- if (native && lay.fmt[(size_t) d.layers].gu_type == 42) // a native Q2_0 pack: the Q2_0 kernels' activations
- for (int64_t t = 0; t < n_tok; ++t) act_quant_any(x_f + (size_t) t * H, H, d.act_multi[(size_t) t]);
- else if (native)
- for (int64_t t = 0; t < n_tok; ++t)
- native_quant_act(lay.fmt[(size_t) d.layers], x_f + (size_t) t * H, d.nact_multi.data() + (size_t) t * kNativeActBytes);
- else
- for (int64_t t = 0; t < n_tok; ++t) act_quant_q8_1(x_f + (size_t) t * H, H, d.act_multi[(size_t) t]);
+ if (any_cpu) {
+ // #578 --remote-expert-opt: a token whose experts all run on a GPU (CUDA0 or a helper) needs no CPU activation
+ const bool ep = d.remote_count > 0 && d.remote[0]->optimized_decode();
+ for (int64_t t = 0; t < n_tok; ++t) {
+ if (ep && std::all_of(kind + t * k, kind + (t + 1) * k, [](int32_t v) { return v >= 0; })) continue;
+ if (native && strata::kernels::cpu::q2_native_kernels(lay.fmt[(size_t) d.layers].gu_type)) // a native Q2_0 pack: the Q2_0 kernels' activations
+ act_quant_any(x_f + (size_t) t * H, H, d.act_multi[(size_t) t]);
+ else if (native)
+ native_quant_act(lay.fmt[(size_t) d.layers], x_f + (size_t) t * H, d.nact_multi.data() + (size_t) t * kNativeActBytes);
+ else
+ act_quant_q8_1(x_f + (size_t) t * H, H, d.act_multi[(size_t) t]);
+ }
+ }
const auto c2 = std::chrono::steady_clock::now();
- { // CS-T: the experts the CPU computes, fetched together (the GGUF in place reads them on several threads)
+ if (any_cpu) { // CS-T: the experts the CPU computes, fetched together (the GGUF in place reads them on several threads)
static thread_local std::vector miss;
miss.clear();
for (int64_t i = 0; i < n_tok * k; ++i)
@@ -2054,9 +2174,12 @@ void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32
}
if (kind[i] >= 0) { // CUDA0, PCIe, or a remote/peer result staged into this row below
if (kind[i] == 0) ++d.cache_hits;
- else if (kind[i] == 2 && d.peer != nullptr) ++d.peer_entries;
- // multi-GPU: a direct peer launch is writing this row right now - zeroing it would race it
- if (!(kind[i] == 2 && d.peer != nullptr && d.peer->launched_direct()))
+ else ++d.offload_entries; // #588: PCIe or another GPU
+ if (kind[i] == 2 && d.peer != nullptr) ++d.peer_entries;
+ // multi-GPU: a direct peer launch is writing this row right now - zeroing it would race it;
+ // dec_batch: copy_rows_from_mapped_kernel already zeroes kind 0/1 rows on the GPU
+ if (!((kind[i] == 2 && d.peer != nullptr && d.peer->launched_direct()) ||
+ (gpu_zeroes_hits && (kind[i] == 0 || kind[i] == 1))))
std::memset(row, 0, (size_t) H * sizeof(float));
continue;
}
@@ -2086,8 +2209,10 @@ void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32
}
const auto c3 = std::chrono::steady_clock::now();
pt("run", njobs);
- if (native) d.pool->run_split_multi_native(lay.fmt[(size_t) d.layers], d.jobs_multi.data(), njobs);
- else d.pool->run_split_multi(d.jobs_multi.data(), njobs);
+ if (njobs > 0) {
+ if (native) d.pool->run_split_multi_native(lay.fmt[(size_t) d.layers], d.jobs_multi.data(), njobs);
+ else d.pool->run_split_multi(d.jobs_multi.data(), njobs);
+ }
if (d.remote_count > 0) {
static thread_local std::string remote_error;
for (int r = 0; r < d.remote_count; ++r)
@@ -2591,7 +2716,7 @@ bool ArenaExpertSource::open(const std::string& pack_dir, int64_t n_layers, int6
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) { err = "ArenaExpertSource: cannot open " + path; return false; }
const uint64_t got = (uint64_t) f.tellg();
- if (got != want) {
+ if (got != want && got != want + (uint64_t) blob) { // + one blob: STRATA_ARENA_MMAP's padded file
char buf[400];
std::snprintf(buf, sizeof buf,
"ArenaExpertSource: %s is %llu B but %lld layers x %lld experts (blobs up to %lld B) "
@@ -2603,6 +2728,76 @@ bool ArenaExpertSource::open(const std::string& pack_dir, int64_t n_layers, int6
}
}
+ // STRATA_ARENA_MMAP=1 (a small-RAM machine whose GPUs hold most experts): the arena is the pack's experts.bin
+ // mapped READ-ONLY - not locked, not pinned. The page cache keeps what the CPU pool and the prompt path
+ // actually read (the experts no VRAM cache holds) and gives back the rest under memory pressure; a page
+ // dropped is re-read from the file, so a result never depends on what is resident. The GPUs then get no
+ // mapped alias: run with --pcie-frac 0. The first start writes experts.bin (arena layout, padded by one blob
+ // so a whole-slot copy may start at any expert), later ones map it.
+#if defined(_WIN32)
+ static const bool arena_mmap = false; // POSIX mmap/madvise: Linux only for now
+#else
+ static const bool arena_mmap = [] { const char* v = std::getenv("STRATA_ARENA_MMAP"); return v && v[0] == '1'; }();
+ if (arena_mmap) {
+ const uint64_t file_bytes = want + (uint64_t) blob;
+ uint64_t have = 0;
+ {
+ std::ifstream f(path, std::ios::binary | std::ios::ate);
+ if (f) have = (uint64_t) f.tellg();
+ }
+ if (have == file_bytes) {
+ const int fd = ::open(path.c_str(), O_RDONLY);
+ void* v = fd >= 0 ? mmap(nullptr, (size_t) file_bytes, PROT_READ, MAP_SHARED, fd, 0) : MAP_FAILED;
+ if (fd >= 0) ::close(fd);
+ if (v == MAP_FAILED) { err = "ArenaExpertSource: mmap of " + path + " failed"; return false; }
+ map_ = v;
+ map_bytes_ = file_bytes;
+ base_ = (const uint8_t*) v;
+ pinned_bytes_ = 0;
+ dev_slice_.clear();
+ slice_bytes_ = 0;
+ blobs_ = n_layers * n_expert;
+ n_expert_ = n_expert;
+ reads_ = 0;
+ note_ = "mapped read-only from " + path + " (STRATA_ARENA_MMAP: not locked, not pinned)";
+ gib_per_s_ = 0.0;
+ load_seconds_ = load_read_s_ = load_copy_s_ = 0.0;
+ return true;
+ }
+ }
+#endif
+
+ // #633: THE RAM BEFORE THE ALLOCATION. On Linux the arena is an anonymous mapping that succeeds whatever the host
+ // has; its pages are committed as the load writes them, so a container whose memory limit is below the arena was
+ // killed by the OOM killer part-way through the load, without a message. A hard cgroup limit below it is
+ // certain to end that way: refused, with the numbers. Less RAM available than the arena (another program, an
+ // engine still exiting) is only a warning - the OS may make room - per the recommend-not-force rule.
+ ram_warning_.clear();
+ // (a shared arena, --shared-expert-arena, may already be in RAM for another engine: not checked)
+ if (detail::HostMemory hm; shared_arena_file.empty() && detail::host_available_memory(hm)) {
+ const uint64_t need = want + (uint64_t) blob;
+ const double gib = 1073741824.0;
+ char buf[512];
+ if (hm.cgroup_limit < need) {
+ std::snprintf(buf, sizeof buf,
+ "ArenaExpertSource: the expert arena needs %.2f GiB of RAM but this process's memory limit "
+ "(cgroup memory.max / memory.limit_in_bytes) is %.2f GiB: it would be killed while loading. "
+ "Raise the container's limit, or run with less RAM: --mmap-experts with "
+ "--resident-budget-gib N keeps only the hottest experts in RAM",
+ (double) need / gib, (double) hm.cgroup_limit / gib);
+ err = buf;
+ return false;
+ }
+ if (hm.available < need) {
+ std::snprintf(buf, sizeof buf,
+ "the expert arena needs %.2f GiB of RAM but %.2f GiB is available (%.2f GiB short): the load "
+ "may swap or be stopped by the OS. Close other programs (or wait for an engine that is "
+ "still exiting), or run with less RAM: --mmap-experts with --resident-budget-gib N",
+ (double) need / gib, (double) hm.available / gib, (double) (need - hm.available) / gib);
+ ram_warning_ = buf;
+ }
+ }
+
uint64_t pack_hash = 0;
if (!shared_arena_file.empty() &&
!shared_arena_pack_hash(pack_dir, path, gguf_, lay, pack_hash, err)) return false;
@@ -2643,7 +2838,7 @@ bool ArenaExpertSource::open(const std::string& pack_dir, int64_t n_layers, int6
return false;
}
// #285: unbuffered when the drive is read anyway and the file cache could not keep the experts for the next
- // start either (a 64 GB PC); otherwise the buffered readers, which a warm restart serves from the cache.
+ // start either (a 64 GB PC); otherwise the buffered readers, which a warm restart serves from the cache
std::vector files;
if (from_gguf) {
for (int64_t l = 0; l < n_layers; ++l)
@@ -2656,20 +2851,19 @@ bool ArenaExpertSource::open(const std::string& pack_dir, int64_t n_layers, int6
}
std::string why;
const bool unbuffered = experts_unbuffered(files, want + (uint64_t) blob, why);
- const int readers = unbuffered ? std::max(threads, 16) : threads;
+ const int readers = unbuffered ? std::max(threads, 16) : threads; // 16 keep a PCIe 5 drive's queue full
if (from_gguf) {
st = load_experts_gguf(gguf_, a->data(), lay, readers, unbuffered);
} else {
if (unbuffered) st = load_experts_direct(path, a->data(), loff, lbytes, readers, /*chunk=*/8u << 20);
- if (!unbuffered || (!st.ok && st.error.empty()))
+ if (!unbuffered || (!st.ok && st.error.empty())) // unaligned ranges: the buffered reader
st = load_experts_ranges(path, a->data(), loff, lbytes, threads, /*chunk=*/8u << 20);
}
- std::fprintf(stderr, "strata generate: expert arena read %s (%s)\n",
- unbuffered ? "unbuffered" : "through the file cache", why.c_str());
+ std::fprintf(stderr, "strata generate: expert arena read %s (%s)\n", unbuffered ? "unbuffered" : "through the file cache",
+ why.c_str());
if (!st.ok) {
delete a;
- err = "ArenaExpertSource: the expert load was refused: " +
- (st.error.empty() ? std::string("unknown") : st.error);
+ err = "ArenaExpertSource: the expert load was refused: " + (st.error.empty() ? std::string("unknown") : st.error);
return false;
}
if (st.bytes != want) {
@@ -2677,6 +2871,31 @@ bool ArenaExpertSource::open(const std::string& pack_dir, int64_t n_layers, int6
err = "ArenaExpertSource: the load read " + std::to_string(st.bytes) + " B of " + std::to_string(want);
return false;
}
+ // the first start: write experts.bin for the mapped starts after this one - only when the drive has room for it
+ // and 2 GiB more (a full drive fails other writes too); otherwise this start says so and runs pinned as before
+ std::error_code space_ec;
+ const uint64_t free_disk = arena_mmap && from_gguf ? (uint64_t) std::filesystem::space(
+ std::filesystem::path(path).parent_path(), space_ec).available : 0;
+ const bool room = !space_ec && free_disk >= want + (uint64_t) blob + (2ull << 30);
+ if (arena_mmap && from_gguf && !room)
+ std::fprintf(stderr, "strata generate: STRATA_ARENA_MMAP: NOT writing %s - %.1f GiB free on that drive, it needs "
+ "%.1f GiB plus 2 GiB to spare; this start keeps the arena in RAM\n", path.c_str(),
+ (double) free_disk / 1073741824.0, (double) (want + (uint64_t) blob) / 1073741824.0);
+ if (arena_mmap && from_gguf && room) {
+ const std::string tmp = path + ".tmp";
+ std::FILE* f = std::fopen(tmp.c_str(), "wb");
+ bool ok = f != nullptr && std::fwrite(a->data(), 1, (size_t) want, f) == (size_t) want;
+ if (ok) {
+ std::vector pad((size_t) blob, 0);
+ ok = std::fwrite(pad.data(), 1, pad.size(), f) == pad.size();
+ }
+ if (f) ok = std::fclose(f) == 0 && ok;
+ if (ok) ok = std::rename(tmp.c_str(), path.c_str()) == 0;
+ if (!ok) std::remove(tmp.c_str());
+ std::fprintf(stderr, "strata generate: STRATA_ARENA_MMAP: %s %s (the next start maps it)\n",
+ ok ? "wrote" : "could NOT write", path.c_str());
+ }
+
if (!shared_arena_file.empty() && !a->publish_shared_population()) {
delete a;
err = "ArenaExpertSource: could not publish shared arena population readiness";
@@ -2715,6 +2934,11 @@ bool ArenaExpertSource::open(const std::string& pack_dir, int64_t n_layers, int6
}
void ArenaExpertSource::close() {
+#if !defined(_WIN32)
+ if (map_ != nullptr) munmap(map_, (size_t) map_bytes_);
+#endif
+ map_ = nullptr;
+ map_bytes_ = 0;
if (arena_ != nullptr) {
delete (PinnedArena*) arena_;
arena_ = nullptr;
@@ -2725,6 +2949,39 @@ void ArenaExpertSource::close() {
reused_shared_population_ = false;
}
+void ArenaExpertSource::prefetch(int64_t layer, int64_t expert) {
+#if defined(_WIN32)
+ (void) layer; (void) expert;
+#else
+ if (map_ == nullptr || layer < 0 || expert < 0 || expert >= n_expert_) return;
+ const auto& lay = strata::kernels::cpu::expert_layout();
+ const uint64_t off = lay.blob_offset(layer, expert) & ~(uint64_t) 4095;
+ const uint64_t end = lay.blob_offset(layer, expert) + lay.blob_bytes(layer);
+ madvise((uint8_t*) map_ + off, (size_t) (end - off), MADV_WILLNEED);
+#endif
+}
+
+// Only the pages wholly inside the blob: a page shared with a neighbour the CPU may still read stays. MADV_DONTNEED
+// unmaps them from this process; they stay in the page cache as clean, unmapped pages - the first the kernel takes
+// back under pressure, and a minor fault away when the CPU needs the expert again (an adaptive swap evicts it).
+// Dropping them from the cache too (POSIX_FADV_DONTNEED) made every eviction a re-read from the disk: ~200 major
+// faults a second in decode, +8 ms a window. The memory the arena used to hold was never the cache itself but
+// ROCclr's pin-in-place locks on it (see main) - locked pages are the ones the kernel cannot take back.
+uint64_t ArenaExpertSource::release(int64_t layer, int64_t expert) {
+#if defined(_WIN32)
+ (void) layer; (void) expert;
+ return 0;
+#else
+ if (map_ == nullptr || layer < 0 || expert < 0 || expert >= n_expert_) return 0;
+ const auto& lay = strata::kernels::cpu::expert_layout();
+ const uint64_t off = (lay.blob_offset(layer, expert) + 4095) & ~(uint64_t) 4095;
+ const uint64_t end = (lay.blob_offset(layer, expert) + lay.blob_bytes(layer)) & ~(uint64_t) 4095;
+ if (end <= off) return 0;
+ if (madvise((uint8_t*) map_ + off, (size_t) (end - off), MADV_DONTNEED) != 0) return 0;
+ return end - off;
+#endif
+}
+
bool ArenaExpertSource::pinned(int64_t layer, int64_t expert) const {
if (base_ == nullptr || layer < 0 || expert < 0 || expert >= n_expert_) return false;
const auto& lay = strata::kernels::cpu::expert_layout();
diff --git a/src/core/layer.cpp b/src/core/layer.cpp
index ff7fc17b58..33450f7d2f 100644
--- a/src/core/layer.cpp
+++ b/src/core/layer.cpp
@@ -387,6 +387,34 @@ if (db != nullptr && g_publish_kernel) {
// write idempotent across replays of the same graph - a captured literal would ring the same number
// forever and the host would never see a change.
strata::kernels::doorbell_ring(db->d_seq, stream); } return true;}
+// The verify window's routing: n tokens' logits in one BF16 projection and their top-k in one launch, each token
+// bitwise what `moe_route` gives it (the projection is the same kernel per column, the top-k kernel is per token).
+// Anything else (a native router, the canonical BF16 GEMVs, a doorbell) takes the per-token `moe_route`.
+bool moe_route_window(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t k, const MoEBuffers& b,
+ const float* x, float* logits, int32_t* ids, float* weights, int n, void* stream, std::string& err) {
+ using namespace strata::kernels;
+ const bool batched = native_bf16_projections && !(native_router_enabled() && g.n_expert == 512 && k == 10) &&
+ !std::getenv("STRATA_ROUTE_PER_TOKEN");
+ if (!batched || n == 1) {
+ for (int t = 0; t < n; ++t) {
+ MoEBuffers mb = b;
+ mb.logits = logits + t * g.n_expert; mb.ids = ids + t * k; mb.weights = weights + t * k;
+ if (!moe_route(tables, g, layer, k, mb, x + t * g.n_embd, stream, err, nullptr)) return false;
+ }
+ return true;
+ }
+ const LayerView v(tables, layer);
+ const WeightRef* w_router = v.get("ffn_gate_inp.weight");
+ if (w_router == nullptr) { err = v.name("ffn_gate_inp.weight") + " is missing"; return false; }
+ if (std::getenv("STRATA_ROUTE_PROJ_PER_TOKEN"))
+ for (int t = 0; t < n; ++t)
+ bf16_gemv_fp32_mmvf(x + t * g.n_embd, (const uint16_t*) w_router->data, logits + t * g.n_expert, g.n_embd,
+ g.n_expert, stream);
+ else
+ bf16_gemv_fp32_mmvf_cols(x, (const uint16_t*) w_router->data, logits, g.n_embd, g.n_expert, n, stream);
+ router_top10(logits, n, (int) g.n_expert, (int) k, ids, weights, stream);
+ return true;
+}
bool moe_shared(const WeightTable& tables, const ModelGeometry& g, int64_t layer, const MoEBuffers& b, const float* x, void* stream, std::string& err) { using namespace strata::kernels; const LayerView v(tables, layer); const WeightRef* w_ginp = v.get("ffn_gate_inp_shexp.weight"); const WeightRef* w_sgate = v.get("ffn_gate_shexp.weight"); const WeightRef* w_sup = v.get("ffn_up_shexp.weight"); const WeightRef* w_sdown = v.get("ffn_down_shexp.weight"); const char* missing = !w_ginp ? "ffn_gate_inp_shexp.weight" : !w_sgate ? "ffn_gate_shexp.weight" : !w_sup ? "ffn_up_shexp.weight" : !w_sdown ? "ffn_down_shexp.weight" : nullptr; if (missing) { err = v.name(missing) + " is missing"; return false; }
// ---- the shared expert. Its three weights are quantized, so they need their planes.
SForm f_gate, f_up, f_down; if (!sform_of(*w_sgate, f_gate, v.name("ffn_gate_shexp.weight"), err)) return false; if (!sform_of(*w_sup, f_up, v.name("ffn_up_shexp.weight"), err)) return false; if (!sform_of(*w_sdown, f_down, v.name("ffn_down_shexp.weight"), err)) return false; Planes p_gate, p_up, p_down; if (!plane_ptrs(*w_sgate, v.name("ffn_gate_shexp.weight"), p_gate, err)) return false; if (!plane_ptrs(*w_sup, v.name("ffn_up_shexp.weight"), p_up, err)) return false; if (!plane_ptrs(*w_sdown, v.name("ffn_down_shexp.weight"), p_down, err)) return false;
diff --git a/src/core/mtp.cpp b/src/core/mtp.cpp
index a53cc60a41..e9953e3002 100644
--- a/src/core/mtp.cpp
+++ b/src/core/mtp.cpp
@@ -26,6 +26,7 @@
#include "strata/kernels/sampler.hpp"
#include "strata/kernels/shared_expert.hpp"
#include "strata/kernels/verify_kernels.hpp"
+#include "strata/kernels/ngram.hpp"
#include
#include
@@ -37,6 +38,11 @@
#include
#include
#include
+#if defined(_WIN32)
+#include
+#elif defined(__x86_64__)
+#include
+#endif
namespace strata::core {
namespace {
@@ -146,6 +152,7 @@ bool MtpDrafter::load(const std::string& rt_dir, const ModelGeometry& g, Session
cudaGetDevice(&device_); // a layer split's last stage on another GPU: the drafter lives there
g_ = &g;
ss_ = &ss;
+ if (ple_ss_ == nullptr) ple_ss_ = &ss;
max_t_ = max_t;
rt_dir_ = rt_dir;
if (max_t < 1 || max_t > strata::kernels::kVerifyMaxT) { err = "mtp: max_t out of range"; return false; }
@@ -580,8 +587,13 @@ bool MtpDrafter::record_forward(int T, int step_row0, cudaStream_t cs, std::stri
nsw.down_type = GGML_Q8_0; nsw.down_data = q8("mlp.shared_expert.down_proj.weight");
nsw.q8_1 = xq_;
const SForm none{};
+ const bool need_bf16_x = !shared_expert_native_bf16_enabled();
+ static const bool fuse_head_gr = [] {
+ const char* v = std::getenv("STRATA_FUSE_HEAD_GR");
+ return v != nullptr && std::atoi(v) != 0;
+ }();
for (int t = 0; t < T; ++t) {
- f32_to_bf16_bulk(mixed_ + t * N, x_bf16_, N, cs);
+ if (need_bf16_x) f32_to_bf16_bulk(mixed_ + t * N, x_bf16_, N, cs);
shared_expert(nullptr, nullptr, x_bf16_, none, nullptr, nullptr, nullptr, none, nullptr, nullptr, nullptr, none,
nullptr, nullptr, nullptr, bf16("mlp.shared_expert_gate.weight"), sh_scratch_, shared_ + t * N,
N, g.n_ff, 32, cs, mixed_ + t * N, &nsw);
@@ -589,14 +601,30 @@ bool MtpDrafter::record_forward(int T, int step_row0, cudaStream_t cs, std::stri
native_moe_combine(parts_ + (size_t) t * K * N, w_ + t * K, shared_ + t * N, y_ + t * N, N, K, cs);
else
moe_combine(parts_ + (size_t) t * K * N, w_ + t * K, shared_ + t * N, y_ + t * N, N, K, cs);
- gr_write(R_ + (size_t) t * HC * N, y_ + t * N, inj2_ + t * HC, gs, R_ + (size_t) t * HC * N, cs);
+ if (!fuse_head_gr)
+ gr_write(R_ + (size_t) t * HC * N, y_ + t * N, inj2_ + t * HC, gs, R_ + (size_t) t * HC * N, cs);
}
// ---- the final mixer and the main model's head
- for (int t = 0; t < T; ++t)
- gr_read(R_ + (size_t) t * HC * N, f32("hyper_connection_mixer.hc_norm.weight"),
- bf16("hyper_connection_mixer.input_mix_weight_down.weight"),
- bf16("hyper_connection_mixer.input_mix_weight_up.weight"), nullptr, EPS, gs, ss.block.gr,
- sample_ + t * N, dummy_inj_, cs);
+ if (fuse_head_gr) {
+ FusedGrArgs fa[kFusedGrMaxT];
+ for (int t = 0; t < T; ++t) {
+ fa[t].R = R_ + (size_t) t * HC * N; fa[t].R_out = R_ + (size_t) t * HC * N; fa[t].apply = true;
+ fa[t].bo_prev = y_ + t * N; fa[t].inj_prev = inj2_ + t * HC;
+ fa[t].w_norm = f32("hyper_connection_mixer.hc_norm.weight");
+ fa[t].w_down = bf16("hyper_connection_mixer.input_mix_weight_down.weight");
+ fa[t].w_up = bf16("hyper_connection_mixer.input_mix_weight_up.weight");
+ fa[t].w_inject = nullptr;
+ fa[t].eps = EPS; fa[t].lo = lo_ + t * g.hc_lr; fa[t].rs = rs_ + t * HC;
+ fa[t].inject_out = dummy_inj_; fa[t].mixed = sample_ + t * N;
+ }
+ fused_gr_read_multi(fa, T, xn_, cs);
+ } else {
+ for (int t = 0; t < T; ++t)
+ gr_read(R_ + (size_t) t * HC * N, f32("hyper_connection_mixer.hc_norm.weight"),
+ bf16("hyper_connection_mixer.input_mix_weight_down.weight"),
+ bf16("hyper_connection_mixer.input_mix_weight_up.weight"), nullptr, EPS, gs, ss.block.gr,
+ sample_ + t * N, dummy_inj_, cs);
+ }
native_quantize_q8_1(sample_, xq_, (int) N, T, cs);
const bool sub = dhead_ != nullptr;
const int64_t nv = sub ? n_dvocab_ : n_vocab_;
@@ -829,6 +857,9 @@ bool MtpDrafter::draft(int T, const int32_t* tokens, int64_t p, int a, int32_t*
if (T < 1 || T > max_t_ || a < 0 || a >= T) { err = "mtp: draft arguments out of range"; return false; }
const bool cp = coupled_active_; // coupled draft sampling for this request: its own graphs
if (!capture_round(T, cp, err)) return false;
+ const int max_steps = std::min(max_t_ - 1, max_drafts_);
+ for (int j = 1; j < max_steps; ++j)
+ if (!capture_step(j, cp, err)) return false;
const Clock::time_point t0 = Clock::now();
const int64_t NH = g_->n_head;
auto put = [&](int row, int64_t cell) {
@@ -843,32 +874,99 @@ bool MtpDrafter::draft(int T, const int32_t* tokens, int64_t p, int a, int32_t*
put(t, p + t);
}
put(2 * max_t_ - 1, coupled_draft_cell(p, a, 0)); // p + a: draft 0's cell
+ for (int j = 1; j < max_steps; ++j)
+ put(max_t_ + j - 1, coupled_draft_cell(p, a, j));
+ for (int j = 0; j < max_steps; ++j) ((volatile int32_t*) h_out_)[j] = -1;
h_row_[0] = a;
h_row_[1] = 0;
std::atomic_thread_fence(std::memory_order_seq_cst);
- if (cudaGraphLaunch(cp ? round_exec_c_[T] : round_exec_[T], cs_) != cudaSuccess ||
- cudaStreamSynchronize(cs_) != cudaSuccess) {
- err = std::string("mtp draft: ") + cudaGetErrorString(cudaGetLastError());
- return false;
- }
- drafts[0] = ((volatile int32_t*) h_out_)[0];
- float pj = ((volatile float*) h_prob_)[0];
- if (probs) probs[0] = pj;
- int n = 1;
- // the chain continues while the last draft is likely enough to be verified
- for (int j = 1; j < std::min(max_t_ - 1, max_drafts_) && pj >= min_p; ++j) {
- if (!capture_step(j, cp, err)) return false;
- put(max_t_ + j - 1, coupled_draft_cell(p, a, j)); // p + a + j; coupled: drawn at counter cell + 1
- std::atomic_thread_fence(std::memory_order_seq_cst);
- if (cudaGraphLaunch(cp ? step_exec_c_[j] : step_exec_[j], cs_) != cudaSuccess ||
- cudaStreamSynchronize(cs_) != cudaSuccess) {
- err = std::string("mtp draft step: ") + cudaGetErrorString(cudaGetLastError());
+
+ SessionState* const pss = ple_ss_ ? ple_ss_ : ss_;
+ const bool do_ple = pss != nullptr && pss->ple.ready() && pss->ple.table != nullptr;
+ int32_t ple_prev[2] = {do_ple ? pss->ple_prev[0] : 0, do_ple ? pss->ple_prev[1] : 0};
+ auto prefetch_ple = [&](int32_t tok) {
+ if (!do_ple || tok < 0) return;
+ uint32_t rows16[strata::kernels::PLE_N_HEADS];
+ strata::kernels::ngram_rows(&tok, ple_prev, 1, pss->ple.consts, rows16);
+ ple_prev[0] = ple_prev[1];
+ ple_prev[1] = tok;
+ pss->ple.table->prefetch_rows(rows16);
+ };
+
+ int n = 0;
+ if (min_p <= 0.0f && max_steps > 0) {
+ if (cudaGraphLaunch(cp ? round_exec_c_[T] : round_exec_[T], cs_) != cudaSuccess) {
+ err = std::string("mtp draft: ") + cudaGetErrorString(cudaGetLastError());
+ return false;
+ }
+ for (int j = 1; j < max_steps; ++j) {
+ if (cudaGraphLaunch(cp ? step_exec_c_[j] : step_exec_[j], cs_) != cudaSuccess) {
+ err = std::string("mtp draft step: ") + cudaGetErrorString(cudaGetLastError());
+ return false;
+ }
+ }
+ (void) cudaStreamQuery(cs_);
+ prefetch_ple(tokens[a]);
+ for (int j = 0; j + 1 < max_steps; ++j) {
+ uint32_t spins = 0;
+ while (((volatile int32_t*) h_out_)[j] < 0) {
+#if defined(_WIN32) || defined(__x86_64__)
+ _mm_pause();
+#endif
+ if ((++spins & 1023u) == 0 && cudaStreamQuery(cs_) != cudaErrorNotReady) break;
+ }
+ drafts[j] = ((volatile int32_t*) h_out_)[j];
+ if (probs) probs[j] = ((volatile float*) h_prob_)[j];
+ prefetch_ple(drafts[j]);
+ }
+ if (cudaStreamSynchronize(cs_) != cudaSuccess) {
+ err = std::string("mtp draft: ") + cudaGetErrorString(cudaGetLastError());
+ return false;
+ }
+ const int last = max_steps - 1;
+ drafts[last] = ((volatile int32_t*) h_out_)[last];
+ if (probs) probs[last] = ((volatile float*) h_prob_)[last];
+ prefetch_ple(drafts[last]);
+ n = max_steps;
+ } else if (max_steps > 0) {
+ if (cudaGraphLaunch(cp ? round_exec_c_[T] : round_exec_[T], cs_) != cudaSuccess) {
+ err = std::string("mtp draft: ") + cudaGetErrorString(cudaGetLastError());
+ return false;
+ }
+ (void) cudaStreamQuery(cs_);
+ prefetch_ple(tokens[a]);
+ auto wait_step = [&](int j) {
+ uint32_t spins = 0;
+ while (((volatile int32_t*) h_out_)[j] < 0) {
+#if defined(_WIN32) || defined(__x86_64__)
+ _mm_pause();
+#endif
+ if ((++spins & 1023u) == 0 && cudaStreamQuery(cs_) != cudaErrorNotReady) break;
+ }
+ };
+ wait_step(0);
+ drafts[0] = ((volatile int32_t*) h_out_)[0];
+ float pj = ((volatile float*) h_prob_)[0];
+ if (probs) probs[0] = pj;
+ n = 1;
+ for (int j = 1; j < max_steps && pj >= min_p; ++j) {
+ if (cudaGraphLaunch(cp ? step_exec_c_[j] : step_exec_[j], cs_) != cudaSuccess) {
+ err = std::string("mtp draft step: ") + cudaGetErrorString(cudaGetLastError());
+ return false;
+ }
+ (void) cudaStreamQuery(cs_);
+ prefetch_ple(drafts[j - 1]);
+ wait_step(j);
+ drafts[j] = ((volatile int32_t*) h_out_)[j];
+ pj = ((volatile float*) h_prob_)[j];
+ if (probs) probs[j] = pj;
+ ++n;
+ }
+ prefetch_ple(drafts[n - 1]);
+ if (cudaStreamSynchronize(cs_) != cudaSuccess) {
+ err = std::string("mtp draft: ") + cudaGetErrorString(cudaGetLastError());
return false;
}
- drafts[j] = ((volatile int32_t*) h_out_)[j];
- pj = ((volatile float*) h_prob_)[j];
- if (probs) probs[j] = pj;
- ++n;
}
for (int j = n; j < max_t_ - 1; ++j) { drafts[j] = 0; if (probs) probs[j] = 0.0f; }
if (n_drafts) *n_drafts = n;
diff --git a/src/core/native_dense.cpp b/src/core/native_dense.cpp
index 4a86d48c21..e0609fdc5a 100644
--- a/src/core/native_dense.cpp
+++ b/src/core/native_dense.cpp
@@ -1,4 +1,5 @@
#include "strata/core/native_dense.hpp"
+#include
#include "strata/core/weights.hpp"
#include "strata/artifact/gguf_reader.hpp"
#include "strata/kernels/native_mmvq.hpp"
@@ -6,6 +7,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -13,6 +15,12 @@
namespace strata::core {
namespace {
+int g_layer_lb = -1, g_layer_le = -1; // set_layer_range; -1: every layer
+bool in_range(const std::string& name) {
+ if (g_layer_lb < 0 || name.rfind("blk.", 0) != 0) return true;
+ const int l = std::atoi(name.c_str() + 4);
+ return l >= g_layer_lb && l < g_layer_le;
+}
bool eligible(const strata::TensorInfo& tensor, bool include_ple_key) {
const auto& name = tensor.name;
if (name.rfind("blk.", 0) != 0) return false;
@@ -53,6 +61,7 @@ bool NativeDense::served_names(const std::vector& shards, bool incl
}
}
+void NativeDense::set_layer_range(int lb, int le) { g_layer_lb = lb; g_layer_le = le; }
bool NativeDense::keep_unquantized_ple_key(const std::string& pack_dir, std::set& skip,
std::string& err) {
const std::string key = "blk.1.ple_key.weight";
@@ -69,7 +78,12 @@ NativeDense::~NativeDense() {
}
bool NativeDense::load(const std::vector& shards, WeightTable& table, std::string& err,
- bool include_ple_key) {
+ bool include_ple_key, int64_t layer_lo, int64_t layer_hi) {
+ auto outside = [&](const std::string& name) { // a blk.. tensor of another stage's layers
+ if (layer_hi < 0 || name.rfind("blk.", 0) != 0) return false;
+ const long l = std::strtol(name.c_str() + 4, nullptr, 10);
+ return l < layer_lo || l >= layer_hi;
+ };
if (scratch_ || !weights_.empty()) { err = "native dense: already loaded"; return false; }
if (shards.empty()) { err = "native dense: at least one GGUF shard is required"; return false; }
try {
@@ -138,7 +152,8 @@ bool NativeDense::load(const std::vector& shards, WeightTable& tabl
}
}
for (const auto& tensor : gguf.tensors()) {
- if (!eligible(tensor, include_ple_key)) continue;
+ if (!eligible(tensor, include_ple_key) || outside(tensor.name)) continue;
+ if (!in_range(tensor.name) && tensor.name.find("ple") == std::string::npos) continue;
if (!seen.insert(tensor.name).second) {
err = "native dense: duplicate tensor " + tensor.name; return false;
}
diff --git a/src/core/pinned.cu b/src/core/pinned.cu
index 8750adf96b..83e9b8adcf 100644
--- a/src/core/pinned.cu
+++ b/src/core/pinned.cu
@@ -225,9 +225,27 @@ void* reserve(uint64_t bytes, PageBacking& got, std::string& note, const std::st
note = "MAP_HUGETLB unavailable (needed " + std::to_string(need) + " 2 MiB pages, vm.nr_hugepages=" +
(have_pool ? std::to_string(pool) : std::string("?")) + "); using 4 KB pages";
}
- void* p = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+ // No hugetlb mapping: a 2 MiB-aligned anonymous mapping with MADV_HUGEPAGE, so transparent huge pages back
+ // it where THP is "madvise" (the common distro default). The CPU expert pool streams whole experts out of
+ // this arena; with 4 KB pages every 3 MB expert costs ~750 TLB misses.
+ constexpr uint64_t kAlign = 2ull << 20;
+ const uint64_t padded = bytes + kAlign;
+ void* raw = mmap(nullptr, padded, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
got = PageBacking::NormalPages;
- return p == MAP_FAILED ? nullptr : p;
+ if (raw == MAP_FAILED) return nullptr;
+ const uintptr_t start = (uintptr_t) raw;
+ const uintptr_t aligned = (start + kAlign - 1) & ~(uintptr_t) (kAlign - 1);
+ if (aligned > start) munmap(raw, aligned - start);
+ const uintptr_t end = aligned + bytes, raw_end = start + padded;
+ if (raw_end > end) munmap((void*) end, raw_end - end);
+ void* p = (void*) aligned;
+ if (std::getenv("STRATA_NO_LARGEPAGES") == nullptr && madvise(p, bytes, MADV_HUGEPAGE) == 0) {
+ const std::string four_k = "; using 4 KB pages";
+ if (note.size() >= four_k.size() && note.compare(note.size() - four_k.size(), four_k.size(), four_k) == 0)
+ note.resize(note.size() - four_k.size());
+ note += "; transparent huge pages requested (MADV_HUGEPAGE)";
+ }
+ return p;
#endif
}
@@ -486,7 +504,7 @@ LoadStats load_experts_direct(const std::string& path, uint8_t* dst, const std::
std::mutex err_mu;
std::string err;
const int wide = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0);
- std::vector wpath((size_t) std::max(wide, 1), L'\0');
+ std::vector wpath((size_t) (std::max)(wide, 1), L'\0');
if (wide > 0) MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, wpath.data(), wide);
auto worker = [&]() {
HANDLE h = CreateFileW(wpath.data(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
@@ -537,7 +555,7 @@ LoadStats load_experts_direct(const std::string& path, uint8_t* dst, const std::
}
bool experts_unbuffered(const std::vector& files, uint64_t arena_bytes, std::string& why,
- bool cache_counts) {
+ bool cache_counts, uint64_t read_bytes) {
const char* env = std::getenv("STRATA_UNBUFFERED_LOAD");
if (env != nullptr && env[0] != '\0') {
why = std::string("STRATA_UNBUFFERED_LOAD=") + env;
@@ -554,7 +572,7 @@ bool experts_unbuffered(const std::vector& files, uint64_t arena_by
uint64_t seed = (uint64_t) GetTickCount64() * 6364136223846793005ull + 1442695040888963407ull;
for (const std::string& f : files) {
const int wide = MultiByteToWideChar(CP_UTF8, 0, f.c_str(), -1, nullptr, 0);
- std::vector w((size_t) std::max(wide, 1), L'\0');
+ std::vector w((size_t) (std::max)(wide, 1), L'\0');
if (wide > 0) MultiByteToWideChar(CP_UTF8, 0, f.c_str(), -1, w.data(), wide);
HANDLE h = CreateFileW(w.data(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_RANDOM_ACCESS,
nullptr);
@@ -585,15 +603,21 @@ bool experts_unbuffered(const std::vector& files, uint64_t arena_by
GlobalMemoryStatusEx(&ms);
const uint64_t avail = ms.ullAvailPhys;
// what the cache could keep beside the arena (~4 GiB for everything else)
- const uint64_t room = avail > arena_bytes + (4ull << 30) ? avail - arena_bytes - (4ull << 30) : 0;
- const bool keepable = room >= total_bytes;
- char msg[200];
- std::snprintf(msg, sizeof msg, "%d of %d probe reads from the file cache; %.1f GiB available, %.1f GiB of files",
- fast, n, (double) avail / (1ull << 30), (double) total_bytes / (1ull << 30));
+ const uint64_t need = read_bytes == kAllFileBytes ? total_bytes : read_bytes;
+ const bool keepable = strata::platform::file_cache_keeps(avail, arena_bytes, need);
+ char msg[256];
+ if (read_bytes == kAllFileBytes)
+ std::snprintf(msg, sizeof msg, "%d of %d probe reads from the file cache; %.1f GiB available, %.1f GiB of files",
+ fast, n, (double) avail / (1ull << 30), (double) total_bytes / (1ull << 30));
+ else
+ std::snprintf(msg, sizeof msg, "%.1f GiB available, %.1f GiB of it still to be taken by the RAM copy, %.1f GiB "
+ "of experts read from the files: the file cache %s keep them",
+ (double) avail / (1ull << 30), (double) arena_bytes / (1ull << 30), (double) need / (1ull << 30),
+ keepable ? "can" : "cannot");
why = msg;
return (!cached || !cache_counts) && !keepable;
#else
- (void) files; (void) arena_bytes; (void) cache_counts;
+ (void) files; (void) arena_bytes; (void) cache_counts; (void) read_bytes;
why = "buffered (not Windows)";
return false;
#endif
diff --git a/src/core/remote_expert_opt.cu b/src/core/remote_expert_opt.cu
new file mode 100644
index 0000000000..229781c805
--- /dev/null
+++ b/src/core/remote_expert_opt.cu
@@ -0,0 +1,194 @@
+#include "strata/core/remote_expert_opt.hpp"
+#include "strata/core/remote_experts.hpp"
+#include "strata/core/on_device.hpp"
+#include "strata/kernels/cpu/expert_layout.hpp"
+#include "strata/kernels/elementwise.hpp"
+#include "strata/kernels/verify_kernels.hpp"
+
+#include
+#include
+
+namespace strata::core {
+namespace {
+constexpr int64_t H = strata::kernels::cpu::H;
+constexpr int K = 10, MAXT = strata::kernels::cpu::MAXT, CAP = MAXT * K;
+struct ReduceMeta { int32_t row[CAP]; float weight[CAP]; };
+
+bool check(cudaError_t status, std::string& err) {
+ if (status == cudaSuccess) return true;
+ err = std::string("remote expert decode optimization: ") + cudaGetErrorString(status);
+ return false;
+}
+
+__global__ void reduce_experts(const float* parts, const ReduceMeta* meta, float* sum) {
+ const int t = blockIdx.y, c = blockIdx.x * blockDim.x + threadIdx.x;
+ if (c >= H) return;
+ float v = 0.0f;
+ for (int j = 0; j < K; ++j) {
+ const int i = t * K + j, r = meta->row[i];
+ if (r >= 0) v += parts[(int64_t) r * H + c] * meta->weight[i];
+ }
+ sum[(int64_t) t * H + c] = v;
+}
+
+// Like the upstream mapped-row copy, also skip rows returned in the remote sum.
+__global__ void copy_cpu_rows(float4* dst, const volatile float4* src, const int32_t* mask,
+ const int32_t* hit_rows, const int32_t* count) {
+ const int row = blockIdx.x;
+ __shared__ int skip;
+ if (threadIdx.x == 0) {
+ int s = mask[row];
+ for (int i = 0; i < *count; ++i) s |= hit_rows[i] == row;
+ skip = s;
+ }
+ __syncthreads();
+ const int64_t start = (int64_t) row * (H / 4);
+ for (int i = threadIdx.x; i < H / 4; i += blockDim.x)
+ dst[start + i] = skip ? make_float4(0, 0, 0, 0) : const_cast(src)[start + i];
+}
+} // namespace
+
+void RemoteExpertOpt::attach(RemoteExperts& remote) {
+ remote.remote_opt_ = this;
+ peers_.push_back({&remote});
+}
+
+bool RemoteExpertOpt::init(std::string& err) {
+ void* mapped = nullptr;
+ if (!check(cudaHostAlloc((void**) &h_sum_, MAXT * (H + K) * sizeof(float),
+ cudaHostAllocPortable | cudaHostAllocMapped), err) ||
+ !check(cudaHostGetDevicePointer(&mapped, h_sum_, 0), err)) return false;
+ m_sum_ = (float*) mapped;
+ h_mask_ = reinterpret_cast(h_sum_ + MAXT * H);
+ m_mask_ = reinterpret_cast(m_sum_ + MAXT * H);
+ for (auto& p : peers_) {
+ const OnDevice on(p.remote->device_);
+ if (!check(cudaMalloc((void**) &p.sum, MAXT * H * sizeof(float)), err)) return false;
+ }
+ return true;
+}
+
+RemoteExpertOpt::~RemoteExpertOpt() {
+ for (auto& p : peers_) {
+ const OnDevice on(p.remote->device_);
+ if (p.remote->stream_) cudaStreamSynchronize(p.remote->stream_);
+ if (p.sum) cudaFree(p.sum);
+ p.remote->remote_opt_ = nullptr;
+ }
+ if (h_sum_) cudaFreeHost(h_sum_);
+}
+
+size_t RemoteExpertOpt::metadata_bytes() { return sizeof(ReduceMeta); }
+
+void RemoteExpertOpt::begin(const float* weights, int token_begin, int tokens) {
+ weights_ = weights;
+ token_begin_ = token_begin;
+ tokens_ = tokens;
+ std::memset(h_sum_ + token_begin * H, 0, tokens * H * sizeof(float));
+ std::memset(h_mask_ + token_begin * K, 0, tokens * K * sizeof(int32_t));
+}
+
+void RemoteExpertOpt::prepare(const RemoteExperts& remote, void* metadata) const {
+ auto* m = (ReduceMeta*) metadata;
+ std::fill(m->row, m->row + tokens_ * K, -1);
+ for (size_t i = 0; i < remote.original_row_.size(); ++i)
+ m->row[remote.original_row_[i]] = (int32_t) i;
+ std::memcpy(m->weight, weights_, tokens_ * K * sizeof(float));
+}
+
+bool RemoteExpertOpt::reduce(RemoteExperts& remote, const void* metadata, std::string& err) {
+ const auto& p = *std::find_if(peers_.begin(), peers_.end(), [&](const Peer& p) { return p.remote == &remote; });
+ reduce_experts<<>>(
+ remote.d_out_, (const ReduceMeta*) metadata, p.sum);
+ return check(cudaMemcpyAsync(remote.h_out_, p.sum, tokens_ * H * sizeof(float),
+ cudaMemcpyDeviceToHost, remote.stream_), err);
+}
+
+void RemoteExpertOpt::accumulate(const RemoteExperts& remote) {
+ for (int64_t i = 0; i < tokens_ * H; ++i) h_sum_[token_begin_ * H + i] += remote.h_out_[i];
+ for (int i = 0; i < tokens_ * K; ++i)
+ h_mask_[token_begin_ * K + i] |= remote.owned_[(size_t) i] != 0;
+}
+
+void RemoteExpertOpt::copy_rows(float* dst, const float* src, int token_begin, int tokens,
+ const int32_t* hit_rows, const int32_t* count, void* stream) const {
+ copy_cpu_rows<<>>(
+ (float4*) dst, (const volatile float4*) src, m_mask_ + token_begin * K, hit_rows, count);
+}
+
+void RemoteExpertOpt::combine(float* dst, float* scratch, int token_begin, int tokens,
+ const uint32_t* skip, uint32_t ring, void* stream) const {
+ const float* sum = m_sum_ + token_begin * H;
+ if (skip) {
+ // An all-primary device-planned group bypasses the host: ignore its stale sum.
+ strata::kernels::copy_or_zero_from_mapped(scratch, sum, tokens * H, skip, ring, stream);
+ sum = scratch;
+ }
+ strata::kernels::add_inplace(dst, sum, tokens * H, stream);
+}
+
+bool RemoteExpertOpt::owns(int64_t layer, int32_t expert) const {
+ for (const auto& p : peers_)
+ if (p.remote->cache_.slot_of(layer, expert) >= 0) return true;
+ return false;
+}
+
+bool RemoteExpertOpt::adapt(const std::vector& usage, const std::vector& primary,
+ const std::vector>& pending, int max_swaps,
+ ExpertSource& source) {
+ if (max_swaps <= 0) return true;
+ const auto& lay = strata::kernels::cpu::expert_layout();
+ std::vector resident(primary.size(), 0);
+ for (size_t i = 0; i < primary.size(); ++i) resident[i] = primary[i] >= 0;
+ for (const auto& [i, slot] : pending) resident[(size_t) i] = 1;
+ for (const auto& p : peers_) {
+ const auto& r = *p.remote;
+ for (int32_t l = 0; l < (int32_t) r.layers_present_.size(); ++l)
+ for (int32_t e = 0; e < r.n_expert_; ++e)
+ if (r.cache_.slot_of(l, e) >= 0) resident[(size_t) l * r.n_expert_ + e] = 1;
+ }
+ struct Swap { float gain; int32_t layer, in, out; };
+ std::string err;
+ for (auto& p : peers_) {
+ auto& r = *p.remote;
+ std::vector swaps;
+ std::vector> candidates, victims;
+ for (int32_t l = 0; l < (int32_t) r.layers_present_.size(); ++l) {
+ candidates.clear(); victims.clear();
+ const size_t base = (size_t) l * r.n_expert_;
+ float primary_cold = 1.0e30f;
+ for (int32_t e = 0; e < r.n_expert_; ++e) {
+ const float u = usage[base + e];
+ if (primary[base + e] >= 0) primary_cold = std::min(primary_cold, u);
+ if (r.cache_.slot_of(l, e) >= 0) victims.emplace_back(u, e);
+ else if (!resident[base + e] && u >= 2.0f) candidates.emplace_back(u, e);
+ }
+ const size_t n = std::min(candidates.size(), victims.size());
+ if (!n) continue;
+ std::sort(candidates.begin(), candidates.end(), [](auto& a, auto& b) { return a.first > b.first; });
+ std::partial_sort(victims.begin(), victims.begin() + n, victims.end(),
+ [](auto& a, auto& b) { return a.first < b.first; });
+ for (size_t i = 0; i < n; ++i) {
+ if (victims[i].first > primary_cold) break;
+ if (candidates[i].first < victims[i].first + 1.5f) break;
+ swaps.push_back({candidates[i].first - victims[i].first, l, candidates[i].second, victims[i].second});
+ }
+ }
+ std::sort(swaps.begin(), swaps.end(), [](auto& a, auto& b) { return a.gain > b.gain; });
+ if ((int) swaps.size() > max_swaps) swaps.resize((size_t) max_swaps);
+ if (swaps.empty()) continue;
+ const OnDevice on(r.device_);
+ for (const auto& s : swaps)
+ if (!r.cache_.fill_slot(r.cache_.slot_of(s.layer, s.out), source.blob(s.layer, s.in),
+ r.stream_, err, (int64_t) lay.blob_bytes(s.layer))) return false;
+ if (!check(cudaStreamSynchronize(r.stream_), err)) return false;
+ for (const auto& s : swaps) {
+ r.cache_.replace(s.layer, s.out, s.in);
+ const size_t base = (size_t) s.layer * r.n_expert_;
+ resident[base + s.out] = 0;
+ resident[base + s.in] = 1;
+ }
+ }
+ return true;
+}
+} // namespace strata::core
diff --git a/src/core/remote_experts.cpp b/src/core/remote_experts.cpp
index 53d8d7643c..1b6969e828 100644
--- a/src/core/remote_experts.cpp
+++ b/src/core/remote_experts.cpp
@@ -1,4 +1,5 @@
#include "strata/core/remote_experts.hpp"
+#include "strata/core/remote_expert_opt.hpp"
#include "strata/kernels/cpu/expert_layout.hpp"
#include "strata/kernels/iq_kernels.hpp"
@@ -120,7 +121,7 @@ void RemoteExperts::close() {
bool RemoteExperts::open(int device, int slots, int64_t layers, int64_t experts,
const std::vector>& ranked,
const ExpertCache& primary, ExpertSource& source,
- std::vector& claimed, std::string& err) {
+ std::vector& claimed, std::string& err, bool auto_size) {
close();
int count = 0;
if (!check(cudaGetDeviceCount(&count), "cudaGetDeviceCount", err, device)) return false;
@@ -134,29 +135,34 @@ bool RemoteExperts::open(int device, int slots, int64_t layers, int64_t experts,
device_ = device;
n_expert_ = experts;
const auto& lay = strata::kernels::cpu::expert_layout();
+ size_t free_bytes = 0, total_bytes = 0;
+ if (!check(cudaMemGetInfo(&free_bytes, &total_bytes), "free memory", err, device)) { close(); return false; }
+ uint64_t needed = 0;
std::vector> selected;
- selected.reserve((size_t) slots);
+ selected.reserve((size_t) std::min(slots, layers * experts));
std::vector picked(claimed.size(), 0);
for (const auto& pair : ranked) {
if (pair.first < 0 || pair.first >= layers || pair.second < 0 || pair.second >= experts) continue;
const size_t index = (size_t) pair.first * (size_t) experts + (size_t) pair.second;
if (primary.slot_of(pair.first, pair.second) < 0 && !claimed[index] && !picked[index]) {
+ const uint64_t bytes = lay.native ? (lay.blob_bytes(pair.first) + 255) / 256 * 256 : lay.max_blob;
+ if (auto_size && needed + bytes + (512ull << 20) > free_bytes) break;
selected.push_back(pair);
+ needed += bytes;
picked[index] = 1;
if ((int) selected.size() >= slots) break;
}
}
- if (selected.empty()) { err = "CUDA" + std::to_string(device) + " experts: no unclaimed experts remain"; close(); return false; }
+ if (selected.empty()) {
+ err = "CUDA" + std::to_string(device) + (auto_size ? " experts: no unclaimed expert fits with 512 MiB free"
+ : " experts: no unclaimed experts remain");
+ close(); return false;
+ }
std::vector sizes;
if (lay.native) {
sizes.reserve(selected.size());
for (const auto& pair : selected) sizes.push_back((int64_t) lay.blob_bytes(pair.first));
}
- size_t free_bytes = 0, total_bytes = 0;
- if (!check(cudaMemGetInfo(&free_bytes, &total_bytes), "free memory", err, device)) { close(); return false; }
- uint64_t needed = 0;
- for (const auto& pair : selected)
- needed += lay.native ? (lay.blob_bytes(pair.first) + 255) / 256 * 256 : lay.max_blob;
// Leave room for the CUDA context, staging and later driver allocations, especially under WDDM.
if (needed + (512ull << 20) > free_bytes) {
err = "CUDA" + std::to_string(device) + " experts: slots leave less than 512 MiB free; reduce --expert-cache-device" + std::to_string(device);
@@ -185,17 +191,18 @@ bool RemoteExperts::open(int device, int slots, int64_t layers, int64_t experts,
const size_t scratch = std::max(
(size_t) strata::kernels::moe_hit_grouped_scratch_bytes(CAP, H, FF),
strata::kernels::native_expert_scratch_bytes(CAP, FF));
+ const size_t meta_bytes = sizeof(RemoteMeta) + (remote_opt_ ? remote_opt_->metadata_bytes() : 0);
const bool allocated =
check(cudaStreamCreateWithFlags(&stream_, cudaStreamNonBlocking), "stream", err, device) &&
check(cudaHostAlloc((void**) &h_x_, (size_t) CAP * H * sizeof(float), cudaHostAllocPortable | cudaHostAllocMapped), "input staging", err, device) &&
check(cudaHostAlloc((void**) &h_out_, (size_t) CAP * H * sizeof(float), cudaHostAllocPortable | cudaHostAllocMapped), "result staging", err, device) &&
- check(cudaHostAlloc(&h_meta_, sizeof(RemoteMeta), cudaHostAllocPortable), "metadata staging", err, device) &&
+ check(cudaHostAlloc(&h_meta_, meta_bytes, cudaHostAllocPortable), "metadata staging", err, device) &&
check(cudaMalloc((void**) &d_x_, (size_t) CAP * H * sizeof(float)), "input", err, device) &&
check(cudaMalloc((void**) &d_out_, (size_t) CAP * H * sizeof(float)), "result", err, device) &&
check(cudaMalloc((void**) &d_q8_, (size_t) CAP * (H / 32) * 36), "activation", err, device) &&
check(cudaMalloc((void**) &d_scales_, (size_t) CAP * (H / 32) * sizeof(float)), "activation scales", err, device) &&
check(cudaMalloc(&d_scratch_, scratch), "scratch", err, device) &&
- check(cudaMalloc(&d_meta_, sizeof(RemoteMeta)), "group metadata", err, device);
+ check(cudaMalloc(&d_meta_, meta_bytes), "group metadata", err, device);
if (!allocated) { close(); return false; }
// Zero-copy: the helper reads its input from, and writes its compact rows into, the pinned host buffers
// directly - two copies fewer per layer, each of which is a PCIe round trip. STRATA_REMOTE_ZEROCOPY=0 copies.
@@ -284,13 +291,16 @@ bool RemoteExperts::begin(int64_t layer, const float* x, const int32_t* ids, int
std::memcpy(meta->dst, dst_.data(), dst_.size() * sizeof(dst_[0]));
std::memcpy(meta->tok, tok_.data(), tok_.size() * sizeof(tok_[0]));
meta->count = (int32_t) group_id_.size();
+ const bool reduce = remote_opt_ && remote_opt_->active();
+ if (reduce) remote_opt_->prepare(*this, meta + 1);
DeviceScope scope(device_);
if (!scope.ok) { err = scope.error(device_); return false; }
groups_ = (int32_t) group_id_.size();
const cudaStream_t s = stream_;
const bool staged =
(zero_copy_ || check(cudaMemcpyAsync(d_x_, h_x_, (size_t) n_tok * H * sizeof(float), cudaMemcpyHostToDevice, s), "copy input", err, device_)) &&
- check(cudaMemcpyAsync(d_meta_, h_meta_, sizeof(RemoteMeta), cudaMemcpyHostToDevice, s), "copy group metadata", err, device_);
+ check(cudaMemcpyAsync(d_meta_, h_meta_, sizeof(RemoteMeta) + (reduce ? remote_opt_->metadata_bytes() : 0),
+ cudaMemcpyHostToDevice, s), "copy group metadata", err, device_);
if (!staged) return false;
const auto& lay = strata::kernels::cpu::expert_layout();
if (lay.native) {
@@ -298,14 +308,16 @@ bool RemoteExperts::begin(int64_t layer, const float* x, const int32_t* ids, int
const auto& fmt = lay.fmt[(size_t) layer];
auto L = strata::kernels::native_expert_layout(fmt.gu_type, fmt.d_type, fmt.n_embd, fmt.n_ff);
strata::kernels::native_expert_grouped(L, d_ptr_, d_start_, d_count_, d_dst_, d_tok_,
- groups_, (int64_t) dst_.size(), d_q8_, d_scratch_, zero_copy_ ? z_out_ : d_out_, s);
+ groups_, (int64_t) dst_.size(), d_q8_, d_scratch_, zero_copy_ && !reduce ? z_out_ : d_out_, s);
} else {
strata::kernels::quantize_q8_0_scaled(zero_copy_ ? z_x_ : d_x_, d_q8_, d_scales_, n_tok * H, s);
strata::kernels::moe_grouped_s2(d_ptr_, d_start_, d_count_, d_dst_, d_tok_,
- groups_, (int64_t) dst_.size(), d_q8_, d_scales_, d_scratch_, zero_copy_ ? z_out_ : d_out_, s);
+ groups_, (int64_t) dst_.size(), d_q8_, d_scales_, d_scratch_, zero_copy_ && !reduce ? z_out_ : d_out_, s);
}
- const uint64_t compact_bytes = (uint64_t) dst_.size() * H * sizeof(float);
- if (!zero_copy_ && !check(cudaMemcpyAsync(h_out_, d_out_, (size_t) compact_bytes, cudaMemcpyDeviceToHost, s),
+ const uint64_t compact_bytes = (uint64_t) (reduce ? n_tok : dst_.size()) * H * sizeof(float);
+ if (reduce) {
+ if (!remote_opt_->reduce(*this, (RemoteMeta*) d_meta_ + 1, err)) return false;
+ } else if (!zero_copy_ && !check(cudaMemcpyAsync(h_out_, d_out_, (size_t) compact_bytes, cudaMemcpyDeviceToHost, s),
"copy results", err, device_)) return false;
++launched_layers_;
returned_bytes_ += compact_bytes;
@@ -320,6 +332,7 @@ bool RemoteExperts::finish(float* out, std::string& err) {
const auto w0 = std::chrono::steady_clock::now();
if (!check(cudaStreamSynchronize(stream_), "finish", err, device_)) return false;
ms_wait_ += std::chrono::duration(std::chrono::steady_clock::now() - w0).count();
+ if (remote_opt_ && remote_opt_->active()) { remote_opt_->accumulate(*this); return true; }
for (size_t i = 0; i < original_row_.size(); ++i)
std::memcpy(out + (size_t) original_row_[i] * H, h_out_ + i * H, (size_t) H * sizeof(float));
return true;
diff --git a/src/core/session.cpp b/src/core/session.cpp
index 14c61663bd..40a44bd126 100644
--- a/src/core/session.cpp
+++ b/src/core/session.cpp
@@ -547,7 +547,7 @@ bool SessionLoopScratch::init(size_t parts_bytes_in, std::string& err) {
const std::vector cores = strata::kernels::cpu::physical_cores(false);
if (!cores.empty()) {
pinned_core = strata::kernels::cpu::pin_current_thread(cores[0]);
- pinned = true;
+ pinned = pinned_core.valid;
}
return true;
}
@@ -558,7 +558,7 @@ void SessionLoopScratch::free() {
if (pinned) {
strata::kernels::cpu::restore_thread_affinity(pinned_core);
pinned = false;
- pinned_core = -1;
+ pinned_core = {};
}
if (probe != nullptr) { cudaEventDestroy(probe); probe = nullptr; }
if (y_miss != nullptr) { cudaFreeHost(y_miss); y_miss = nullptr; }
diff --git a/src/core/verify.cpp b/src/core/verify.cpp
index f956d73158..dd5362bacd 100644
--- a/src/core/verify.cpp
+++ b/src/core/verify.cpp
@@ -1,5 +1,6 @@
// src/core/verify.cpp - see include/strata/core/verify.hpp.
#include "strata/core/verify.hpp"
+#include "strata/core/remote_expert_opt.hpp"
#if defined(_WIN32)
#include
#endif
@@ -69,7 +70,29 @@ struct Bump {
}
};
+// #649 (HIP, opt-in A/Bs for the gfx1030 verify timeouts; CUDA never reads them)
+// STRATA_VERIFY_COHERENT=1 the handshake words and rows in explicitly coherent (fine-grained) host memory
+// STRATA_DOORBELL_STORE=1 the GPU stores each step's ring instead of read-modify-writing it over PCIe
+bool env_on(const char* name) {
+ const char* e = std::getenv(name);
+ return e != nullptr && e[0] != 0 && e[0] != '0';
+}
+#if defined(STRATA_USE_HIP)
+const bool g_coherent = env_on("STRATA_VERIFY_COHERENT");
+const bool g_doorbell_store = env_on("STRATA_DOORBELL_STORE");
+#endif
+const bool g_trace = env_on("STRATA_VERIFY_TRACE");
+
bool mapped(size_t bytes, void** h, void** d) {
+#if defined(STRATA_USE_HIP)
+ if (g_coherent) {
+ if (hipHostMalloc(h, bytes, hipHostMallocMapped | hipHostMallocCoherent |
+ (peer_portable() ? hipHostMallocPortable : 0)) != hipSuccess)
+ return false;
+ std::memset(*h, 0, bytes);
+ return cudaHostGetDevicePointer(d, *h, 0) == cudaSuccess;
+ }
+#endif
if (cudaHostAlloc(h, bytes, cudaHostAllocMapped | (peer_portable() ? cudaHostAllocPortable : 0)) != cudaSuccess) return false; // multi-GPU: portable only with a peer - the peer card writes its rows into them
std::memset(*h, 0, bytes);
return cudaHostGetDevicePointer(d, *h, 0) == cudaSuccess;
@@ -122,6 +145,20 @@ void release_live_verifiers(std::FILE* f) {
}
if (f != nullptr) std::fflush(f);
}
+// #649: the host side of STRATA_VERIFY_TRACE - a ring of the last kTraceN handshake events of every verifier
+struct TraceEv {
+ int64_t t_ns;
+ const void* who;
+ int64_t window, step, layer, aux;
+ uint32_t seq, flag, a, b;
+ const char* what;
+};
+constexpr uint64_t kTraceN = 4096;
+TraceEv g_trace_ring[kTraceN];
+std::atomic g_trace_next{0};
+int64_t trace_now_ns() {
+ return std::chrono::duration_cast(Clock::now().time_since_epoch()).count();
+}
std::string released_note(bool drained) {
return drained ? "; its GPU waits were released and the GPU finished (#267)"
: "; its GPU waits were released but the GPU did not finish within 5 s (#267)";
@@ -136,6 +173,7 @@ const int64_t g_test_stall = [] {
bool Verifier::release_gpu_waits(int timeout_ms) {
released_.store(true);
+ trace_ev("RELEASE", -1, -1, timeout_ms);
// the words the spin kernels read (wait_flag_ge, wait_flag_ge_or) are mapped host memory, so a store here
// reaches them with no API call; UINT32_MAX is past every ring. (E-6's skip words are device memory, but
// wait_flag_ge_or also returns on its flag.) A host function raising flag B later only raises.
@@ -148,13 +186,76 @@ bool Verifier::release_gpu_waits(int timeout_ms) {
for (cudaStream_t s : {cs_, copy_}) {
if (s == nullptr) continue;
while (cudaStreamQuery(s) == cudaErrorNotReady) {
- if (ms_since(t0) > timeout_ms) return false;
+ if (ms_since(t0) > timeout_ms) {
+ trace_ev("RELEASE-NOT-DRAINED", -1, -1, (int64_t) ms_since(t0));
+ return false;
+ }
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
+ trace_ev("RELEASE-DRAINED", -1, -1, (int64_t) ms_since(t0)); // aux: ms the GPU took to finish once released
return true;
}
+void Verifier::trace_ev(const char* what, int64_t step, int64_t layer, int64_t aux) const {
+ if (!g_trace) return;
+ auto rd = [](const uint32_t* p) { return p ? *(const volatile uint32_t*) p : 0u; };
+ TraceEv& e = g_trace_ring[g_trace_next.fetch_add(1) % kTraceN];
+ e = {trace_now_ns(), this, windows, step, layer, aux, rd(h_seq_), rd(h_flag_), rd(h_flagA_), rd(h_flagB_), what};
+}
+
+void Verifier::trace_dump(std::FILE* f) const {
+ if (!g_trace || f == nullptr) return;
+ static const char* names[kProfPer] = {"pre", "hc-read0", "qkv gemv", "conv", "ab", "z", "rec", "kv-idx",
+ "k/v rope", "kv append", "q", "scores+topk", "kv-resolve", "attention",
+ "gate", "", "out-proj", "router+ring", "shared", "waitA", "VRAM hits", "waitB",
+ "PCIe grp", "waitCPU", "combine", "", "head", "", "", "", "", "", ""};
+ const uint64_t end = g_trace_next.load();
+ const uint64_t n = end < 160 ? end : 160;
+ std::fprintf(f, "strata verify trace (#649): the last %llu handshake events (host clock, us before the last; "
+ "words: seq = GPU rang, flag = CPU served, A = plan, B = copies)\n", (unsigned long long) n);
+ const int64_t t_last = n ? g_trace_ring[(end - 1) % kTraceN].t_ns : 0;
+ for (uint64_t i = end - n; i < end; ++i) {
+ const TraceEv& e = g_trace_ring[i % kTraceN];
+ if (e.what == nullptr) continue;
+ std::fprintf(f, " %10.1f %-20s v%p window %lld step %lld layer %lld aux %lld | seq %u flag %u A %u B %u\n",
+ (double) (t_last - e.t_ns) / 1000.0, e.what, e.who, (long long) e.window, (long long) e.step,
+ (long long) e.layer, (long long) e.aux, e.seq, e.flag, e.a, e.b);
+ }
+ if (trace_h_ == nullptr || g_ == nullptr) return;
+ // the breadcrumbs: every stamp point the GPU passed in this window (ns of the GPU clock after the window's first)
+ unsigned long long t0 = ~0ull;
+ for (size_t i = 0; i < trace_n_; ++i) {
+ const unsigned long long t = *(const volatile unsigned long long*) (trace_h_ + i);
+ if (t != 0 && t < t0) t0 = t;
+ }
+ if (t0 == ~0ull) {
+ std::fprintf(f, "strata verify trace: no GPU breadcrumb in this window (the GPU never started it)\n");
+ return;
+ }
+ int64_t last_l = -1;
+ for (int64_t l = 0; l <= g_->n_layers; ++l)
+ for (int i = 0; i < kProfPer; ++i)
+ for (int grp = 0; grp < 2; ++grp)
+ if (trace_h_[(size_t) ((l * kProfPer + i) * 2 + grp)] != 0) last_l = l;
+ std::fprintf(f, "strata verify trace: GPU breadcrumbs (us after the window's first), the GPU got as far as layer "
+ "%lld:\n", (long long) last_l);
+ for (int64_t l = std::max(0, last_l - 2); l <= std::min(g_->n_layers, last_l + 1); ++l)
+ for (int grp = 0; grp < 2; ++grp) {
+ std::string line;
+ char b[64];
+ for (int i = 0; i < kProfPer; ++i) {
+ const unsigned long long t = trace_h_[(size_t) ((l * kProfPer + i) * 2 + grp)];
+ if (t == 0) continue;
+ std::snprintf(b, sizeof b, " %s(%d)=%.1f", names[i], i, (double) (t - t0) / 1000.0);
+ line += b;
+ }
+ if (!line.empty())
+ std::fprintf(f, " layer %lld group %d:%s\n", (long long) l, grp, line.c_str());
+ }
+ std::fflush(f);
+}
+
void Verifier::diag(std::FILE* f) const {
auto rd = [](const uint32_t* p) { return p ? *(const volatile uint32_t*) p : 0u; };
// #251: outside a verify stage these are the LAST window's numbers (it finished), not the stalled work's
@@ -163,6 +264,7 @@ void Verifier::diag(std::FILE* f) const {
std::fprintf(f, " verify window%s: %d tokens at position %lld, host at layer step %u; the GPU rang %u; flags: "
"served %u, plan (A) %u, copies (B) %u\n", current ? "" : " (last window, not the current stage)",
last_t_, (long long) last_pos0_, cur_layer_ + 1, rd(h_seq_), rd(h_flag_), rd(h_flagA_), rd(h_flagB_));
+ trace_dump(f); // #649: STRATA_VERIFY_TRACE=1 only
}
Verifier::~Verifier() {
@@ -173,12 +275,22 @@ Verifier::~Verifier() {
slot.compare_exchange_strong(me, nullptr);
}
if (cs_) cudaStreamSynchronize(cs_);
+ if (sh_cs_) cudaStreamSynchronize(sh_cs_);
for (auto& e : exec_)
if (e) cudaGraphExecDestroy(e);
if (commit_exec_) cudaGraphExecDestroy(commit_exec_);
+ for (auto& kv : exec_bm_)
+ if (kv.second) cudaGraphExecDestroy(kv.second);
+ for (auto& kv : commit_bm_)
+ if (kv.second) cudaGraphExecDestroy(kv.second);
+ if (arena_b_) cudaFree(arena_b_);
+ if (h_commitb_) cudaFreeHost(h_commitb_);
if (cs_) cudaStreamDestroy(cs_);
+ if (sh_cs_) cudaStreamDestroy(sh_cs_);
if (copy_) { cudaStreamSynchronize(copy_); cudaStreamDestroy(copy_); }
if (commit_done_) cudaEventDestroy(commit_done_);
+ if (ev_fork_) cudaEventDestroy(ev_fork_);
+ if (ev_join_) cudaEventDestroy(ev_join_);
if (arena_) cudaFree(arena_);
void* hosts[] = {h_tok_, h_step_, h_pos_, h_commit_, h_ple_, h_out_, h_x_, h_ids_, h_w_, h_seq_, h_flag_, h_ymiss_,
h_flagA_, h_plan_, h_flagB_};
@@ -298,6 +410,7 @@ bool Verifier::init(const WeightTable& wt, const ModelGeometry& g, SessionState&
inj_ = b.take(T * HC); inj2_ = b.take(T * HC);
lo_ = b.take(T * (uint64_t) g.hc_lr); rs_ = b.take(T * HC); xn_ = b.take(T * HC * N);
xq_ = b.take(strata::kernels::native_q8_1_bytes(max_in, (int) T));
+ sh_xq_ = b.take(strata::kernels::native_q8_1_bytes(max_in, (int) T));
qkv_L_ = b.take(nG * T * C); h_L_ = b.take(nG * T * C);
gate_L_ = b.take(nG * T * HV); beta_L_ = b.take(nG * T * HV);
z_ = b.take(T * ZV); y_ = b.take(T * ZV); y_dummy_ = b.take(T * ZV);
@@ -331,6 +444,23 @@ bool Verifier::init(const WeightTable& wt, const ModelGeometry& g, SessionState&
return false;
}
cudaMemset(arena_, 0, count.used);
+ if (g_trace && trace_h_ == nullptr) { // #649: the breadcrumbs, mapped so they read while the GPU hangs
+ trace_n_ = (size_t) (g.n_layers + 1) * kProfPer * 2;
+ if (!mapped(trace_n_ * 8, (void**) &trace_h_, (void**) &trace_m_)) {
+ (void) cudaGetLastError();
+ trace_h_ = trace_m_ = nullptr;
+ trace_n_ = 0;
+ }
+#if defined(STRATA_USE_HIP)
+ std::fprintf(stderr, "strata verify trace (#649): on; coherent words %s, doorbell %s, HIP_HOST_COHERENT=%s "
+ "HSA_ENABLE_SDMA=%s GPU_MAX_HW_QUEUES=%s\n", g_coherent ? "explicit" : "default",
+ g_doorbell_store ? "stored" : "incremented", std::getenv("HIP_HOST_COHERENT") ? std::getenv("HIP_HOST_COHERENT") : "-",
+ std::getenv("HSA_ENABLE_SDMA") ? std::getenv("HSA_ENABLE_SDMA") : "-",
+ std::getenv("GPU_MAX_HW_QUEUES") ? std::getenv("GPU_MAX_HW_QUEUES") : "-");
+#else
+ std::fprintf(stderr, "strata verify trace (#649): on\n");
+#endif
+ }
prof_on_ = std::getenv("STRATA_VERIFY_PROFILE") != nullptr;
if (prof_on_) {
const size_t np = (size_t) g.n_layers * kProfPer + 4;
@@ -347,31 +477,56 @@ bool Verifier::init(const WeightTable& wt, const ModelGeometry& g, SessionState&
err = "verify: copy stream create failed";
return false;
}
- if (cudaStreamCreateWithFlags(&cs_, cudaStreamNonBlocking) != cudaSuccess) {
+ if (cudaStreamCreateWithFlags(&cs_, cudaStreamNonBlocking) != cudaSuccess ||
+ cudaStreamCreateWithFlags(&sh_cs_, cudaStreamNonBlocking) != cudaSuccess) {
err = "verify: stream create failed";
return false;
}
- if (cudaEventCreateWithFlags(&commit_done_, cudaEventDisableTiming) != cudaSuccess) {
+ if (cudaEventCreateWithFlags(&commit_done_, cudaEventDisableTiming) != cudaSuccess ||
+ cudaEventCreateWithFlags(&ev_fork_, cudaEventDisableTiming) != cudaSuccess ||
+ cudaEventCreateWithFlags(&ev_join_, cudaEventDisableTiming) != cudaSuccess) {
err = "verify: event create failed";
return false;
}
+ // Check whether 100% of experts across [lb_, le_) are resident in this stage's VRAM cache.
+ // When true, every layer plans on device and writes directly into parts_ without any CPU doorbells,
+ // wait_flag_ge spins, PCIe empty launches, or moe_hit_add copies.
+ all_resident_ = false;
+ if (hits.h_res != nullptr && hits.d_res != nullptr && hits.cache_base != nullptr) {
+ const char* v_ar = std::getenv("STRATA_VERIFY_ALL_RESIDENT");
+ if (v_ar == nullptr || std::atoi(v_ar) != 0) {
+ bool all_ok = true;
+ for (int64_t l = lb_; l < le_ && all_ok; ++l) {
+ for (int64_t e = 0; e < g.n_expert; ++e) {
+ if (hits.h_res[l * g.n_expert + e] < 0) {
+ all_ok = false;
+ break;
+ }
+ }
+ }
+ all_resident_ = all_ok;
+ }
+ }
// E-6: a layer whose routed experts are all resident is planned on the device (STRATA_VERIFY_DEVICE_PLAN=1: on;
// exact, but neutral on RIBPC 1-2 GPUs: off by default)
{
const char* v = std::getenv("STRATA_VERIFY_DEVICE_PLAN");
- device_plan_ = v != nullptr && std::atoi(v) != 0;
+ device_plan_ = !all_resident_ && (v != nullptr && std::atoi(v) != 0);
}
- if (device_plan_) {
- bool ok2 = cudaMalloc((void**) &skip_, 64) == cudaSuccess && cudaMemset(skip_, 0, 64) == cudaSuccess;
+ if (all_resident_ || device_plan_) {
+ bool ok2 = true;
+ if (device_plan_)
+ ok2 = cudaMalloc((void**) &skip_, 64) == cudaSuccess && cudaMemset(skip_, 0, 64) == cudaSuccess;
if (ok2 && hits.slot_off != nullptr && hits.n_slots > 0) {
ok2 = cudaMalloc((void**) &slot_off_d_, (size_t) hits.n_slots * sizeof(unsigned long long)) == cudaSuccess &&
cudaMemcpy(slot_off_d_, hits.slot_off, (size_t) hits.n_slots * sizeof(unsigned long long),
cudaMemcpyHostToDevice) == cudaSuccess;
}
- if (!ok2) { cudaGetLastError(); device_plan_ = false; }
+ if (!ok2) { cudaGetLastError(); all_resident_ = false; device_plan_ = false; }
}
- std::fprintf(stderr, "strata verify: window up to %d tokens, %.1f MiB of device buffers\n", max_t,
- (double) count.used / 1048576.0);
+ std::fprintf(stderr, "strata verify: window up to %d tokens, %.1f MiB of device buffers%s\n", max_t,
+ (double) count.used / 1048576.0,
+ all_resident_ ? " (100% VRAM resident: zero-doorbell graph)" : "");
return true;
}
@@ -403,11 +558,18 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
const int64_t TS = (s.idx_block - 1) * ID;
const bool ple_on = ss.ple.ready() && ple_stage();
auto Rt = [&](int t) { return R_ + (size_t) t * HC * N; };
- const int G = (split_ && T >= 2) ? 2 : 1;
+ const int G = (split_ && T >= 2 && !batch_rec_) ? 2 : 1; // a batch window is one group
static const bool dec_batch = [] { const char* v = std::getenv("STRATA_DEC_BATCH"); return v == nullptr || std::atoi(v) != 0; }();
- auto stamp = [&](int64_t l, int i, int grp) { if (prof_on_ && grp == 0) gpu_stamp(prof_, (int) (l * kProfPer + i), cs); };
+ auto stamp = [&](int64_t l, int i, int grp) {
+ if (prof_on_ && grp == 0) gpu_stamp(prof_, (int) (l * kProfPer + i), cs);
+ if (trace_m_ != nullptr) gpu_stamp(trace_m_, (int) ((l * kProfPer + i) * 2 + grp), cs); // #649
+ };
const int tb_[2] = {0, (T + 1) / 2}, te_[2] = {G == 2 ? (T + 1) / 2 : T, T};
- groups_[T] = G;
+ if (!batch_rec_) groups_[T] = G;
+ const int64_t nQall = g.n_qsa_layers();
+ // a batch window: row t is slot t, whose state lives in its own session
+ auto slot_ss = [&](int t) -> SessionState& { return batch_rec_ ? *slots_[(size_t) brow_[t]] : ss; };
+ const int hrow0 = batch_rec_ ? row_base_ : 0; // a slot group's own hand-off rows
// ---- the window's inputs, from mapped staging
copy_i32_from_mapped(tok_, m_tok_, T, cs);
@@ -416,17 +578,15 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
// per-ROW positions of the K rows [t][NKV] and the indexer query rows [t][IQ] (for batched RoPE)
const int32_t* pos_k = pos_ + MT * NH;
const int32_t* pos_i = pos_ + MT * (NH + NKV);
- if (ple_on) copy_from_mapped(ple_, m_ple_, (int64_t) T * N, cs);
// ---- the embeddings, broadcast to the hc streams - or, in a later stage of a layer split, the previous stage's
// residual, pending write and inject (see set_stage)
const int64_t HB = Verifier::handoff_floats(g);
if (lb_ > 0) {
- for (int t = 0; t < T; ++t) {
- copy_from_mapped(Rt(t), hand_in_ + (size_t) t * HB, HC * N, cs);
- copy_from_mapped(bo_ + (size_t) t * N, hand_in_ + (size_t) t * HB + HC * N, N, cs);
- copy_from_mapped(inj2_ + (size_t) t * HC, hand_in_ + (size_t) t * HB + HC * N + N, HC, cs);
- }
+ const float* hin = hand_in_ + (size_t) hrow0 * HB; // the group's rows: [R][bo][inj] contiguous
+ copy_from_mapped(R_, hin, (int64_t) T * HC * N, cs);
+ copy_from_mapped(bo_, hin + (size_t) T * HC * N, (int64_t) T * N, cs);
+ copy_from_mapped(inj2_, hin + (size_t) T * (HC + 1) * N, (int64_t) T * HC, cs);
} else if (const NativeEmbed* ne = native_embed()) { // plan v0.3 P6: the GGUF-form table
ne->gather_dev(tok_, T, emb_, cs);
broadcast_streams(emb_, R_, N, (int) HC, T, cs);
@@ -474,20 +634,48 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
// already applied it)
bool pending = l > 0 && !cvec().covers(l - 1);
if (l == 1 && ple_on) {
+ if (grp == 0) {
+ if (all_resident_) wait_flag_ge(m_flag_, 1, cs);
+ copy_from_mapped(ple_, m_ple_, (int64_t) T * N, cs);
+ }
float* normalized = (float*) ((uint8_t*) ss.ple.scratch + ple_block_scratch_bytes());
+ static const bool ple_batch_env = [] {
+ const char* e = std::getenv("STRATA_PLE_BATCH");
+ return !e || e[0] != '0';
+ }();
+ const bool ple_batch_kv = ple_batch_env && dec_batch && n > 1 && ss.ple.w.key_bf16 != nullptr &&
+ ss.ple.w.value_bf16 != nullptr && ple_native_bf16_enabled() &&
+ ple_native_postops_enabled();
+ if (ple_batch_kv) {
+ try {
+ bf16_gemv_fp32_mmvf_multi(ple_ + (size_t) tb * N, N, ss.ple.w.key_bf16,
+ xn_ + (size_t) tb * HC * N, HC * N, N, HC * N, n, cs);
+ bf16_gemv_fp32_mmvf_multi(ple_ + (size_t) tb * N, N, ss.ple.w.value_bf16,
+ z_ + (size_t) tb * N, N, N, N, n, cs);
+ } catch (const std::exception& e) {
+ err = std::string("verify PLE multi: ") + e.what();
+ return false;
+ }
+ }
for (int t = tb; t < te; ++t) {
gr_write(Rt(t), bo_ + t * N, inj2_ + t * HC, gs, Rt(t), cs);
PleOut po;
po.normalized = normalized;
po.result = Rt(t);
+ float* hist = batch_rec_ ? slot_ss(t).ple_hist : ss.ple.hist; // the row's own history
try {
- ple_block(ple_ + t * N, Rt(t), ss.ple.hist, ss.ple.w, po, ss.ple.scratch, cs);
- ple_history_advance(ss.ple.hist, normalized, cs);
+ if (ple_batch_kv) {
+ ple_block_projected(xn_ + (size_t) t * HC * N, z_ + (size_t) t * N, Rt(t), hist,
+ ss.ple.w, po, ss.ple.scratch, cs);
+ } else {
+ ple_block(ple_ + t * N, Rt(t), hist, ss.ple.w, po, ss.ple.scratch, cs);
+ }
+ ple_history_advance(hist, normalized, cs);
} catch (const std::exception& e) {
err = std::string("verify PLE: ") + e.what();
return false;
}
- copy_from_mapped(hist_snap_ + (size_t) t * HS, ss.ple.hist, HS, cs);
+ copy_from_mapped(hist_snap_ + (size_t) t * HS, hist, HS, cs);
}
pending = false;
}
@@ -530,6 +718,15 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
native_quantize_q8_1(xm, xq_, (int) N, n, cs);
native_mmvq(wqkv->native_type, wqkv->native_data, xq_, qkv + (size_t) tb * C, (int) N, (int) C, n, cs);
stamp(l, 2, grp);
+ if (batch_rec_) { // each row from its own slot's conv history, one row each
+ for (int t = tb; t < te; ++t) {
+ SessionState& sx = slot_ss(t);
+ const float* cx = sx.gdn_state + (size_t) (gi - sx.gdn_ord0) * gdn_floats +
+ (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size;
+ gdn_conv_l2_multi(cx, qkv + (size_t) t * C, (const float*) wc->data, hb + (size_t) t * C, (int) C,
+ (int) (2 * HK), EPS, 1, cs, 0);
+ }
+ } else
gdn_conv_l2_multi(conv, qkv, (const float*) wc->data, hb, (int) C, (int) (2 * HK), EPS, n, cs, tb);
stamp(l, 3, grp);
gdn_ab_multi(xm, (const uint16_t*) wa->data, (const uint16_t*) wb->data, (const float*) wdt->data,
@@ -539,6 +736,15 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
native_mmvq(wg->native_type, wg->native_data, xq_, z_ + (size_t) tb * ZV, (int) N, (int) ZV, n, cs);
stamp(l, 5, grp);
// the recurrence from the untouched state over tokens [0, te); outputs only for this group's
+ if (batch_rec_) { // each row's recurrence from its own slot's state, one token
+ for (int t = tb; t < te; ++t) {
+ SessionState& sx = slot_ss(t);
+ float* stx = sx.gdn_state + (size_t) (gi - sx.gdn_ord0) * gdn_floats;
+ gdn_step_norm_multi(stx, hb + (size_t) t * C, (int) C, gate + (size_t) t * HV, beta + (size_t) t * HV,
+ z_ + (size_t) t * ZV, (const float*) wnm->data, EPS, y_ + (size_t) t * ZV,
+ (int) HK, (int) HV, 1, nullptr, cs, 0);
+ }
+ } else
gdn_step_norm_multi(state, hb, (int) C, gate, beta, z_, (const float*) wnm->data, EPS, y_, (int) HK,
(int) HV, te, nullptr, cs, tb);
stamp(l, 6, grp);
@@ -583,8 +789,14 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
fwht256_inplace_cuda(vcur_ + tb * NKV * HD, (int64_t) n * NKV, cs);
}
stamp(l, 8, grp);
+ if (batch_rec_) { // every slot's indexer tail, restored by its commit
+ for (int t = tb; t < te; ++t)
+ copy_from_mapped(tail_snap_b_ + ((size_t) brow_[t] * nQall + qi) * TS, slot_ss(t).qsa_states[qi].idx_tail,
+ TS, cs);
+ } else
if (grp == 0) copy_from_mapped(tail_snap_ + (size_t) qi * TS, st.idx_tail, TS, cs);
for (int t = tb; t < te; ++t) {
+ const QsaState& st = slot_ss(t).qsa_states[qi]; // the row's own K/V (ss's outside a batch)
const int32_t* step_t = step_ + t * kStepCount;
if (st.kv_hybrid) { // K8V4: the unused half's lanes folded onto the used pool (layer.cpp)
kv_append_q8_step(st.k_q, st.k_q, st.k_scale, st.k_scale, st.page_table, step_t,
@@ -601,11 +813,13 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
kv_append_step(st.k_pool, st.v_pool, st.page_table, step_t, kcur_ + t * NKV * HD,
vcur_ + t * NKV * HD, s, cs, &st.host);
}
- const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos};
- for (int t = tb; t < te; ++t)
+ for (int t = tb; t < te; ++t) {
+ const QsaState& sx = slot_ss(t).qsa_states[qi];
+ const QsaIndexerBuffers ib{sx.idx_tail, sx.idx_dead, sx.idx_pooled, sx.idx_block_pos};
native_qsa_indexer_append(idx_raw + t * ID, step_ + t * kStepCount + kStepPos, 0,
- (const float*) wikn->data, EPS, ib, s, st.max_cells,
+ (const float*) wikn->data, EPS, ib, s, sx.max_cells,
rope_scaling(), cs);
+ }
stamp(l, 9, grp);
native_mmvq(wq->native_type, wq->native_data, xq_, qfull_ + tb * NH * 2 * HD, (int) N, (int) (NH * 2 * HD),
n, cs);
@@ -638,6 +852,20 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
}
}
stamp(l, 10, grp);
+ if (batch_rec_) { // each row selects and attends over its own slot's K/V
+ for (int t = tb; t < te; ++t) {
+ const QsaState& sx = slot_ss(t).qsa_states[qi];
+ qsa_block_scores(sx.idx_pooled, sx.idx_dead, qidx_ + t * IQ * ID, step_ + t * kStepCount, 1,
+ max_blocks_, s, scores_ + (size_t) t * max_blocks_, cs);
+ qsa_block_topk(scores_ + (size_t) t * max_blocks_, step_ + t * kStepCount, 1, max_blocks_, cap_, s,
+ sel_ + (size_t) t * cap_, cs);
+ qsa_kv_resolve(sx, *g_, sel_ + (size_t) t * cap_, step_ + t * kStepCount, 1, cap_, cs);
+ const QsaAttnPools px = qsa_attn_pools(sx);
+ qsa_decode_attn_batch(qcur_ + t * NH * HD, px, sel_ + (size_t) t * cap_, step_ + t * kStepCount,
+ cap_, s, attn_scratch_ + (size_t) t * attn_scratch_floats_,
+ attn_ + t * NH * HD, 1, cs);
+ }
+ } else {
qsa_block_scores(st.idx_pooled, st.idx_dead, qidx_ + tb * IQ * ID, step_ + tb * kStepCount, n, max_blocks_,
s, scores_ + (size_t) tb * max_blocks_, cs);
qsa_block_topk(scores_ + (size_t) tb * max_blocks_, step_ + tb * kStepCount, n, max_blocks_, cap_, s,
@@ -649,6 +877,7 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
const QsaAttnPools pools = qsa_attn_pools(st);
qsa_decode_attn_batch(qcur_ + tb * NH * HD, pools, sel_ + (size_t) tb * cap_, step_ + tb * kStepCount, cap_,
s, attn_scratch_ + (size_t) tb * attn_scratch_floats_, attn_ + tb * NH * HD, n, cs);
+ }
stamp(l, 13, grp);
if (st.kv_rot || st.kv_hybrid) fwht256_inplace_cuda(attn_ + tb * NH * HD, (int64_t) n * NH, cs); // back: H^-1 = H
if (qb) native_qsa_gate_apply(attn_ + tb * NH * HD, qfull_ + tb * NH * 2 * HD, attn32_ + tb * NH * HD,
@@ -671,6 +900,16 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
}
stamp(l, 16, grp);
gr_read_group(1, true, inj_, inj2_);
+ static const bool sh_stream_env = [] {
+ const char* e = std::getenv("STRATA_SH_STREAM");
+ return !e || e[0] != '0';
+ }();
+ const bool sh_fork = sh_stream_env && !prof_on_ && sh_cs_ != nullptr && ev_fork_ != nullptr && ev_join_ != nullptr;
+ cudaStream_t sh_stream = sh_fork ? sh_cs_ : cs;
+ if (sh_fork) {
+ cudaEventRecord(ev_fork_, cs);
+ cudaStreamWaitEvent(sh_cs_, ev_fork_, 0);
+ }
// the window's rows routed in 2 launches (one router GEMV reading the weight once, one
// top-10) instead of 2 per token; every row's arithmetic is the single-token call's (STRATA_DEC_BATCH=0: old)
const WeightRef* w_router = v.get("ffn_gate_inp.weight");
@@ -680,19 +919,46 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
NE, n, cs);
native_router_top10_multi(logits_ + tb * NE, ids_ + tb * K, w_ + tb * K, n, cs);
} catch (const std::exception& e) { err = "verify router: " + std::string(e.what()); return false; }
+#if defined(STRATA_HIP_GFX906)
+ } else if (dec_batch) {
+ // gfx906: the Coder's 256-expert router (the native router is 512 x 10 only) for the whole window in one
+ // multi-column BF16 projection and one top-k, bitwise the per-token calls (route_window_parity); ~3 ms
+ // of a ~55 ms verify window on 2x MI50. STRATA_ROUTE_PER_TOKEN=1: the per-token calls.
+ if (!moe_route_window(wt, g, l, K, ss.moe, mixed_ + tb * N, logits_ + tb * NE, ids_ + tb * K, w_ + tb * K, n,
+ cs, err))
+ return false;
+#endif
} else
for (int t = tb; t < te; ++t) {
MoEBuffers mb = ss.moe;
mb.logits = logits_ + t * NE; mb.ids = ids_ + t * K; mb.weights = w_ + t * K;
if (!moe_route(wt, g, l, K, mb, mixed_ + t * N, cs, err, nullptr)) return false;
}
- if (device_plan_) // E-6: every routed expert resident: this group's plan without the host
+ if (all_resident_) {
resident_plan(ids_ + tb * K, n * (int) K, (int) K, hits_.d_res + l * g.n_expert, (int) g.n_expert,
hits_.cache_base, slot_off_d_, (long long) hits_.blob,
- plan_ + (size_t) grp * (size_t) (plan_i32_ + 16), (long long) max_t_ * K, skip_ + grp,
- (uint32_t) ((l - lb_) * G + grp + 1), cs);
- doorbell_publish(xm, ids_ + tb * K, w_ + tb * K, (int64_t) n * N, (int64_t) n * K, m_x_ + tb * N,
- m_ids_ + tb * K, m_w_ + tb * K, m_seq_, cs);
+ plan_ + (size_t) grp * (size_t) (plan_i32_ + 16), (long long) max_t_ * K, nullptr, 0, cs);
+ } else {
+ if (device_plan_) // E-6: every routed expert resident: this group's plan without the host
+ resident_plan(ids_ + tb * K, n * (int) K, (int) K, hits_.d_res + l * g.n_expert, (int) g.n_expert,
+ hits_.cache_base, slot_off_d_, (long long) hits_.blob,
+ plan_ + (size_t) grp * (size_t) (plan_i32_ + 16), (long long) max_t_ * K, skip_ + grp,
+ (uint32_t) ((l - lb_) * G + grp + 1), cs);
+#if defined(STRATA_USE_HIP)
+ if (g_doorbell_store) // #649 A/B: the step's ring stored, not incremented over PCIe
+ doorbell_publish_value(xm, ids_ + tb * K, w_ + tb * K, (int64_t) n * N, (int64_t) n * K, m_x_ + tb * N,
+ m_ids_ + tb * K, m_w_ + tb * K, m_seq_, (uint32_t) ((l - lb_) * G + grp + 1), cs);
+ else
+#endif
+ if (remote_opt_) // #578: the helper GPUs reduce with the routing weights - publish them
+ doorbell_publish(xm, ids_ + tb * K, w_ + tb * K, (int64_t) n * N, (int64_t) n * K, m_x_ + tb * N,
+ m_ids_ + tb * K, m_w_ + tb * K, m_seq_, cs);
+ else {
+ const int32_t* layer_res = hits_.d_res != nullptr ? (hits_.d_res + l * g.n_expert) : nullptr;
+ doorbell_publish_res(xm, ids_ + tb * K, layer_res, (int) g.n_expert, (int64_t) n * N, (int64_t) n * K,
+ m_x_ + tb * N, m_ids_ + tb * K, m_seq_, cs);
+ }
+ }
stamp(l, 17, grp);
{
const WeightRef *wgi = need(v, "ffn_gate_inp_shexp.weight", err), *wsg = need(v, "ffn_gate_shexp.weight", err),
@@ -705,16 +971,19 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
nsw.gate_type = wsg->native_type; nsw.gate_data = wsg->native_data;
nsw.up_type = wsu->native_type; nsw.up_data = wsu->native_data;
nsw.down_type = wsd->native_type; nsw.down_data = wsd->native_data;
- nsw.q8_1 = xq_;
- if (dec_batch) f32_to_bf16_bulk(mixed_ + tb * N, sh_bf16_ + tb * N, (int64_t) n * N, cs); // contiguous rows
- else for (int t = tb; t < te; ++t) f32_to_bf16_bulk(mixed_ + t * N, sh_bf16_ + t * N, N, cs);
+ nsw.q8_1 = sh_fork ? sh_xq_ : xq_;
+ if (!shared_expert_native_bf16_enabled()) {
+ if (dec_batch) f32_to_bf16_bulk(mixed_ + tb * N, sh_bf16_ + tb * N, (int64_t) n * N, sh_stream); // contiguous rows
+ else for (int t = tb; t < te; ++t) f32_to_bf16_bulk(mixed_ + t * N, sh_bf16_ + t * N, N, sh_stream);
+ }
try {
shared_expert_multi(n, xm, sh_bf16_ + tb * N, nsw, (const uint16_t*) wgi->data, sh_gate_ + (size_t) tb * g.n_ff,
- sh_up_ + (size_t) tb * g.n_ff, sh_g_ + tb, shared_ + tb * N, N, g.n_ff, cs);
+ sh_up_ + (size_t) tb * g.n_ff, sh_g_ + tb, shared_ + tb * N, N, g.n_ff, sh_stream);
} catch (const std::exception& e) {
err = std::string("verify shared expert: ") + e.what();
return false;
}
+ if (sh_fork) cudaEventRecord(ev_join_, sh_cs_);
}
if (strata::kernels::cpu::expert_layout().native)
quantize_q8_1_rows(xm, n, N, nat_xq_ + (size_t) tb * (N / 32) * 36, cs);
@@ -725,19 +994,17 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
};
// ---------------------------------------------------------------- post(l, group): experts, combine
+ static const bool fuse_head_gr_env = [] {
+ const char* e = std::getenv("STRATA_FUSE_HEAD_GR");
+ return e && e[0] == '1';
+ }();
+ const bool fuse_head_gr = fuse_head_gr_env && (le_ == g.n_layers) && (head_ != nullptr && head_->loaded()) &&
+ !cvec().covers(g.n_layers - 1);
auto post = [&](int64_t l, int grp) -> bool {
const int tb = tb_[grp], te = te_[grp], n = te - tb;
const uint32_t ring = (uint32_t) ((l - lb_) * G + grp + 1);
const int64_t cap = (int64_t) n * K, capx = (int64_t) max_t_ * K;
int32_t* pl = plan_ + (size_t) grp * (size_t) (plan_i32_ + 16);
- if (device_plan_) { // E-6: skipped when the device planned this group (all its experts resident)
- wait_flag_ge_or(m_flagA_, ring, skip_ + grp, cs);
- copy_i32_from_mapped_unless(pl, m_plan_ + (size_t) grp * (size_t) plan_i32_, plan_i32_, skip_ + grp, ring, cs);
- } else {
- wait_flag_ge(m_flagA_, ring, cs); // the pool published this group's GPU plan
- copy_i32_from_mapped(pl, m_plan_ + (size_t) grp * (size_t) plan_i32_, plan_i32_, cs);
- }
- stamp(l, 19, grp);
const int32_t* p_counts = pl;
const int32_t* p_start = pl + 4;
const int32_t* p_dst = p_start + capx + 1;
@@ -747,50 +1014,70 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
const unsigned long long* p_ptr2 = p_ptr + capx;
const int32_t* p_start2 = pl + ptr_off + 4 * capx;
float* hit_out = hit_out_ + (size_t) tb * K * N;
+ float* parts_out = parts_ + (size_t) tb * K * N;
const auto& lay = strata::kernels::cpu::expert_layout();
// plan v0.3 P6: the VRAM groups now; the PCIe groups once the copy engine has landed them in staging.
// `gy`: the native launch's groups side by side (0: cap, one block row per possible group).
- auto grouped = [&](const unsigned long long* gp, const int32_t* gs, const int32_t* gn, int64_t gy) {
+ auto grouped = [&](const unsigned long long* gp, const int32_t* gs, const int32_t* gn, int64_t gy, float* dst_buf) {
if (lay.native) {
// the layer's GGUF formats (i-quant gate/up, Q2_0 / IQ4_NL down)
const auto& f = lay.fmt[(size_t) l];
const NativeExpertLayout L = native_expert_layout(f.gu_type, f.d_type, f.n_embd, f.n_ff);
native_expert_grouped(L, gp, gs, gn, p_dst, p_tok, cap, cap,
- nat_xq_ + (size_t) tb * (N / 32) * 36, hit_scratch_, hit_out, cs, gy);
+ nat_xq_ + (size_t) tb * (N / 32) * 36, hit_scratch_, dst_buf, cs, gy);
} else {
moe_grouped_s2(gp, gs, gn, p_dst, p_tok, cap, cap, hit_xq_ + (size_t) tb * (N / 32) * 34,
- hit_xs_ + (size_t) tb * (N / 32), hit_scratch_, hit_out, cs);
+ hit_xs_ + (size_t) tb * (N / 32), hit_scratch_, dst_buf, cs);
}
};
- grouped(p_ptr, p_start, p_counts, 0);
- stamp(l, 20, grp);
- if (device_plan_) wait_flag_ge_or(m_flagB_, ring, skip_ + grp, cs);
- else wait_flag_ge(m_flagB_, ring, cs); // the PCIe share is in staging (DMA) or mapped
- if (sink_.pcie_mode == 2) { // stage it with a copy kernel, then point at staging
- const int64_t per = G == 2 ? kStagingBlobs / 2 : kStagingBlobs;
- uint8_t* stage = staging_ + (size_t) (grp * per) * lay.max_blob;
- fetch_blobs(p_ptr2, p_counts + 2, stage, (int64_t) lay.blob_bytes(l), (int) per, cs);
- rebase_ptrs((unsigned long long*) p_ptr2, p_counts + 2, stage, (int64_t) lay.blob_bytes(l), cs);
- }
- stamp(l, 21, grp);
- // the PCIe share is pcie_frac of the misses: a few groups when the cache is cold, usually none (always none at
- // pcie_frac 0), so its launch is kPcieGroupRows block rows striding over the groups, not cap of them
- grouped(p_ptr2, p_start2, p_counts + 2, kPcieGroupRows);
- stamp(l, 22, grp);
- if (device_plan_) { // no CPU share when the device planned the group: its rows are zeros
- wait_flag_ge_or(m_flag_, ring, skip_ + grp, cs);
- copy_or_zero_from_mapped(parts_ + (size_t) tb * K * N, m_ymiss_ + (size_t) tb * K * N, (long long) n * K * N,
- skip_ + grp, ring, cs);
+ if (all_resident_) {
+ stamp(l, 19, grp);
+ grouped(p_ptr, p_start, p_counts, 0, parts_out);
+ stamp(l, 20, grp);
} else {
- wait_flag_ge(m_flag_, ring, cs); // the CPU's share is in the mapped rows
- stamp(l, 23, grp);
- if (dec_batch) // only the CPU rows cross PCIe (p_dst[0, counts[1]) = the GPU's own rows)
- copy_rows_from_mapped(parts_ + (size_t) tb * K * N, m_ymiss_ + (size_t) tb * K * N, (int64_t) n * K, N,
- p_dst, p_counts + 1, cs);
- else
- copy_from_mapped(parts_ + (size_t) tb * K * N, m_ymiss_ + (size_t) tb * K * N, (int64_t) n * K * N, cs);
+ if (device_plan_) { // E-6: skipped when the device planned this group (all its experts resident)
+ wait_flag_ge_or(m_flagA_, ring, skip_ + grp, cs);
+ copy_i32_from_mapped_unless(pl, m_plan_ + (size_t) grp * (size_t) plan_i32_, plan_i32_, skip_ + grp, ring, cs);
+ } else {
+ wait_flag_ge(m_flagA_, ring, cs); // the pool published this group's GPU plan
+ copy_i32_from_mapped(pl, m_plan_ + (size_t) grp * (size_t) plan_i32_, plan_i32_, cs);
+ }
+ stamp(l, 19, grp);
+ grouped(p_ptr, p_start, p_counts, 0, hit_out);
+ stamp(l, 20, grp);
+ if (device_plan_) wait_flag_ge_or(m_flagB_, ring, skip_ + grp, cs);
+ else wait_flag_ge(m_flagB_, ring, cs); // the PCIe share is in staging (DMA) or mapped
+ if (sink_.pcie_mode == 2) { // stage it with a copy kernel, then point at staging
+ const int64_t per = G == 2 ? kStagingBlobs / 2 : kStagingBlobs;
+ uint8_t* stage = staging_ + (size_t) (grp * per) * lay.max_blob;
+ fetch_blobs(p_ptr2, p_counts + 2, stage, (int64_t) lay.blob_bytes(l), (int) per, cs);
+ rebase_ptrs((unsigned long long*) p_ptr2, p_counts + 2, stage, (int64_t) lay.blob_bytes(l), cs);
+ }
+ stamp(l, 21, grp);
+ // the PCIe share is pcie_frac of the misses: a few groups when the cache is cold, usually none (always none at
+ // pcie_frac 0), so its launch is kPcieGroupRows block rows striding over the groups, not cap of them
+ grouped(p_ptr2, p_start2, p_counts + 2, kPcieGroupRows, hit_out);
+ stamp(l, 22, grp);
+ if (device_plan_) { // no CPU share when the device planned the group: its rows are zeros
+ wait_flag_ge_or(m_flag_, ring, skip_ + grp, cs);
+ copy_or_zero_from_mapped(parts_out, m_ymiss_ + (size_t) tb * K * N, (long long) n * K * N,
+ skip_ + grp, ring, cs);
+ } else {
+ wait_flag_ge(m_flag_, ring, cs); // the CPU's share is in the mapped rows
+ stamp(l, 23, grp);
+ if (remote_opt_) // #578: the helper's rows come back reduced; skip them as well
+ remote_opt_->copy_rows(parts_out, m_ymiss_ + (size_t) tb * K * N, tb, n, p_dst, p_counts + 1, cs);
+ else if (dec_batch) // only the CPU rows cross PCIe (p_dst[0, counts[1]) = the GPU's own rows)
+ copy_rows_from_mapped(parts_out, m_ymiss_ + (size_t) tb * K * N, (int64_t) n * K, N,
+ p_dst, p_counts + 1, cs);
+ else
+ copy_from_mapped(parts_out, m_ymiss_ + (size_t) tb * K * N, (int64_t) n * K * N, cs);
+ }
+ moe_hit_add(parts_out, hit_out, p_dst, p_counts + 1, cap, N, cs);
+ }
+ if (!prof_on_ && sh_cs_ != nullptr && ev_fork_ != nullptr && ev_join_ != nullptr) {
+ cudaStreamWaitEvent(cs, ev_join_, 0);
}
- moe_hit_add(parts_ + (size_t) tb * K * N, hit_out, p_dst, p_counts + 1, cap, N, cs);
if (dec_batch && n > 1 && native_moe_combine_enabled()) { // one launch for the window's rows
try {
native_moe_combine_multi(parts_ + (size_t) tb * K * N, w_ + tb * K, shared_ + tb * N, bo_ + tb * N, N, K, n, cs);
@@ -801,10 +1088,14 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
mb.weights = w_ + t * K; mb.shared = shared_ + t * N;
if (!moe_combine_parts(g, l, K, mb, parts_ + (size_t) t * K * N, bo_ + t * N, cs, err)) return false;
}
+ if (remote_opt_) remote_opt_->combine(bo_ + tb * N, y_dummy_ + tb * N, tb, n,
+ device_plan_ ? skip_ + grp : nullptr, ring, cs);
stamp(l, 24, grp);
if (l == g.n_layers - 1) {
- for (int t = tb; t < te; ++t) gr_write(Rt(t), bo_ + t * N, inj2_ + t * HC, gs, Rt(t), cs);
- if (cvec().covers(l)) cvec_apply(Rt(tb), l, n, HC * N, nullptr, 0, nullptr, 0, false, cs);
+ if (!fuse_head_gr) {
+ for (int t = tb; t < te; ++t) gr_write(Rt(t), bo_ + t * N, inj2_ + t * HC, gs, Rt(t), cs);
+ if (cvec().covers(l)) cvec_apply(Rt(tb), l, n, HC * N, nullptr, 0, nullptr, 0, false, cs);
+ }
} else if (cvec().covers(l)) {
cvec_apply(Rt(tb), l, n, HC * N, bo_ + tb * N, N, inj2_ + tb * HC, HC, true, cs);
}
@@ -819,11 +1110,10 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
if (l + 1 < le_ && !pre(l + 1, grp)) return false;
}
if (le_ < g.n_layers) { // a layer split's earlier stage: hand the residual on, no head
- for (int t = 0; t < T; ++t) {
- copy_from_mapped(hand_out_ + (size_t) t * HB, Rt(t), HC * N, cs);
- copy_from_mapped(hand_out_ + (size_t) t * HB + HC * N, bo_ + (size_t) t * N, N, cs);
- copy_from_mapped(hand_out_ + (size_t) t * HB + HC * N + N, inj2_ + (size_t) t * HC, HC, cs);
- }
+ float* hout = hand_out_ + (size_t) hrow0 * HB;
+ copy_from_mapped(hout, R_, (int64_t) T * HC * N, cs);
+ copy_from_mapped(hout + (size_t) T * HC * N, bo_, (int64_t) T * N, cs);
+ copy_from_mapped(hout + (size_t) T * (HC + 1) * N, inj2_, (int64_t) T * HC, cs);
return true;
}
@@ -833,14 +1123,33 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
const WeightRef *hn = wt.find("output_hc_norm.weight"), *hd = wt.find("output_hc_down.weight"),
*hu = wt.find("output_hc_up.weight");
if (!hn || !hd || !hu) { err = "verify: an output_hc_* weight is missing"; return false; }
- for (int t = 0; t < T; ++t) {
- BlockBuffers bb = ss.block;
- bb.R = Rt(t);
- bb.mixed = head_mixed_ + t * N;
- if (head_ != nullptr && head_->loaded()) {
- if (!lm_head_mix(wt, g, bb, cs, err)) return false;
- } else if (!lm_head(wt, g, bb, head_logits_ + (size_t) t * n_vocab_, cs, err)) {
- return false;
+ if (fuse_head_gr) {
+ FusedGrArgs fa[kFusedGrMaxT];
+ for (int t = 0; t < T; ++t) {
+ FusedGrArgs& a = fa[t];
+ a.R = Rt(t); a.R_out = Rt(t); a.apply = true;
+ a.bo_prev = bo_ + t * N; a.inj_prev = inj2_ + t * HC;
+ a.w_norm = (const float*) hn->data;
+ a.w_down = (const uint16_t*) hd->data;
+ a.w_up = (const uint16_t*) hu->data;
+ a.w_inject = nullptr;
+ a.eps = EPS;
+ a.lo = lo_ + t * g.hc_lr;
+ a.rs = rs_ + t * HC;
+ a.inject_out = head_inj_;
+ a.mixed = head_mixed_ + t * N;
+ }
+ fused_gr_read_multi(fa, T, xn_, cs);
+ } else {
+ for (int t = 0; t < T; ++t) {
+ BlockBuffers bb = ss.block;
+ bb.R = Rt(t);
+ bb.mixed = head_mixed_ + t * N;
+ if (head_ != nullptr && head_->loaded()) {
+ if (!lm_head_mix(wt, g, bb, cs, err)) return false;
+ } else if (!lm_head(wt, g, bb, head_logits_ + (size_t) t * n_vocab_, cs, err)) {
+ return false;
+ }
}
}
if (head_ != nullptr && head_->loaded()) {
@@ -984,7 +1293,7 @@ bool Verifier::capture_commit(std::string& err) {
gdn_conv_commit(conv, qkv, (int) C, commit_, cs_);
gdn_step_norm_multi(state, h_L_ + (size_t) gdn_index * MT * C, (int) C, gate_L_ + (size_t) gdn_index * MT * HV,
beta_L_ + (size_t) gdn_index * MT * HV, z_, (const float*) wnm->data, EPS, y_dummy_,
- (int) g.ssm_k_heads, (int) HV, (int) MT, commit_, cs_);
+ (int) g.ssm_k_heads, (int) HV, (int) MT, commit_, cs_, (int) MT);
++gdn_index;
} else {
const QsaState& st = ss.qsa_states[qsa_index];
@@ -1019,10 +1328,70 @@ bool Verifier::capture_commit(std::string& err) {
return true;
}
+void Verifier::stage_inputs(int T, const int32_t* tokens, int64_t pos0) {
+ using namespace strata::kernels;
+ const ModelGeometry& g = *g_;
+ const QsaShapes s = shapes_of(g);
+ int32_t* const pk = h_pos_ + (size_t) max_t_ * g.n_head;
+ int32_t* const pi = pk + (size_t) max_t_ * g.n_head_kv;
+ for (int t = 0; t < T; ++t) {
+ h_tok_[t] = tokens[t];
+ qsa_step_fill(h_step_ + t * kStepCount, pos0 + t, s);
+ const int32_t pos_t = (int32_t) (pos0 + t);
+ for (int64_t h = 0; h < g.n_head; ++h) h_pos_[t * g.n_head + h] = pos_t;
+ for (int64_t h = 0; h < g.n_head_kv; ++h) pk[t * g.n_head_kv + h] = pos_t;
+ for (int64_t h = 0; h < g.idx_q_heads; ++h) pi[t * g.idx_q_heads + h] = pos_t;
+ }
+ *(volatile uint32_t*) h_seq_ = 0;
+ *(volatile uint32_t*) h_flag_ = 0;
+ *(volatile uint32_t*) h_flagA_ = 0;
+ *(volatile uint32_t*) h_flagB_ = 0;
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ last_t_ = T;
+ last_pos0_ = pos0;
+ for (int t = 0; t < T; ++t) last_tokens_[t] = tokens[t];
+ staged_ = true;
+}
+
+void Verifier::collect_profile() {
+ const ModelGeometry& g = *g_;
+ cudaMemcpy(prof_h_.data(), prof_, prof_h_.size() * 8, cudaMemcpyDeviceToHost);
+ const int64_t L = g.n_layers;
+ auto at = [&](int64_t l, int i) { return prof_h_[(size_t) (l * kProfPer + i)]; };
+ // D8: the derived columns below read stamps the hc-read kernels write themselves or the next
+ // layer's first. A slot no kernel stamped is 0 and its unsigned difference wrapped to ~1e19 ns -
+ // which is why (gap)/head/hc0 printed ~1e14 ms per window. A missing or out-of-order stamp now
+ // contributes nothing.
+ const auto gap = [](unsigned long long to, unsigned long long from) {
+ return (from != 0 && to != 0 && to >= from) ? (double) (to - from) : 0.0;
+ };
+ for (int64_t l = 0; l < L; ++l) {
+ const int kind = is_qsa_layer(g, l) ? 1 : 0;
+ unsigned long long prev = at(l, 0);
+ for (int i = 1; i <= 24; ++i) {
+ const unsigned long long x = at(l, i);
+ if (x == 0 || x < prev) continue;
+ prof_sum_[kind][i] += (double) (x - prev);
+ prev = x;
+ }
+ if (l + 1 < L) prof_sum_[kind][25] += gap(at(l + 1, 0), at(l, 24));
+ const double dn = gap(at(l, 27), at(l, 0)), dd = gap(at(l, 28), at(l, 27)), du = gap(at(l, 1), at(l, 28));
+ if (dn > 0 && dd > 0 && du > 0) { // the split exists: show it split, not twice
+ prof_sum_[kind][27] += dn; // hc-read0: norm
+ prof_sum_[kind][28] += dd; // down
+ prof_sum_[kind][29] += du; // up (through both halves, as before)
+ prof_sum_[kind][1] -= gap(at(l, 1), at(l, 0)); // (hc-read0 shown split)
+ }
+ }
+ prof_sum_[0][26] += gap(at(L, 1), at(L, 0));
+ ++prof_windows_;
+}
+
bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool, void* user, int32_t* out,
std::string& err) {
using namespace strata::kernels;
const OnDevice on_device(device_);
+ last_batch_ = false;
if (T < 1 || T > max_t_) { err = "verify: window size out of range"; return false; }
if (released_.load()) { err = "verify: an earlier window never finished on the GPU (#267); restart the engine"; return false; }
const ModelGeometry& g = *g_;
@@ -1031,37 +1400,26 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
if (!capture(T, err) || !capture_commit(err)) return false;
VDBG("captured; staging\n");
const Clock::time_point t0 = Clock::now();
- const QsaShapes s = shapes_of(g);
- for (int t = 0; t < T; ++t) {
- h_tok_[t] = tokens[t];
- qsa_step_fill(h_step_ + t * kStepCount, pos0 + t, s);
- for (int64_t h = 0; h < g.n_head; ++h) h_pos_[t * g.n_head + h] = (int32_t) (pos0 + t);
- int32_t* pk = h_pos_ + (size_t) max_t_ * g.n_head;
- int32_t* pi = pk + (size_t) max_t_ * g.n_head_kv;
- for (int64_t h = 0; h < g.n_head_kv; ++h) pk[t * g.n_head_kv + h] = (int32_t) (pos0 + t);
- for (int64_t h = 0; h < g.idx_q_heads; ++h) pi[t * g.idx_q_heads + h] = (int32_t) (pos0 + t);
- }
- if (ss.ple.ready() && ple_stage()) {
- uint32_t rows[kVerifyMaxT * PLE_N_HEADS];
+ if (!staged_) stage_inputs(T, tokens, pos0);
+ staged_ = false;
+ const bool do_ple = ss.ple.ready() && ple_stage();
+ uint32_t ple_rows[kVerifyMaxT * PLE_N_HEADS];
+ if (do_ple) {
int32_t prev[2] = {ss.ple_prev[0], ss.ple_prev[1]};
for (int t = 0; t < T; ++t) {
- ngram_rows(&tokens[t], prev, 1, ss.ple.consts, rows + t * PLE_N_HEADS);
+ ngram_rows(&tokens[t], prev, 1, ss.ple.consts, ple_rows + t * PLE_N_HEADS);
prev[0] = prev[1];
prev[1] = tokens[t];
+ ss.ple.table->prefetch_rows(ple_rows + t * PLE_N_HEADS);
}
- if (!ss.ple.table->gather_batch(rows, (size_t) T, h_ple_, err)) return false;
}
- *(volatile uint32_t*) h_seq_ = 0;
- *(volatile uint32_t*) h_flag_ = 0;
- *(volatile uint32_t*) h_flagA_ = 0;
- *(volatile uint32_t*) h_flagB_ = 0;
+ if (trace_h_ != nullptr) std::memset(trace_h_, 0, trace_n_ * 8); // #649: this window's breadcrumbs only
std::atomic_thread_fence(std::memory_order_seq_cst);
- last_t_ = T;
- last_pos0_ = pos0;
- for (int t = 0; t < T; ++t) last_tokens_[t] = tokens[t];
+ trace_ev("WINDOW", -1, -1, pos0 * 16 + T);
ms_host += ms_since(t0);
VDBG("staged; launching\n");
const cudaError_t le = cudaGraphLaunch(exec_[T], cs_);
+ trace_ev("LAUNCHED", -1, -1, (int64_t) le);
if (le != cudaSuccess) { err = std::string("verify: launch: ") + cudaGetErrorString(le); return false; }
(void) cudaStreamQuery(cs_);
VDBG("launched\n");
@@ -1071,6 +1429,18 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
const int gtb[2] = {0, (T + 1) / 2}, gte[2] = {G == 2 ? (T + 1) / 2 : T, T};
const int64_t steps = (le_ - lb_) * G;
const bool test_stall = g_test_stall > 0 && windows + 1 == g_test_stall; // #267 test hook (off: false)
+ if (all_resident_ && !test_stall) {
+ if (do_ple) {
+ const Clock::time_point tp = Clock::now();
+ if (!ss.ple.table->gather_batch(ple_rows, (size_t) T, h_ple_, err)) return false;
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ _mm_sfence();
+ *flag = 1;
+ ms_host += ms_since(tp);
+ } else {
+ *flag = 1;
+ }
+ } else
for (int64_t k = 0; k < steps; ++k) {
const int64_t l = lb_ + k / G;
const int grp = (int) (k % G);
@@ -1087,6 +1457,8 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
last_flush = now;
const cudaError_t q = cudaStreamQuery(cs_);
if (q != cudaErrorNotReady && *seq < want) {
+ trace_ev("NEVER-RANG", k, l, (int64_t) q);
+ trace_dump(stderr);
err = "verify: layer " + std::to_string(l) + " never rang (" +
(q == cudaSuccess ? std::string("graph finished") : std::string(cudaGetErrorString(q))) + ")";
return false;
@@ -1094,20 +1466,33 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
}
if (now - a > std::chrono::seconds(20)) {
// #267: the caller ends the engine; no spin kernel may outlive it
+ trace_ev("TIMEOUT", k, l, (int64_t) cudaStreamQuery(cs_));
+ if (g_trace) {
+ diag(stderr); // the words and the breadcrumbs before the release ...
+ const bool drained = release_gpu_waits(5000);
+ trace_dump(stderr); // ... and after it: did the GPU move once its waits were raised?
+ err = "verify: timed out at layer " + std::to_string(l) + released_note(drained);
+ return false;
+ }
err = "verify: timed out at layer " + std::to_string(l) + released_note(release_gpu_waits(5000));
return false;
}
}
const Clock::time_point b = Clock::now();
+ if (g_trace) trace_ev("RANG", k, l, (int64_t) std::chrono::duration_cast(b - a).count());
VDBG("layer %lld rang\n", (long long) l);
cur_layer_ = want - 1;
set_plan_slot(grp);
const int tb = gtb[grp], n = gte[grp] - gtb[grp];
progress_at("verify window: the CPU experts of layer", l);
+ if (remote_opt_) remote_opt_->begin(h_w_ + (size_t) tb * ss.k, tb, n);
if (pool != nullptr)
pool(user, h_x_ + (size_t) tb * g.n_embd, h_ids_ + (size_t) tb * ss.k, n, ss.k,
h_ymiss_ + (size_t) tb * ss.k * g.n_embd, l);
+ if (remote_opt_) remote_opt_->end();
VDBG("layer %lld served\n", (long long) l);
+ if (g_trace) trace_ev(*(volatile uint32_t*) h_flagA_ == want ? "SERVED" : "SERVED-NO-PLAN-YET", k, l,
+ (int64_t) ms_since(b)); // aux: ms the CPU experts took
progress_tick();
std::atomic_thread_fence(std::memory_order_seq_cst);
_mm_sfence();
@@ -1121,52 +1506,39 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
*(volatile uint32_t*) h_flagA_ = want;
raise_flag(h_flagB_, want);
}
+ // Layer 1's pre(1, 0) copies h_ple_ -> ple_ after Layer 0's wait_flag_ge(m_flag_, 1).
+ // By collecting PLE here at k == 0 (after publishing flagA/flagB for Layer 0 so the GPU can run
+ // Layer 0's VRAM experts, and before raising *flag = 1), the NVMe PLE reads overlap with both
+ // MtpDrafter::draft and Layer 0's attention + router + expert execution!
+ if (k == 0 && do_ple) {
+ const Clock::time_point tp = Clock::now();
+ if (!ss.ple.table->gather_batch(ple_rows, (size_t) T, h_ple_, err)) return false;
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ _mm_sfence();
+ ms_host += ms_since(tp);
+ }
if (!(test_stall && k + 1 == steps)) *flag = want;
ms_wait += std::chrono::duration(b - a).count();
ms_pool += ms_since(b);
}
+ // (#646 staged the next stage's inputs here; 0.1.39b keeps the layer split's order: each stage stages its own)
progress_at("verify window: waiting for the GPU to finish the window (flags A/B/M raised)", (int64_t) T);
// #267: a window the GPU never finishes (a spin kernel that never sees its flag) holds the host here; the stall
// watchdog then releases every verifier's GPU waits (release_live_verifiers) before it ends the engine, so no
// spin kernel outlives the process - the case that left Windows GPUs "lost" until a power cycle. The wait
// itself stays a blocking sync: a cudaStreamQuery poll here cost IQ3_S ~3% decode (a core calling the driver
// beside the expert workers).
+ trace_ev("SYNC", -1, -1, 0);
const cudaError_t se = cudaStreamSynchronize(cs_);
+ trace_ev("SYNCED", -1, -1, (int64_t) se);
if (se != cudaSuccess) { err = std::string("verify: ") + cudaGetErrorString(se); return false; }
+ commit_pending_ = false;
progress_at("verify window: waiting for the expert copies", (int64_t) T);
- cudaStreamSynchronize(copy_); // no host function of this window may raise flag B in the next one
- if (prof_on_ && G == 1) { // the window's GPU stage stamps
- cudaMemcpy(prof_h_.data(), prof_, prof_h_.size() * 8, cudaMemcpyDeviceToHost);
- const int64_t L = g.n_layers;
- auto at = [&](int64_t l, int i) { return prof_h_[(size_t) (l * kProfPer + i)]; };
- // D8: the derived columns below read stamps the hc-read kernels write themselves or the next
- // layer's first. A slot no kernel stamped is 0 and its unsigned difference wrapped to ~1e19 ns -
- // which is why (gap)/head/hc0 printed ~1e14 ms per window. A missing or out-of-order stamp now
- // contributes nothing.
- const auto gap = [](unsigned long long to, unsigned long long from) {
- return (from != 0 && to != 0 && to >= from) ? (double) (to - from) : 0.0;
- };
- for (int64_t l = 0; l < L; ++l) {
- const int kind = is_qsa_layer(g, l) ? 1 : 0;
- unsigned long long prev = at(l, 0);
- for (int i = 1; i <= 24; ++i) {
- const unsigned long long x = at(l, i);
- if (x == 0 || x < prev) continue;
- prof_sum_[kind][i] += (double) (x - prev);
- prev = x;
- }
- if (l + 1 < L) prof_sum_[kind][25] += gap(at(l + 1, 0), at(l, 24));
- const double dn = gap(at(l, 27), at(l, 0)), dd = gap(at(l, 28), at(l, 27)), du = gap(at(l, 1), at(l, 28));
- if (dn > 0 && dd > 0 && du > 0) { // the split exists: show it split, not twice
- prof_sum_[kind][27] += dn; // hc-read0: norm
- prof_sum_[kind][28] += dd; // down
- prof_sum_[kind][29] += du; // up (through both halves, as before)
- prof_sum_[kind][1] -= gap(at(l, 1), at(l, 0)); // (hc-read0 shown split)
- }
- }
- prof_sum_[0][26] += gap(at(L, 1), at(L, 0));
- ++prof_windows_;
+ if (copy_used_) {
+ cudaStreamSynchronize(copy_); // no host function of this window may raise flag B in the next one
+ copy_used_ = false;
}
+ if (prof_on_ && G == 1) collect_profile(); // the window's GPU stage stamps
// ---- a sampled or penalized request: the head's sampling again, host-side so its parameters are this call's
// own (a captured kernel would replay the same draws forever). Row t's draw is Philox(seed, pos0 + t): tied to
// the POSITION it samples, not to how the text was cut into windows, so a seed replays the same text whatever
@@ -1221,7 +1593,7 @@ void Verifier::set_plan_slot(int grp) {
sink_.ptr = (unsigned long long*) (base + ptr_off);
sink_.ptr2 = sink_.ptr + cap;
sink_.start2 = base + ptr_off + 4 * cap;
- const int G = groups_[last_t_] > 0 ? groups_[last_t_] : 1;
+ const int G = last_batch_ ? 1 : (groups_[last_t_] > 0 ? groups_[last_t_] : 1);
const int64_t per = G == 2 ? kStagingBlobs / 2 : kStagingBlobs;
sink_.staging = (unsigned long long) (staging_ + (size_t) (grp * per) * strata::kernels::cpu::expert_layout().max_blob);
sink_.staging_cap = per;
@@ -1250,6 +1622,7 @@ void Verifier::fetch_dma(void* ctx, const uint8_t* const* src, int n, size_t byt
Verifier* v = (Verifier*) ctx;
const uint32_t want = v->cur_layer_ + 1;
if (n <= 0) { raise_flag(v->h_flagB_, want); return; }
+ v->copy_used_ = true;
uint8_t* stage = (uint8_t*) v->sink_.staging; // this group's half in a split window
for (int i = 0; i < n; ++i) cudaMemcpyAsync(stage + (size_t) i * bytes, src[i], bytes, cudaMemcpyHostToDevice, v->copy_);
FlagSet& fs = v->flag_sets_[v->cur_layer_ % (sizeof v->flag_sets_ / sizeof v->flag_sets_[0])];
@@ -1342,6 +1715,7 @@ bool Verifier::commit(int n_keep, std::string& err) {
} else {
const cudaError_t re = cudaEventRecord(commit_done_, cs_);
if (re != cudaSuccess) { err = std::string("verify: commit event: ") + cudaGetErrorString(re); return false; }
+ (void) cudaStreamQuery(cs_);
commit_pending_ = true;
}
if (ple_stage()) // stages that share one session must advance it once
@@ -1363,6 +1737,417 @@ bool Verifier::wait_commit(std::string& err) {
return next_ == nullptr || next_->wait_commit(err);
}
+
+// ================================ BATCH WINDOWS (see init_slots) ================================
+
+bool Verifier::init_slots(const std::vector& slots, std::string& err) {
+ const OnDevice on_device(device_);
+ if (g_ == nullptr || ss_ == nullptr) { err = "verify: init_slots before init"; return false; }
+ if (slots.empty() || (int) slots.size() > max_t_) {
+ err = "verify: init_slots needs 1.." + std::to_string(max_t_) + " sessions";
+ return false;
+ }
+ for (SessionState* x : slots) {
+ if (x == nullptr || x->layer_lo != ss_->layer_lo || x->layer_hi != ss_->layer_hi ||
+ x->max_cells != ss_->max_cells || x->gdn_ord0 != ss_->gdn_ord0 || x->qsa_ord0 != ss_->qsa_ord0) {
+ err = "verify: a slot's session is not carved like the stage's own (layer range, context)";
+ return false;
+ }
+ }
+ const strata::kernels::QsaShapes s = shapes_of(*g_);
+ const int64_t S = (int64_t) slots.size(), CB = 2 + max_t_;
+ const int64_t TS = (s.idx_block - 1) * g_->idx_key_dim, nQ = g_->n_qsa_layers();
+ void* d = nullptr;
+ if (!mapped((size_t) (S * CB * 4 + 16), (void**) &h_commitb_, (void**) &m_commitb_)) {
+ err = "verify: the batch commit staging failed";
+ return false;
+ }
+ const uint64_t a = ((uint64_t) S * CB * 4 + 255) & ~255ull;
+ if (cudaMalloc(&d, a + (uint64_t) S * std::max(nQ, 1) * TS * 4) != cudaSuccess) {
+ err = "verify: the batch buffers do not fit";
+ return false;
+ }
+ arena_b_ = d;
+ commitb_ = (int32_t*) d;
+ tail_snap_b_ = (float*) ((uint8_t*) d + a);
+ slots_ = slots;
+ slot_sp_.assign(slots.size(), sampling_); // greedy until set_slot_sampling
+ std::fprintf(stderr, "strata verify: batch windows of up to %lld sequences (layers [%lld, %lld))\n", (long long) S,
+ (long long) lb_, (long long) le_);
+ return true;
+}
+
+bool Verifier::capture_batch(const int* rows, int S, int hbase, std::string& err) {
+ cudaGraphExec_t& ex = exec_bm_[batch_key(rows, S, hbase)];
+ if (ex != nullptr) return true;
+ if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) {
+ err = "verify: begin batch capture failed";
+ return false;
+ }
+ batch_rec_ = true;
+ row_base_ = hbase;
+ for (int t = 0; t < S; ++t) brow_[t] = rows[t];
+ std::string rerr;
+ const bool ok = record_window(S, cs_, rerr);
+ batch_rec_ = false;
+ row_base_ = 0;
+ cudaGraph_t graph = nullptr;
+ const cudaError_t ce = cudaStreamEndCapture(cs_, &graph);
+ if (!ok || ce != cudaSuccess) {
+ if (graph) cudaGraphDestroy(graph);
+ err = !ok ? rerr : std::string("verify: end batch capture: ") + cudaGetErrorString(ce);
+ return false;
+ }
+ const cudaError_t ie = cudaGraphInstantiate(&ex, graph, 0);
+ cudaGraphDestroy(graph);
+ if (ie != cudaSuccess) { err = std::string("verify: batch instantiate: ") + cudaGetErrorString(ie); return false; }
+ cudaGraphUpload(ex, cs_);
+ cudaStreamSynchronize(cs_);
+ std::string list;
+ for (int t = 0; t < S; ++t) list += (t ? "," : "") + std::to_string(rows[t]);
+ std::fprintf(stderr, "strata verify: captured the batch window over slots %s\n", list.c_str());
+ return true;
+}
+
+bool Verifier::capture_commit_batch(const int* rows, int S, int hbase, std::string& err) {
+ cudaGraphExec_t& cex = commit_bm_[batch_key(rows, S, hbase)];
+ if (cex != nullptr) return true;
+ using namespace strata::kernels;
+ const ModelGeometry& g = *g_;
+ const QsaShapes s = shapes_of(g);
+ const int64_t C = g.ssm_conv_channels, HV = g.ssm_v_heads, ZV = g.ssm_value_dim, ID = g.idx_key_dim, MT = max_t_;
+ const int64_t CB = 2 + MT, nQ = g.n_qsa_layers();
+ const uint64_t gdn_floats = (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size +
+ (uint64_t) g.ssm_conv_channels * (g.ssm_d_conv - 1);
+ const int64_t TS = (s.idx_block - 1) * ID;
+ const int64_t HS = (int64_t) NG_HIST * NG_HC_DIM;
+ if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) {
+ err = "verify: begin batch commit capture failed";
+ return false;
+ }
+ bool ok = true;
+ try {
+ for (int t = 0; t < S; ++t)
+ copy_i32_from_mapped(commitb_ + (size_t) rows[t] * CB, m_commitb_ + (size_t) rows[t] * CB, CB, cs_);
+ int64_t qsa_index = 0, gdn_index = 0;
+ for (int64_t l = 0; l < lb_; ++l) (is_qsa_layer(g, l) ? qsa_index : gdn_index) += 1;
+ for (int64_t l = lb_; l < le_ && ok; ++l) {
+ const LayerView v(*wt_, l);
+ if (!is_qsa_layer(g, l)) {
+ const WeightRef* wnm = need(v, "ssm_norm.weight", err);
+ if (!wnm) { ok = false; break; }
+ for (int t = 0; t < S; ++t) {
+ SessionState& sx = *slots_[(size_t) rows[t]];
+ const int32_t* keep = commitb_ + (size_t) rows[t] * CB;
+ float* state = sx.gdn_state + (size_t) (gdn_index - sx.gdn_ord0) * gdn_floats;
+ float* conv = state + (uint64_t) g.ssm_state_size * g.ssm_v_heads * g.ssm_state_size;
+ gdn_conv_commit(conv, qkv_L_ + (size_t) gdn_index * MT * C + (size_t) t * C, (int) C, keep, cs_);
+ gdn_step_norm_multi(state, h_L_ + (size_t) gdn_index * MT * C + (size_t) t * C, (int) C,
+ gate_L_ + (size_t) gdn_index * MT * HV + (size_t) t * HV,
+ beta_L_ + (size_t) gdn_index * MT * HV + (size_t) t * HV, z_ + (size_t) t * ZV,
+ (const float*) wnm->data, EPS, y_dummy_ + (size_t) t * ZV, (int) g.ssm_k_heads,
+ (int) HV, 1, keep, cs_);
+ }
+ ++gdn_index;
+ } else {
+ const WeightRef* wikn = need(v, "indexer.k_norm.weight", err);
+ if (!wikn) { ok = false; break; }
+ for (int t = 0; t < S; ++t) {
+ const QsaState& st = slots_[(size_t) rows[t]]->qsa_states[qsa_index];
+ copy_from_mapped(st.idx_tail, tail_snap_b_ + ((size_t) rows[t] * nQ + qsa_index) * TS, TS, cs_);
+ const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos};
+ native_qsa_indexer_append(idx_raw_L_ + (size_t) (qsa_index * MT + t) * ID, commitb_ + (size_t) rows[t] * CB + 2,
+ 0, (const float*) wikn->data, EPS, ib, s, st.max_cells, rope_scaling(), cs_);
+ }
+ ++qsa_index;
+ }
+ }
+ if (ok && ss_->ple.ready() && ple_stage())
+ for (int t = 0; t < S; ++t)
+ copy_indexed(slots_[(size_t) rows[t]]->ple_hist, hist_snap_ + (size_t) t * HS, HS,
+ commitb_ + (size_t) rows[t] * CB + 1, HS, cs_);
+ } catch (const std::exception& e) {
+ err = std::string("verify batch commit: ") + e.what();
+ ok = false;
+ }
+ cudaGraph_t graph = nullptr;
+ const cudaError_t ce = cudaStreamEndCapture(cs_, &graph);
+ if (!ok) {
+ if (graph) cudaGraphDestroy(graph);
+ return false;
+ }
+ if (ce != cudaSuccess || cudaGraphInstantiate(&cex, graph, 0) != cudaSuccess) {
+ if (graph) cudaGraphDestroy(graph);
+ err = std::string("verify: batch commit capture: ") + cudaGetErrorString(ce);
+ return false;
+ }
+ cudaGraphDestroy(graph);
+ return true;
+}
+
+bool Verifier::stage_batch(const int* rows, int S, int hbase, const int32_t* tokens, const int64_t* pos,
+ std::string& err) {
+ using namespace strata::kernels;
+ if (S < 1 || S > max_t_ || hbase < 0 || hbase + S > (int) slots_.size()) {
+ err = "verify: batch rows out of range (init_slots)";
+ return false;
+ }
+ for (int t = 0; t < S; ++t) {
+ bool dup = false;
+ for (int u = 0; u < t; ++u) dup = dup || rows[u] == rows[t];
+ if (rows[t] < 0 || rows[t] >= (int) slots_.size() || dup) { err = "verify: a batch row's slot is out of range or twice"; return false; }
+ }
+ if (released_.load()) { err = "verify: an earlier window never finished on the GPU (#267); restart the engine"; return false; }
+ const ModelGeometry& g = *g_;
+ for (int t = 0; t < S; ++t)
+ if (pos[t] < 0 || pos[t] + 1 > slots_[(size_t) rows[t]]->max_cells) {
+ err = "verify: slot " + std::to_string(rows[t]) + " runs past its context";
+ return false;
+ }
+ if (!capture_batch(rows, S, hbase, err) || !capture_commit_batch(rows, S, hbase, err)) return false;
+ const Clock::time_point t0 = Clock::now();
+ const QsaShapes s = shapes_of(g);
+ for (int t = 0; t < S; ++t) {
+ h_tok_[t] = tokens[t];
+ qsa_step_fill(h_step_ + t * kStepCount, pos[t], s);
+ for (int64_t h = 0; h < g.n_head; ++h) h_pos_[t * g.n_head + h] = (int32_t) pos[t];
+ int32_t* pk = h_pos_ + (size_t) max_t_ * g.n_head;
+ int32_t* pi = pk + (size_t) max_t_ * g.n_head_kv;
+ for (int64_t h = 0; h < g.n_head_kv; ++h) pk[t * g.n_head_kv + h] = (int32_t) pos[t];
+ for (int64_t h = 0; h < g.idx_q_heads; ++h) pi[t * g.idx_q_heads + h] = (int32_t) pos[t];
+ }
+ if (ss_->ple.ready() && ple_stage()) {
+ uint32_t ple_rows[kVerifyMaxT * PLE_N_HEADS]; // (not `rows`: that is the slots of the window's rows)
+ for (int t = 0; t < S; ++t) {
+ const SessionState& sx = *slots_[(size_t) rows[t]];
+ int32_t prev[2] = {sx.ple_prev[0], sx.ple_prev[1]};
+ ngram_rows(&tokens[t], prev, 1, ss_->ple.consts, ple_rows + t * PLE_N_HEADS);
+ }
+ if (!ss_->ple.table->gather_batch(ple_rows, (size_t) S, h_ple_, err)) return false;
+ }
+ // the commit's rows (a batch window keeps every row): [1, 0, position, -1 ..] per slot
+ const int64_t CB = 2 + max_t_;
+ for (int t = 0; t < S; ++t) {
+ int32_t* c = h_commitb_ + (size_t) rows[t] * CB;
+ c[0] = 1;
+ c[1] = 0;
+ c[2] = (int32_t) pos[t];
+ for (int64_t j = 3; j < CB; ++j) c[j] = -1;
+ }
+ *(volatile uint32_t*) h_seq_ = 0;
+ *(volatile uint32_t*) h_flag_ = 0;
+ *(volatile uint32_t*) h_flagA_ = 0;
+ *(volatile uint32_t*) h_flagB_ = 0;
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ last_t_ = S;
+ for (int t = 0; t < S; ++t) last_rows_[t] = rows[t];
+ row_base_ = hbase; // the graphs' key (and nothing else) reads it until the next stage_batch
+ last_batch_ = true;
+ for (int t = 0; t < S; ++t) { last_tokens_[t] = tokens[t]; last_pos_b_[t] = pos[t]; }
+ ms_host += ms_since(t0);
+ return true;
+}
+
+bool Verifier::run_slots(int S, const int32_t* tokens, const int64_t* pos, PoolMultiFn pool, void* user, int32_t* out,
+ std::string& err) {
+ int rows[8] = {};
+ for (int t = 0; t < S && t < 8; ++t) rows[t] = t;
+ return run_slot_rows(rows, S, tokens, pos, pool, user, out, err);
+}
+
+bool Verifier::run_slot_rows(const int* rows, int S, const int32_t* tokens, const int64_t* pos, PoolMultiFn pool,
+ void* user, int32_t* out, std::string& err) {
+ using namespace strata::kernels;
+ const OnDevice on_device(device_);
+ const ModelGeometry& g = *g_;
+ if (!stage_batch(rows, S, 0, tokens, pos, err)) return false;
+ const cudaError_t le = cudaGraphLaunch(exec_bm_[batch_key(rows, S, 0)], cs_);
+ if (le != cudaSuccess) { err = std::string("verify: batch launch: ") + cudaGetErrorString(le); return false; }
+ (void) cudaStreamQuery(cs_);
+ volatile uint32_t* const seq = h_seq_;
+ volatile uint32_t* const flag = h_flag_;
+ const int64_t steps = le_ - lb_;
+ for (int64_t k = 0; k < steps; ++k) {
+ const int64_t l = lb_ + k;
+ const uint32_t want = (uint32_t) (k + 1);
+ const Clock::time_point a = Clock::now();
+ auto last_flush = a;
+ uint32_t spins = 0;
+ progress_at("verify batch: waiting for the GPU to reach layer", l);
+ while (*seq < want) {
+ _mm_pause();
+ if ((++spins & 1023u) != 0) continue;
+ const auto now = Clock::now();
+ if (now - last_flush > std::chrono::microseconds(2000)) {
+ last_flush = now;
+ const cudaError_t q = cudaStreamQuery(cs_);
+ if (q != cudaErrorNotReady && *seq < want) {
+ err = "verify batch: layer " + std::to_string(l) + " never rang (" +
+ (q == cudaSuccess ? std::string("graph finished") : std::string(cudaGetErrorString(q))) + ")";
+ return false;
+ }
+ }
+ if (now - a > std::chrono::seconds(20)) {
+ err = "verify batch: timed out at layer " + std::to_string(l) + released_note(release_gpu_waits(5000));
+ return false;
+ }
+ }
+ const Clock::time_point b = Clock::now();
+ cur_layer_ = want - 1;
+ set_plan_slot(0);
+ progress_at("verify batch: the CPU experts of layer", l);
+ if (pool != nullptr) pool(user, h_x_, h_ids_, S, ss_->k, h_ymiss_, l);
+ progress_tick();
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ _mm_sfence();
+ if (*(volatile uint32_t*) h_flagA_ != want) { // the pool did not publish a plan: an empty one
+ sink_.counts[0] = 0;
+ sink_.counts[1] = 0;
+ sink_.counts[2] = 0;
+ sink_.start[0] = 0;
+ sink_.start2[0] = 0;
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ *(volatile uint32_t*) h_flagA_ = want;
+ raise_flag(h_flagB_, want);
+ }
+ *flag = want;
+ ms_wait += std::chrono::duration(b - a).count();
+ ms_pool += ms_since(b);
+ }
+ const cudaError_t se = cudaStreamSynchronize(cs_);
+ if (se != cudaSuccess) { err = std::string("verify batch: ") + cudaGetErrorString(se); return false; }
+ cudaStreamSynchronize(copy_);
+ if (prof_on_) collect_profile();
+ ++windows;
+ if (le_ < g.n_layers) return next_ == nullptr || next_->run_slot_rows(rows, S, tokens, pos, pool, next_user_, out, err);
+ if (!sample_rows(S, err)) return false;
+ for (int t = 0; t < S; ++t) out[t] = ((volatile int32_t*) h_out_)[t];
+ progress_at("decode");
+ progress_beat();
+ return true;
+}
+
+bool Verifier::commit_slots(std::string& err) {
+ const OnDevice on_device(device_);
+ if (!last_batch_ || last_t_ < 1) { err = "verify: commit_slots without a batch window"; return false; }
+ const int S = last_t_;
+ const Clock::time_point t0 = Clock::now();
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ const cudaError_t le = cudaGraphLaunch(commit_bm_[batch_key(last_rows_, S, row_base_)], cs_);
+ if (le != cudaSuccess) { err = std::string("verify: batch commit launch: ") + cudaGetErrorString(le); return false; }
+ const cudaError_t se = cudaStreamSynchronize(cs_);
+ if (se != cudaSuccess) { err = std::string("verify: batch commit: ") + cudaGetErrorString(se); return false; }
+ if (ple_stage())
+ for (int t = 0; t < S; ++t) {
+ SessionState& sx = *slots_[(size_t) last_rows_[t]];
+ sx.ple_prev[0] = sx.ple_prev[1];
+ sx.ple_prev[1] = last_tokens_[t];
+ }
+ ms_commit += ms_since(t0);
+ return next_ == nullptr || next_->commit_slots(err);
+}
+
+bool Verifier::sample_rows(int S, std::string& err) {
+ bool any = false;
+ for (int t = 0; t < S; ++t) {
+ strata::kernels::SamplerParams sp = slot_sp_[(size_t) last_rows_[t]];
+ if (sp.greedy || sp.temperature <= 0.0f) continue;
+ sp.counter = (uint64_t) last_pos_b_[t]; // Philox(seed, position): the solo window's draw for this position
+ sp.penalty_last_n = 0;
+ strata::kernels::sample_tokens(head_logits_ + (size_t) t * (size_t) n_vocab_, 1, (int) n_vocab_, nullptr, 0, sp,
+ m_out_ + t, cs_);
+ any = true;
+ }
+ if (any && cudaStreamSynchronize(cs_) != cudaSuccess) { err = "verify batch: the row sampling failed"; return false; }
+ return true;
+}
+
+bool Verifier::batch_launch(int base, int S, const int32_t* tokens, const int64_t* pos, std::string& err) {
+ const OnDevice on_device(device_);
+ if (b_running_) { err = "verify: batch_launch while this stage is busy"; return false; }
+ int rows[8] = {};
+ for (int t = 0; t < S && t < 8; ++t) rows[t] = base + t;
+ if (!stage_batch(rows, S, base, tokens, pos, err)) return false;
+ cudaError_t le = cudaGraphLaunch(exec_bm_[batch_key(rows, S, base)], cs_);
+ if (le == cudaSuccess) le = cudaGraphLaunch(commit_bm_[batch_key(rows, S, base)], cs_); // right behind it: every row is kept
+ if (le != cudaSuccess) { err = std::string("verify: batch launch: ") + cudaGetErrorString(le); return false; }
+ (void) cudaStreamQuery(cs_);
+ if (ple_stage()) // the host's side of the commit (the hash's last two tokens)
+ for (int t = 0; t < S; ++t) {
+ SessionState& sx = *slots_[(size_t) rows[t]];
+ sx.ple_prev[0] = sx.ple_prev[1];
+ sx.ple_prev[1] = tokens[t];
+ }
+ b_running_ = true;
+ b_k_ = 0;
+ b_steps_ = le_ - lb_;
+ b_last_ = Clock::now();
+ return true;
+}
+
+int Verifier::batch_poll(PoolMultiFn pool, void* user, std::string& err) {
+ if (!b_running_) return 1;
+ const OnDevice on_device(device_);
+ volatile uint32_t* const seq = h_seq_;
+ const int S = last_t_;
+ while (b_k_ < b_steps_) {
+ const uint32_t want = (uint32_t) (b_k_ + 1);
+ if (*seq < want) {
+ const auto now = Clock::now();
+ if (now - b_last_ > std::chrono::milliseconds(2)) {
+ const cudaError_t q = cudaStreamQuery(cs_);
+ if (q != cudaErrorNotReady && *seq < want) {
+ err = "verify batch: layer " + std::to_string(lb_ + b_k_) + " never rang (" +
+ (q == cudaSuccess ? std::string("graph finished") : std::string(cudaGetErrorString(q))) + ")";
+ b_running_ = false;
+ return -1;
+ }
+ if (now - b_last_ > std::chrono::seconds(20)) {
+ err = "verify batch: timed out at layer " + std::to_string(lb_ + b_k_) + released_note(release_gpu_waits(5000));
+ b_running_ = false;
+ return -1;
+ }
+ }
+ return 0;
+ }
+ const Clock::time_point b = Clock::now();
+ cur_layer_ = want - 1;
+ set_plan_slot(0);
+ if (pool != nullptr) pool(user, h_x_, h_ids_, S, ss_->k, h_ymiss_, lb_ + b_k_);
+ progress_tick();
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ _mm_sfence();
+ if (*(volatile uint32_t*) h_flagA_ != want) { // the pool did not publish a plan: an empty one
+ sink_.counts[0] = 0;
+ sink_.counts[1] = 0;
+ sink_.counts[2] = 0;
+ sink_.start[0] = 0;
+ sink_.start2[0] = 0;
+ std::atomic_thread_fence(std::memory_order_seq_cst);
+ *(volatile uint32_t*) h_flagA_ = want;
+ raise_flag(h_flagB_, want);
+ }
+ *(volatile uint32_t*) h_flag_ = want;
+ ms_pool += ms_since(b);
+ b_last_ = Clock::now();
+ ++b_k_;
+ }
+ const cudaError_t q = cudaStreamQuery(cs_);
+ if (q == cudaErrorNotReady) return 0;
+ if (q != cudaSuccess) { err = std::string("verify batch: ") + cudaGetErrorString(q); b_running_ = false; return -1; }
+ const cudaError_t qc = cudaStreamQuery(copy_); // no host function of this window may raise flag B in the next
+ if (qc == cudaErrorNotReady) return 0;
+ if (prof_on_) collect_profile();
+ if (last_stage()) {
+ if (!sample_rows(S, err)) { b_running_ = false; return -1; }
+ for (int t = 0; t < S; ++t) b_out_[t] = ((volatile int32_t*) h_out_)[t];
+ }
+ ++windows;
+ b_running_ = false;
+ progress_beat();
+ return 1;
+}
+
bool Verifier::copy_logits(int t, float* host) const {
if (next_ != nullptr) return next_->copy_logits(t, host); // a layer split: the head is on the last stage
if (head_logits_ == nullptr || host == nullptr || t < 0 || n_vocab_ <= 0) return false;
diff --git a/src/kernels/cpu/expert_layout.cpp b/src/kernels/cpu/expert_layout.cpp
index 4583b1077c..8b6e3bc2c5 100644
--- a/src/kernels/cpu/expert_layout.cpp
+++ b/src/kernels/cpu/expert_layout.cpp
@@ -21,9 +21,26 @@ ExpertLayout g_layout;
const ExpertLayout& expert_layout() { return g_layout; }
+int cpu_isa_cap() {
+ // STRATA_FORCE_ISA (tests, the experimental older-CPU builds): the engine's own dispatch acts as if this CPU
+ // stopped at that level. ggml-cpu is not affected: it runs what the build compiled it for.
+ static const int cap = [] {
+ const char* f = std::getenv("STRATA_FORCE_ISA");
+ if (f == nullptr || f[0] == '\0') return 3;
+ const std::string v(f);
+ if (v == "avx2") return 2;
+ if (v == "avx") return 1;
+ if (v == "sse" || v == "sse4.2" || v == "none") return 0;
+ std::fprintf(stderr, "strata: STRATA_FORCE_ISA=%s is not avx2, avx or sse; ignored\n", f);
+ return 3;
+ }();
+ return cap;
+}
+
bool cpu_avx512_ok() {
static const bool ok = [] {
if (const char* f = std::getenv("STRATA_FORCE_AVX2"); f != nullptr && f[0] == '1') return false;
+ if (cpu_isa_cap() < 3) return false;
unsigned r[4] = {0, 0, 0, 0};
auto cpuid = [&](unsigned leaf, unsigned sub) {
#if defined(_MSC_VER)
@@ -53,8 +70,41 @@ bool cpu_avx512_ok() {
return ok;
}
+bool cpu_avx512bw_ok() {
+ static const bool ok = [] {
+ if (const char* f = std::getenv("STRATA_FORCE_AVX2"); f != nullptr && f[0] == '1') return false;
+ unsigned r[4] = {0, 0, 0, 0};
+ auto cpuid = [&](unsigned leaf, unsigned sub) {
+#if defined(_MSC_VER)
+ int x[4];
+ __cpuidex(x, (int) leaf, (int) sub);
+ for (int i = 0; i < 4; ++i) r[i] = (unsigned) x[i];
+#else
+ __cpuid_count(leaf, sub, r[0], r[1], r[2], r[3]);
+#endif
+ };
+ cpuid(0, 0);
+ if (r[0] < 7) return false;
+ cpuid(1, 0);
+ if (!((r[2] >> 27) & 1u)) return false; // OSXSAVE
+#if defined(_MSC_VER)
+ const unsigned long long xcr0 = _xgetbv(0);
+#else
+ unsigned lo = 0, hi = 0;
+ __asm__ volatile("xgetbv" : "=a"(lo), "=d"(hi) : "c"(0));
+ const unsigned long long xcr0 = ((unsigned long long) hi << 32) | lo;
+#endif
+ if ((xcr0 & 0xE6) != 0xE6) return false; // the OS saves the AVX-512 state
+ cpuid(7, 0);
+ const unsigned ebx = r[1];
+ return ((ebx >> 16) & 1u) && ((ebx >> 17) & 1u) && ((ebx >> 30) & 1u) && ((ebx >> 31) & 1u);
+ }();
+ return ok;
+}
+
bool cpu_avx2_ok() {
static const bool ok = [] {
+ if (cpu_isa_cap() < 2) return false;
unsigned r[4] = {0, 0, 0, 0};
auto cpuid = [&](unsigned leaf, unsigned sub) {
#if defined(_MSC_VER)
@@ -85,6 +135,57 @@ bool cpu_avx2_ok() {
return ok;
}
+bool cpu_avx1_ok() {
+ // AVX (Sandy Bridge, 2011): AVX + OSXSAVE with the OS saving the YMM state; FMA, F16C and AVX2 not needed.
+ // From the Strata_Dirigo fork (rwkeyes).
+ static const bool ok = [] {
+ if (cpu_isa_cap() < 1) return false;
+ unsigned r[4] = {0, 0, 0, 0};
+#if defined(_MSC_VER)
+ int x[4];
+ __cpuidex(x, 1, 0);
+ for (int i = 0; i < 4; ++i) r[i] = (unsigned) x[i];
+#else
+ __cpuid_count(1, 0, r[0], r[1], r[2], r[3]);
+#endif
+ if (!((r[2] >> 27) & 1u) || !((r[2] >> 28) & 1u)) return false; // OSXSAVE, AVX
+#if defined(_MSC_VER)
+ const unsigned long long xcr0 = _xgetbv(0);
+#else
+ unsigned lo = 0, hi = 0;
+ __asm__ volatile("xgetbv" : "=a"(lo), "=d"(hi) : "c"(0));
+ const unsigned long long xcr0 = ((unsigned long long) hi << 32) | lo;
+#endif
+ return (xcr0 & 0x6) == 0x6;
+ }();
+ return ok;
+}
+
+bool cpu_sse42_ok() {
+ static const bool ok = [] {
+ unsigned r[4] = {0, 0, 0, 0};
+#if defined(_MSC_VER)
+ int x[4];
+ __cpuidex(x, 1, 0);
+ for (int i = 0; i < 4; ++i) r[i] = (unsigned) x[i];
+#else
+ __cpuid_count(1, 0, r[0], r[1], r[2], r[3]);
+#endif
+ return ((r[2] >> 20) & 1u) && ((r[2] >> 23) & 1u); // SSE4.2, POPCNT
+ }();
+ return ok;
+}
+
+const char* isa_floor_build() {
+#if defined(STRATA_ISA_FLOOR_AVX)
+ return "avx";
+#elif defined(STRATA_ISA_FLOOR_NONE)
+ return "sse4.2";
+#else
+ return "";
+#endif
+}
+
std::string cpu_name() {
unsigned r[12] = {};
#if defined(_MSC_VER)
diff --git a/src/kernels/cpu/iq_avx2.cpp b/src/kernels/cpu/iq_avx2.cpp
index 09d178db6b..bb8849bb51 100644
--- a/src/kernels/cpu/iq_avx2.cpp
+++ b/src/kernels/cpu/iq_avx2.cpp
@@ -56,19 +56,30 @@ inline __m256i sc32(int s) { return _mm256_set1_epi16(s); }
// keven_signs_q2xs (ggml keeps it static in arch/x86/quants.c): one u64 per 7-bit sign index, byte k
// = 0xFF when bit k of ksigns_iq2xs[i] is set, 0x01 otherwise. With this a whole 32-value sign vector
// is four scalar loads and a set_epi64x - no ksigns byte packing chain and no bit_selector expansion.
-// Built once at load time from the shared ggml-common table so it can never drift from it.
+//
+// Computed at COMPILE time (constexpr), so this file has no static constructor at all. This TU is compiled
+// for AVX2, and a runtime constructor here runs before main() on every CPU: MSVC turned its variable shift
+// into BMI2 `shlx` (#391, demetree: strata.exe exited 0xC000001D before printing anything on a Sandy Bridge
+// Xeon) and GCC vectorised the loop into AVX2 (`vpbroadcastb`, found on an AVX-only Xeon E5 by the
+// Strata_Dirigo fork). ksigns_iq2xs[i] is i's 7 bits plus an even-parity bit 7 (ggml-common.h); a const
+// array is not usable in a constant expression, so the byte is derived the same way here, and
+// native_expert_parity checks these kernels against ggml-cpu's.
struct EvenSigns {
uint64_t v[128];
- EvenSigns() {
+ constexpr EvenSigns() : v{} {
for (int i = 0; i < 128; ++i) {
+ int par = 0;
+ for (int k = 0; k < 7; ++k) par ^= (i >> k) & 1;
+ const int s = i | (par << 7); // == ksigns_iq2xs[i]
uint64_t r = 0;
- for (int k = 0; k < 8; ++k)
- r |= (uint64_t) (((ksigns_iq2xs[i] >> k) & 1) ? 0xFF : 0x01) << (8 * k);
+ for (int k = 0; k < 8; ++k) r |= (uint64_t) (((s >> k) & 1) ? 0xFF : 0x01) << (8 * k);
v[i] = r;
}
}
};
-static const EvenSigns even_signs;
+static constexpr EvenSigns even_signs{};
+static_assert(even_signs.v[0] == 0x0101010101010101ull && even_signs.v[1] == 0xFF010101010101FFull,
+ "keven_signs_q2xs: byte k = 0xFF when bit k of ksigns_iq2xs[i] is set");
inline float hsum8(__m256 v) {
const __m128 lo = _mm256_castps256_ps128(v), hi = _mm256_extractf128_ps(v, 1);
@@ -95,6 +106,11 @@ inline void rows_ahead(const uint8_t* p) {
_mm_prefetch((const char*) p + 64, _MM_HINT_T0);
}
+// E-2 on the AVX-2 path (the AVX-512 kernels' STRATA_IQ_GATHER): the IQ3 grids by one AVX2 gather instead of eight
+// scalar loads assembled with set_epi32. AVX2 gather is a different instruction with worse throughput on some cores
+// (opt-in, as on AVX-512); on the i7-12850HX it measures ~1.3x on the two 32-bit-grid formats, bit-exact.
+static const bool gather = [] { const char* v = std::getenv("STRATA_IQ256_GATHER"); return v != nullptr && std::atoi(v) != 0; }();
+
// ---- per format: one 32-value half (values 64*j + 32*half .. +31) -> grid magnitudes, sign vector, scales
template struct Fmt32;
@@ -152,8 +168,13 @@ template <> struct Fmt32<18> { // IQ3_XXS: d, qs[64] grid bytes, 8 x u32 (4 x
static constexpr float K = 0.25f;
static inline void decode(const uint8_t* b, int j, int half, __m256i& g, __m256i& sgn, __m256i& sc) {
const uint8_t* q = b + 2 + 16 * j + 8 * half;
- g = _mm256_set_epi32((int) iq3xxs_grid[q[7]], (int) iq3xxs_grid[q[6]], (int) iq3xxs_grid[q[5]], (int) iq3xxs_grid[q[4]],
- (int) iq3xxs_grid[q[3]], (int) iq3xxs_grid[q[2]], (int) iq3xxs_grid[q[1]], (int) iq3xxs_grid[q[0]]);
+ if (gather) {
+ const __m256i idx = _mm256_cvtepu8_epi32(_mm_loadl_epi64((const __m128i*) q));
+ g = _mm256_i32gather_epi32((const int*) iq3xxs_grid, idx, 4);
+ } else {
+ g = _mm256_set_epi32((int) iq3xxs_grid[q[7]], (int) iq3xxs_grid[q[6]], (int) iq3xxs_grid[q[5]], (int) iq3xxs_grid[q[4]],
+ (int) iq3xxs_grid[q[3]], (int) iq3xxs_grid[q[2]], (int) iq3xxs_grid[q[1]], (int) iq3xxs_grid[q[0]]);
+ }
const uint32_t w = u32(b + 2 + 64 + 8 * j + 4 * half);
sgn = _mm256_set_epi64x((long long) even_signs.v[(w >> 21) & 127], (long long) even_signs.v[(w >> 14) & 127],
(long long) even_signs.v[(w >> 7) & 127], (long long) even_signs.v[w & 127]);
@@ -167,9 +188,17 @@ template <> struct Fmt32<21> { // IQ3_S: d, qs[64], qh[8], signs[32], scales[4
static inline void decode(const uint8_t* b, int j, int half, __m256i& g, __m256i& sgn, __m256i& sc) {
const uint8_t* q = b + 2 + 16 * j + 8 * half;
const uint32_t h = b[66 + 2 * j + half];
+ if (gather) {
+ const __m256i bits = _mm256_setr_epi32(0, 1, 2, 3, 4, 5, 6, 7);
+ __m256i idx = _mm256_cvtepu8_epi32(_mm_loadl_epi64((const __m128i*) q));
+ idx = _mm256_add_epi32(idx, _mm256_slli_epi32(
+ _mm256_and_si256(_mm256_srlv_epi32(_mm256_set1_epi32((int) h), bits), _mm256_set1_epi32(1)), 8));
+ g = _mm256_i32gather_epi32((const int*) iq3s_grid, idx, 4);
+ } else {
#define G3(k) (int) iq3s_grid[q[k] | (((h >> k) & 1u) << 8)]
- g = _mm256_set_epi32(G3(7), G3(6), G3(5), G3(4), G3(3), G3(2), G3(1), G3(0));
+ g = _mm256_set_epi32(G3(7), G3(6), G3(5), G3(4), G3(3), G3(2), G3(1), G3(0));
#undef G3
+ }
const uint64_t m = u64(b + 74 + 8 * j);
sgn = sgn_vec(half ? (uint32_t) (m >> 32) : (uint32_t) m);
const uint8_t s = b[106 + j];
diff --git a/src/kernels/cpu/kq_avx1.cpp b/src/kernels/cpu/kq_avx1.cpp
new file mode 100644
index 0000000000..fbdf73f40f
--- /dev/null
+++ b/src/kernels/cpu/kq_avx1.cpp
@@ -0,0 +1,71 @@
+// src/kernels/cpu/kq_avx1.cpp - the AVX1 router dot for older CPUs (see kq_avx1.hpp). From rwkeyes' Strata_Dirigo
+// fork (MIT), its AVX1 floor.
+//
+// Measured on the hardware this exists for (Xeon E5-2665, 2x8 cores, AVX only) for one layer's router
+// [512 experts x 5120 embeddings]: the scalar std::fma fallback it replaces took 362 ms/layer, a scalar
+// mul+add 4.9 ms, and this kernel 2.6 ms - 137x, with no extra memory (the weights stay bf16).
+//
+// Compiled for AVX (/arch:AVX, -mavx; CMakeLists.txt sets that per source); it is only ever called behind
+// cpu_avx1_ok(), so no CPU that lacks AVX can reach it.
+#include "strata/kernels/cpu/kq_avx1.hpp"
+
+#include
+
+#include
+
+#if !defined(__AVX__)
+#error "kq_avx1.cpp must be compiled with AVX enabled (see the per-source flags in CMakeLists.txt)"
+#endif
+
+namespace strata::kernels::cpu {
+namespace {
+
+// 8 floats -> one float, the same reduction order the AVX2 kernel's hsum_float_8 uses.
+inline float hsum_float_8_avx1(__m256 v) {
+ __m128 lo = _mm256_castps256_ps128(v);
+ __m128 hi = _mm256_extractf128_ps(v, 1);
+ lo = _mm_add_ps(lo, hi);
+ lo = _mm_add_ps(lo, _mm_movehl_ps(lo, lo));
+ lo = _mm_add_ss(lo, _mm_shuffle_ps(lo, lo, 0x55));
+ return _mm_cvtss_f32(lo);
+}
+
+// 8 bf16 -> 8 f32: a bf16 is the top half of its f32, so widen in 128-bit halves (AVX1 has no
+// 256-bit integer ops) and shift each 32-bit lane into the high half.
+inline __m256 bf16x8_to_f32x8_avx1(const uint16_t* p) {
+ const __m128i v = _mm_loadu_si128((const __m128i*) p);
+ const __m128i z = _mm_setzero_si128();
+ const __m128 lo = _mm_castsi128_ps(_mm_slli_epi32(_mm_unpacklo_epi16(v, z), 16));
+ const __m128 hi = _mm_castsi128_ps(_mm_slli_epi32(_mm_unpackhi_epi16(v, z), 16));
+ return _mm256_insertf128_ps(_mm256_castps128_ps256(lo), hi, 1);
+}
+
+} // namespace
+
+void bf16_rows_dot_multi_avx1(const uint16_t* w, int rows, int cols, const float* x, int nt, float* out) {
+ // each row is read once for all `nt` (<= 8) tokens, exactly as the AVX2 kernel does
+ for (int r = 0; r < rows; ++r) {
+ const uint16_t* wr = w + (size_t) r * (size_t) cols;
+ __m256 acc[8];
+ for (int t = 0; t < nt; ++t) acc[t] = _mm256_setzero_ps();
+ int c = 0;
+ for (; c + 8 <= cols; c += 8) {
+ const __m256 wf = bf16x8_to_f32x8_avx1(wr + c);
+ for (int t = 0; t < nt; ++t) {
+ const __m256 xv = _mm256_loadu_ps(x + (size_t) t * (size_t) cols + c);
+ acc[t] = _mm256_add_ps(acc[t], _mm256_mul_ps(wf, xv)); // no FMA on AVX1: mul, then add
+ }
+ }
+ for (int t = 0; t < nt; ++t) {
+ float s = hsum_float_8_avx1(acc[t]);
+ for (; c < cols; ++c) { // cols % 8 != 0 tail (cols % 8 == 0 today)
+ const uint32_t bits = (uint32_t) wr[c] << 16;
+ float wf; std::memcpy(&wf, &bits, sizeof wf);
+ s += wf * x[(size_t) t * (size_t) cols + c];
+ }
+ out[(size_t) t * (size_t) rows + r] = s;
+ }
+ }
+}
+
+} // namespace strata::kernels::cpu
diff --git a/src/kernels/cpu/native_expert.cpp b/src/kernels/cpu/native_expert.cpp
index 0903761e61..0dc5d3aa84 100644
--- a/src/kernels/cpu/native_expert.cpp
+++ b/src/kernels/cpu/native_expert.cpp
@@ -84,7 +84,14 @@ void native_gu_rows(const NativeFmt& f, const uint8_t* blob, const void* const*
// AVX-2 kernel, STRATA_NO_IQ256 drops the AVX-2 kernel; ggml-cpu's single-token vec_dot is reached only with
// both set (and on a CPU without AVX-512, STRATA_NO_IQ512 changes nothing).
static const bool avx512 = cpu_avx512_ok() && std::getenv("STRATA_NO_IQ512") == nullptr;
- static const bool avx2 = std::getenv("STRATA_NO_IQ256") == nullptr;
+ // STRATA_NO_IQ256 drops the AVX-2 kernel. cpu_avx2_ok() is ALSO required: iq_avx2.cpp and kq_avx2.cpp
+ // are compiled /arch:AVX2 and use AVX2 and FMA3, so calling them on a CPU without either is an
+ // illegal instruction, not a slow path. The gate sat next to `avx512` above, which does test
+ // cpu_avx512_ok(), and that asymmetry is what let a pre-Haswell CPU reach iq256_gu_rows.
+ // Without it this falls through to ggml-cpu's vec_dot below, which is compiled for whatever
+ // baseline the build selected (AVX1 here) and covers the same types - IQ2_XXS and IQ2_S among
+ // them. That path loops over tokens itself, so it is correct for any `nt`, not just one.
+ static const bool avx2 = cpu_avx2_ok() && std::getenv("STRATA_NO_IQ256") == nullptr;
// #152: from how many tokens the multi-token kernels run (ggml's vec_dot below that). The default 2 is the
// measured-fastest rule, but a token's expert rows then round differently alone than in a group, so greedy output
// can depend on how many drafts a verify window held. STRATA_IQ_MT_MIN=1 (opt-in, 0.1.30) uses the multi-token
@@ -93,7 +100,7 @@ void native_gu_rows(const NativeFmt& f, const uint8_t* blob, const void* const*
// Unsloth UD-Q4_K_XL's Q4_K gate/up: the multi-token kernel is bit-exact against ggml's per-token dot (any group
// size, no #152 rule). Opt-in, STRATA_KQ256=1: measured no faster in the engine (a window's expert groups hold
// ~1.4 tokens and the weights stay in L1 across ggml's per-token calls; 1.01-1.13x in native_expert_parity).
- static const bool kq = [] { const char* v = std::getenv("STRATA_KQ256"); return v != nullptr && std::atoi(v) != 0; }();
+ static const bool kq = [] { const char* v = std::getenv("STRATA_KQ256"); return cpu_avx2_ok() && v != nullptr && std::atoi(v) != 0; }();
if (kq && f.gu_type == 12 && nt >= 2) { // one token: ggml's own dot below (the same bits, less overhead)
kq256_gu_rows(f.gu_type, blob, f.gu_row, f.up_off, (int) f.n_embd, act, nt, ff, r0, r1);
return;
@@ -133,11 +140,14 @@ void native_down_rows(const NativeFmt& f, const uint8_t* blob, const void* const
static const bool iq4nl_mt = std::getenv("STRATA_NO_IQ4NL") == nullptr;
static const int mt_min = [] { const char* e = std::getenv("STRATA_IQ_MT_MIN"); return e ? std::atoi(e) : 2; }();
static const bool kq = [] { const char* v = std::getenv("STRATA_KQ256"); return v != nullptr && std::atoi(v) != 0; }();
- if (kq && nt >= 2 && (f.d_type == 7 || f.d_type == 8)) { // Q5_1 / Q8_0 down: bit-exact, any group size
+ // Both multi-token kernels below are /arch:AVX2 translation units (kq_avx2.cpp and iq_avx2.cpp),
+ // so a CPU without AVX2 has to reach ggml-cpu's vec_dot instead - same reasoning as the gate/up
+ // rows above, where `avx512` tested cpu_avx512_ok() and `avx2` did not.
+ if (cpu_avx2_ok() && kq && nt >= 2 && (f.d_type == 7 || f.d_type == 8)) { // Q5_1 / Q8_0 down: bit-exact, any group size
kq256_rows(f.d_type, blob + f.down_off, f.d_row, (int) f.n_ff, hq, nt, out, r0, r1);
return;
}
- if (nt >= mt_min && f.d_type == 20 && iq4nl_mt) { // #152: the same rule as the gate/up rows
+ if (cpu_avx2_ok() && nt >= mt_min && f.d_type == 20 && iq4nl_mt) { // #152: the same rule as the gate/up rows
iq4nl256_down_rows(blob + f.down_off, f.d_row, (int) f.n_ff, hq, nt, out, r0, r1);
return;
}
diff --git a/src/kernels/cpu/pool.cpp b/src/kernels/cpu/pool.cpp
index 45e4347037..c64462936b 100644
--- a/src/kernels/cpu/pool.cpp
+++ b/src/kernels/cpu/pool.cpp
@@ -14,9 +14,11 @@
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include
+#include "pool_affinity_win.hpp"
#else
#include
#include
+#include "pool_affinity_linux.hpp"
#endif
namespace strata::kernels::cpu {
@@ -36,7 +38,13 @@ CpuTopology detect_cpu_topology(bool skip_first, PoolAffinity affinity) {
DWORD len = 0;
GetLogicalProcessorInformationEx(RelationProcessorCore, nullptr, &len);
if (len == 0) {
- for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) topo.worker_cores.push_back((int) i);
+ // Preserve group identity even when detailed core topology is unavailable.
+ const WORD groups = GetActiveProcessorGroupCount();
+ for (WORD group = 0; group < groups; ++group) {
+ const DWORD count = GetActiveProcessorCount(group);
+ if (count == 0 || count == (DWORD) -1 || count > 64) continue;
+ for (DWORD i = 0; i < count; ++i) topo.worker_cores.push_back((int) group * 64 + (int) i);
+ }
if (skip_first && !topo.worker_cores.empty()) {
topo.host_core = topo.worker_cores.front();
topo.worker_cores.erase(topo.worker_cores.begin());
@@ -60,10 +68,12 @@ CpuTopology detect_cpu_topology(bool skip_first, PoolAffinity affinity) {
CoreDesc cd;
cd.efficiency = e->Processor.EfficiencyClass;
cd.has_smt = (e->Processor.Flags & LTP_PC_SMT) != 0;
- const GROUP_AFFINITY& g = e->Processor.GroupMask[0];
- for (int bit = 0; bit < 64; ++bit) {
- if (g.Mask & (1ull << bit)) {
- cd.lps.push_back((int) (g.Group * 64 + bit));
+ for (WORD group = 0; group < e->Processor.GroupCount; ++group) {
+ const GROUP_AFFINITY& g = e->Processor.GroupMask[group];
+ for (int bit = 0; bit < 64; ++bit) {
+ if (g.Mask & (KAFFINITY(1) << bit)) {
+ cd.lps.push_back((int) (g.Group * 64 + bit));
+ }
}
}
if (!cd.lps.empty()) {
@@ -95,6 +105,12 @@ CpuTopology detect_cpu_topology(bool skip_first, PoolAffinity affinity) {
}
if (affinity == PoolAffinity::All || !topo.is_hybrid) {
+ // #642 (from Hardin22's fork): on a hybrid CPU the P-cores first (the host takes the first of them), so a
+ // pool smaller than the core count (setup's --pool-workers for a hybrid CPU) runs on the P-cores and the
+ // first E-cores rather than on whatever the OS numbered first. All cores alike: the order is unchanged.
+ if (topo.is_hybrid)
+ std::stable_sort(descs.begin(), descs.end(),
+ [](const CoreDesc& x, const CoreDesc& y) { return x.efficiency > y.efficiency; });
for (const auto& c : descs) topo.worker_cores.push_back(c.lps[0]);
if (skip_first && !topo.worker_cores.empty()) {
topo.host_core = topo.worker_cores.front();
@@ -162,12 +178,8 @@ CpuTopology detect_cpu_topology(bool skip_first, PoolAffinity affinity) {
};
std::vector allowed;
- cpu_set_t set;
- CPU_ZERO(&set);
- if (sched_getaffinity(0, sizeof set, &set) == 0) {
- for (int i = 0; i < CPU_SETSIZE; ++i)
- if (CPU_ISSET(i, &set)) allowed.push_back(i);
- } else {
+ std::vector allowed_mask;
+ if (detail::get_thread_affinity(allowed_mask, &allowed) != 0) {
for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) allowed.push_back((int) i);
}
@@ -219,6 +231,9 @@ CpuTopology detect_cpu_topology(bool skip_first, PoolAffinity affinity) {
}
if (affinity == PoolAffinity::All || !topo.is_hybrid) {
+ if (topo.is_hybrid) // #642: the P-cores first (see the Windows branch)
+ std::stable_sort(all_cpus.begin(), all_cpus.end(),
+ [](const CoreLinux& x, const CoreLinux& y) { return x.cap > y.cap; });
for (const auto& cl : all_cpus) {
if (!cl.is_sibling) topo.worker_cores.push_back(cl.cpu);
}
@@ -264,49 +279,60 @@ std::vector physical_cores(bool skip_first, PoolAffinity affinity) {
namespace {
-void pin_this_thread(int core) {
- if (core < 0) return;
+bool pin_this_thread(int core, [[maybe_unused]] int worker = -1) {
+ if (core < 0) return false;
#if defined(_WIN32)
- SetThreadAffinityMask(GetCurrentThread(), (DWORD_PTR) 1 << (core & 63));
+ return detail::set_thread_group_affinity(core, worker);
#else
- cpu_set_t set;
- CPU_ZERO(&set);
- CPU_SET(core, &set);
- pthread_setaffinity_np(pthread_self(), sizeof set, &set);
+ const int error = detail::pin_thread_to_cpu(core);
+ if (error != 0)
+ std::fprintf(stderr, "strata cpu pool: affinity for worker %d (CPU %d) failed: %d; previous affinity kept\n",
+ worker, core, error);
+ return error == 0;
#endif
}
} // namespace
-long long pin_current_thread(int core) {
- if (core < 0) return -1;
+ThreadAffinity pin_current_thread(int core) {
+ if (core < 0) return {};
#if defined(_WIN32)
- // `SetThreadAffinityMask` RETURNS the previous mask, or 0 on failure - so 0 doubles as the error, which is
- // why the caller must not treat it as a restorable value.
- const DWORD_PTR prev = SetThreadAffinityMask(GetCurrentThread(), (DWORD_PTR) 1 << (core & 63));
- return prev == 0 ? -1 : (long long) prev;
+ ThreadAffinity previous;
+ ULONG target = 0;
+ if (!detail::get_thread_cpu_sets(previous.cpu_sets) || !detail::cpu_set_for_core(core, target) ||
+ !SetThreadSelectedCpuSets(GetCurrentThread(), &target, 1)) {
+ std::fprintf(stderr, "strata cpu pool: host CPU Set selection for processor %d failed: %lu; previous placement kept\n",
+ core, (unsigned long) GetLastError());
+ return {};
+ }
+ previous.valid = true;
+ return previous;
#else
- cpu_set_t prev;
- CPU_ZERO(&prev);
- if (pthread_getaffinity_np(pthread_self(), sizeof prev, &prev) != 0) return -1;
- unsigned long mask = 0;
- for (int i = 0; i < CPU_SETSIZE && i < 64; ++i)
- if (CPU_ISSET(i, &prev)) mask |= 1ul << i;
- pin_this_thread(core);
- return (long long) mask;
+ ThreadAffinity previous;
+ int error = detail::get_thread_affinity(previous.mask);
+ if (error == 0) error = detail::pin_thread_to_cpu(core);
+ if (error != 0) {
+ std::fprintf(stderr, "strata cpu pool: host affinity for CPU %d failed: %d; previous affinity kept\n", core, error);
+ return {};
+ }
+ previous.valid = true;
+ return previous;
#endif
}
-void restore_thread_affinity(long long previous) {
- if (previous <= 0) return;
+void restore_thread_affinity(const ThreadAffinity& previous) {
+ if (!previous.valid) return;
#if defined(_WIN32)
- SetThreadAffinityMask(GetCurrentThread(), (DWORD_PTR) previous);
+ // Clearing an originally empty selection restores process-default/all-group eligibility without
+ // turning the caller's implicit Windows 11 affinity into an explicit single-group hard mask.
+ if (!SetThreadSelectedCpuSets(GetCurrentThread(), previous.cpu_sets.empty() ? nullptr : previous.cpu_sets.data(),
+ (ULONG) previous.cpu_sets.size()))
+ std::fprintf(stderr, "strata cpu pool: host CPU Set restoration failed: %lu\n",
+ (unsigned long) GetLastError());
#else
- cpu_set_t set;
- CPU_ZERO(&set);
- for (int i = 0; i < 64; ++i)
- if ((previous >> i) & 1) CPU_SET(i, &set);
- pthread_setaffinity_np(pthread_self(), sizeof set, &set);
+ const int error = detail::set_thread_affinity(previous.mask);
+ if (error != 0)
+ std::fprintf(stderr, "strata cpu pool: host affinity restoration failed: %d\n", error);
#endif
}
@@ -366,7 +392,7 @@ ExpertPool::ExpertPool(int n_workers, bool pin, bool host_works, PoolAffinity af
for (int i = 0; i < n_; ++i) {
const int core = pin ? (i < (int) topo_.worker_cores.size() ? topo_.worker_cores[(size_t) i] : -1) : -1;
threads_.emplace_back([this, i, core] {
- pin_this_thread(core);
+ pin_this_thread(core, i);
worker(i);
});
}
@@ -540,7 +566,7 @@ void ExpertPool::drain(int id, ExpertScratch& scratch, uint32_t epoch) {
const int e = (int) (r / per), r0 = (int) (r % per);
const int r1 = (int) std::min(per, r0 + (g1 - r));
SplitBufMulti& sb = split_multi_[(size_t) e];
- if (mode_ == 5 && nfmt_->gu_type == 42) {
+ if (mode_ == 5 && q2_native_kernels(nfmt_->gu_type)) {
// a native Q2_0 pack: gate and up rows on the Q2_0 kernels, then SwiGLU
thread_local float gbuf[MAXT][FF], ubuf[MAXT][FF];
float* gp[MAXT];
@@ -556,7 +582,7 @@ void ExpertPool::drain(int id, ExpertScratch& scratch, uint32_t epoch) {
float* ff[MAXT];
for (int t = 0; t < mjobs_[e].nt; ++t) ff[t] = sb.ff[t];
native_gu_rows(*nfmt_, mjobs_[e].blob, mjobs_[e].nact, mjobs_[e].nt, ff, r0, r1);
- } else if (nfmt_->d_type == 42) {
+ } else if (q2_native_kernels(nfmt_->d_type)) {
// Q2_0 down (most IQ layers): the AVX-512 kernel, ggml-cpu has only a scalar one on x86
const ActQ* a2[MAXT];
for (int t = 0; t < mjobs_[e].nt; ++t) a2[t] = &sb.a2[t];
@@ -676,7 +702,7 @@ void ExpertPool::run_split_multi_native(const NativeFmt& f, ExpertJobMulti* jobs
const auto b = std::chrono::steady_clock::now();
for (int e = 0; e < nb; ++e)
for (int t = 0; t < mjobs_[e].nt; ++t)
- if (f.d_type == 42) act_quant_any(split_multi_[(size_t) e].ff[t], FF, split_multi_[(size_t) e].a2[t]);
+ if (q2_native_kernels(f.d_type)) act_quant_any(split_multi_[(size_t) e].ff[t], FF, split_multi_[(size_t) e].a2[t]);
else native_quant_h(f, split_multi_[(size_t) e].ff[t], split_multi_[(size_t) e].hq[t]);
const auto c = std::chrono::steady_clock::now();
mrows_ = (int64_t) nb * H;
diff --git a/src/kernels/cpu/pool_affinity_linux.hpp b/src/kernels/cpu/pool_affinity_linux.hpp
new file mode 100644
index 0000000000..0791ac5c7a
--- /dev/null
+++ b/src/kernels/cpu/pool_affinity_linux.hpp
@@ -0,0 +1,69 @@
+#pragma once
+
+#if defined(__linux__)
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+namespace strata::kernels::cpu::detail {
+
+// CPU_ALLOC and the sized macros also handle kernels whose CPU mask exceeds CPU_SETSIZE.
+// Keep the native mask opaque: copy its bytes for storage and use CPU_*_S to inspect it.
+class LinuxCpuSet {
+public:
+ explicit LinuxCpuSet(int cpus) : bytes(CPU_ALLOC_SIZE(cpus)), set(CPU_ALLOC(cpus)) {
+ if (set) CPU_ZERO_S(bytes, set);
+ }
+ ~LinuxCpuSet() { CPU_FREE(set); }
+ LinuxCpuSet(const LinuxCpuSet&) = delete;
+ LinuxCpuSet& operator=(const LinuxCpuSet&) = delete;
+
+ const size_t bytes;
+ cpu_set_t* const set;
+};
+
+// pthread affinity functions return an error number directly; they do not set errno.
+// A too-small query buffer returns EINVAL, even when the number of online CPUs is small.
+inline int get_thread_affinity(std::vector& mask, std::vector* cpus = nullptr) {
+ for (int count = CPU_SETSIZE;;) {
+ LinuxCpuSet current(count);
+ if (!current.set) return ENOMEM;
+ const int error = pthread_getaffinity_np(pthread_self(), current.bytes, current.set);
+ if (error == 0) {
+ mask.resize(current.bytes / sizeof(unsigned long));
+ std::memcpy(mask.data(), current.set, current.bytes);
+ if (cpus) {
+ cpus->clear();
+ for (int cpu = 0; cpu < count; ++cpu)
+ if (CPU_ISSET_S(cpu, current.bytes, current.set)) cpus->push_back(cpu);
+ }
+ return 0;
+ }
+ if (error != EINVAL || count > INT_MAX / 2) return error;
+ count *= 2;
+ }
+}
+
+inline int set_thread_affinity(const std::vector& mask) {
+ constexpr size_t word_bits = sizeof(unsigned long) * CHAR_BIT;
+ if (mask.empty() || mask.size() > INT_MAX / word_bits) return EINVAL;
+ LinuxCpuSet target(static_cast(mask.size() * word_bits));
+ if (!target.set) return ENOMEM;
+ std::memcpy(target.set, mask.data(), target.bytes);
+ return pthread_setaffinity_np(pthread_self(), target.bytes, target.set);
+}
+
+inline int pin_thread_to_cpu(int core) {
+ if (core < 0 || core == INT_MAX) return EINVAL;
+ LinuxCpuSet target(core < CPU_SETSIZE ? CPU_SETSIZE : core + 1);
+ if (!target.set) return ENOMEM;
+ CPU_SET_S(core, target.bytes, target.set);
+ return pthread_setaffinity_np(pthread_self(), target.bytes, target.set);
+}
+
+} // namespace strata::kernels::cpu::detail
+#endif
diff --git a/src/kernels/cpu/pool_affinity_linux_test.cpp b/src/kernels/cpu/pool_affinity_linux_test.cpp
new file mode 100644
index 0000000000..6b5752af1f
--- /dev/null
+++ b/src/kernels/cpu/pool_affinity_linux_test.cpp
@@ -0,0 +1,290 @@
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+
+#include "strata/kernels/cpu/pool.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#if defined(__linux__)
+#include "pool_affinity_linux.hpp"
+
+#include
+#include
+#include
+
+namespace cpu = strata::kernels::cpu;
+
+class DynamicCpuSet {
+public:
+ explicit DynamicCpuSet(size_t count)
+ : count_(count), bytes_(CPU_ALLOC_SIZE(count)), set_(CPU_ALLOC(count)) {
+ if (set_) CPU_ZERO_S(bytes_, set_);
+ }
+
+ ~DynamicCpuSet() {
+ if (set_) CPU_FREE(set_);
+ }
+
+ DynamicCpuSet(const DynamicCpuSet&) = delete;
+ DynamicCpuSet& operator=(const DynamicCpuSet&) = delete;
+
+ cpu_set_t* get() { return set_; }
+ const cpu_set_t* get() const { return set_; }
+ size_t bytes() const { return bytes_; }
+ size_t count() const { return count_; }
+ explicit operator bool() const { return set_ != nullptr; }
+
+private:
+ size_t count_;
+ size_t bytes_;
+ cpu_set_t* set_;
+};
+
+static size_t initial_cpu_count() {
+ const long configured = sysconf(_SC_NPROCESSORS_CONF);
+ return (std::max)(size_t(128), configured > 0 ? (size_t) configured : size_t(0));
+}
+
+// Query with CPU_ALLOC rather than cpu_set_t: this must observe CPUs beyond CPU_SETSIZE too.
+static bool get_affinity(std::vector& ids) {
+ size_t count = initial_cpu_count();
+ for (int attempt = 0; attempt < 8; ++attempt) {
+ DynamicCpuSet set(count);
+ if (!set) return false;
+ const int error = pthread_getaffinity_np(pthread_self(), set.bytes(), set.get());
+ if (error == 0) {
+ ids.clear();
+ for (size_t i = 0; i < set.count(); ++i)
+ if (CPU_ISSET_S(i, set.bytes(), set.get())) ids.push_back((int) i);
+ return !ids.empty();
+ }
+ if (error != EINVAL || count > (size_t(1) << 20)) {
+ std::fprintf(stderr, "pthread_getaffinity_np failed: %s\n", std::strerror(error));
+ return false;
+ }
+ count *= 2;
+ }
+ std::fprintf(stderr, "pthread_getaffinity_np still needs a larger CPU set\n");
+ return false;
+}
+
+static bool set_affinity(const std::vector& ids) {
+ if (ids.empty()) return false;
+ const int highest = *std::max_element(ids.begin(), ids.end());
+ if (highest < 0) return false;
+ const size_t count = (std::max)(initial_cpu_count(), (size_t) highest + 1);
+ DynamicCpuSet set(count);
+ if (!set) return false;
+ for (int id : ids) {
+ if (id < 0 || (size_t) id >= count) return false;
+ CPU_SET_S((size_t) id, set.bytes(), set.get());
+ }
+ const int error = pthread_setaffinity_np(pthread_self(), set.bytes(), set.get());
+ if (error != 0) {
+ std::fprintf(stderr, "pthread_setaffinity_np failed: %s\n", std::strerror(error));
+ return false;
+ }
+ return true;
+}
+
+class OriginalAffinity {
+public:
+ bool capture() { return get_affinity(ids_); }
+ const std::vector& ids() const { return ids_; }
+ bool restore() { return ids_.empty() || set_affinity(ids_); }
+ ~OriginalAffinity() { if (!ids_.empty()) set_affinity(ids_); }
+
+private:
+ std::vector ids_;
+};
+
+static bool expect_affinity(const std::vector& expected, const char* label) {
+ std::vector actual;
+ if (!get_affinity(actual)) {
+ std::fprintf(stderr, "Linux affinity test: %s: cannot query current affinity\n", label);
+ return false;
+ }
+ if (actual != expected) {
+ std::fprintf(stderr, "Linux affinity test: %s: expected %zu CPUs, observed %zu\n",
+ label, expected.size(), actual.size());
+ return false;
+ }
+ return true;
+}
+
+static bool round_trip_full_mask(const std::vector& allowed) {
+ const int target = allowed.front();
+ const auto previous = cpu::pin_current_thread(target);
+ const bool pinned = previous.valid && expect_affinity({target}, "pin from full allowed mask");
+ cpu::restore_thread_affinity(previous);
+ return pinned && expect_affinity(allowed, "restore full allowed mask");
+}
+
+static bool topology_respects_current_mask() {
+ std::vector allowed;
+ if (!get_affinity(allowed)) return false;
+ const auto topology = cpu::detect_cpu_topology(false);
+ if (topology.worker_cores.empty()) return false;
+ for (int core : topology.worker_cores) {
+ if (!std::binary_search(allowed.begin(), allowed.end(), core)) {
+ std::fprintf(stderr, "Linux affinity test: topology selected disallowed CPU %d\n", core);
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool round_trip_sparse_nested(const std::vector& allowed) {
+ if (allowed.size() < 2) {
+ std::puts("SKIP sparse/nested affinity: fewer than two allowed CPUs");
+ return true;
+ }
+ const std::vector sparse{allowed.front(), allowed.back()};
+ if (!set_affinity(sparse) || !expect_affinity(sparse, "establish sparse mask") ||
+ !topology_respects_current_mask()) return false;
+
+ const auto outer = cpu::pin_current_thread(sparse.front());
+ const bool outer_pinned = outer.valid && expect_affinity({sparse.front()}, "outer pin");
+ if (!outer_pinned) {
+ cpu::restore_thread_affinity(outer);
+ return false;
+ }
+
+ const auto inner = cpu::pin_current_thread(sparse.back());
+ const bool inner_pinned = inner.valid && expect_affinity({sparse.back()}, "inner pin");
+ cpu::restore_thread_affinity(inner);
+ const bool outer_restored = expect_affinity({sparse.front()}, "restore inner pin to outer pin");
+ cpu::restore_thread_affinity(outer);
+ const bool sparse_restored = expect_affinity(sparse, "restore outer pin to sparse mask");
+ const bool full_restored = set_affinity(allowed) && expect_affinity(allowed, "restore full mask after sparse test");
+ return inner_pinned && outer_restored && sparse_restored && full_restored;
+}
+
+static bool round_trip_high_cpu(const std::vector& allowed, bool require_high) {
+ const auto it = std::find_if(allowed.begin(), allowed.end(), [](int id) { return id >= 64; });
+ if (it == allowed.end()) {
+ if (require_high) {
+ std::fprintf(stderr, "Linux affinity test: --require-high-cpu requested, but no allowed CPU ID is >= 64\n");
+ return false;
+ }
+ std::puts("SKIP high-CPU affinity path: no allowed CPU ID is >= 64 (use --require-high-cpu to require it)");
+ return true;
+ }
+ const int target = allowed.front() == *it ? (allowed.size() > 1 ? allowed.back() : *it) : allowed.front();
+ if (target == *it) {
+ if (require_high) {
+ std::fprintf(stderr, "Linux affinity test: --require-high-cpu needs at least two allowed CPUs to test restoration\n");
+ return false;
+ }
+ std::puts("SKIP high-CPU affinity round trip: only one CPU is allowed, so no meaningful pin transition is possible");
+ return true;
+ }
+
+ // Restrict the caller to one high-numbered CPU, then pin to a different originally allowed CPU.
+ // Restoring the high-only mask catches truncation even when the original process mask is sparse.
+ const std::vector high_only{*it};
+ if (!set_affinity(high_only) || !expect_affinity(high_only, "establish high-only mask") ||
+ !topology_respects_current_mask()) return false;
+ const auto previous = cpu::pin_current_thread(target);
+ const bool pinned = previous.valid && expect_affinity({target}, "pin away from high-only mask");
+ cpu::restore_thread_affinity(previous);
+ const bool restored = pinned && expect_affinity(high_only, "restore high-only mask");
+ if (restored) std::printf("High-CPU affinity round trip: saved CPU %d, pin target %d\n", *it, target);
+ return restored;
+}
+
+static bool negative_cpu_is_noop() {
+ std::vector before;
+ if (!get_affinity(before)) return false;
+ const auto invalid = cpu::pin_current_thread(-1);
+ std::vector after;
+ const bool unchanged = get_affinity(after) && before == after;
+ cpu::restore_thread_affinity(invalid);
+ return !invalid.valid && unchanged && expect_affinity(before, "negative CPU no-op");
+}
+
+static bool worker_thread_can_pin(int target) {
+ bool ok = false;
+ std::thread worker([&] {
+ std::vector before;
+ if (!get_affinity(before)) return;
+ std::vector saved;
+ if (cpu::detail::get_thread_affinity(saved) != 0) return;
+ const int pin_error = cpu::detail::pin_thread_to_cpu(target);
+ const bool pinned = pin_error == 0 && expect_affinity({target}, "worker thread helper pin") &&
+ sched_getcpu() == target;
+ const int restore_error = cpu::detail::set_thread_affinity(saved);
+ ok = pinned && restore_error == 0 && expect_affinity(before, "worker thread helper restore");
+ });
+ worker.join();
+ return ok;
+}
+
+static bool failed_pin_preserves_affinity() {
+ std::vector before;
+ if (!get_affinity(before)) return false;
+ // An ID outside the caller's current mask may still be online and legal, so it is not a
+ // reliable failure case. INT_MAX is rejected before allocating or changing any CPU set.
+ const int error = cpu::detail::pin_thread_to_cpu(INT_MAX);
+ std::vector after;
+ const bool helper_unchanged = get_affinity(after) && before == after;
+ if (error == 0) {
+ std::fprintf(stderr, "Linux affinity test: pin_thread_to_cpu(INT_MAX) unexpectedly succeeded\n");
+ return false;
+ }
+ const auto invalid = cpu::pin_current_thread(INT_MAX);
+ cpu::restore_thread_affinity(invalid);
+ std::vector after_public;
+ const bool public_unchanged = get_affinity(after_public) && before == after_public;
+ if (!helper_unchanged || invalid.valid || !public_unchanged)
+ std::fprintf(stderr, "Linux affinity test: failed pin changed the observed affinity or returned a valid snapshot\n");
+ return helper_unchanged && !invalid.valid && public_unchanged;
+}
+
+int main(int argc, char** argv) {
+ bool require_high = false;
+ for (int i = 1; i < argc; ++i) {
+ if (std::strcmp(argv[i], "--require-high-cpu") == 0) require_high = true;
+ else {
+ std::fprintf(stderr, "Unknown argument: %s\n", argv[i]);
+ return 2;
+ }
+ }
+
+ OriginalAffinity original;
+ if (!original.capture()) {
+ std::fprintf(stderr, "Linux affinity test: cannot capture original thread affinity\n");
+ return 1;
+ }
+ const auto allowed = original.ids();
+
+ bool ok = round_trip_full_mask(allowed) && worker_thread_can_pin(allowed.back());
+ if (ok) ok = round_trip_sparse_nested(allowed);
+ if (ok) ok = round_trip_high_cpu(allowed, require_high);
+ if (ok) ok = negative_cpu_is_noop();
+ if (ok) ok = failed_pin_preserves_affinity();
+ const bool restored = original.restore() && expect_affinity(allowed, "final original affinity cleanup");
+ if (!ok || !restored) return 1;
+
+ std::printf("Linux affinity test: full, sparse/nested, worker helper, invalid-target, failed-pin, and cleanup checks passed (%zu allowed CPUs)\n",
+ allowed.size());
+ return 0;
+}
+
+#else
+
+int main() {
+ std::puts("SKIP Linux affinity test: Linux is required");
+ return 0;
+}
+
+#endif
diff --git a/src/kernels/cpu/pool_affinity_test.cpp b/src/kernels/cpu/pool_affinity_test.cpp
new file mode 100644
index 0000000000..052ae59b23
--- /dev/null
+++ b/src/kernels/cpu/pool_affinity_test.cpp
@@ -0,0 +1,148 @@
+#include "strata/kernels/cpu/pool.hpp"
+#include "pool_affinity_win.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace cpu = strata::kernels::cpu;
+
+static bool check(bool ok, const char* message) {
+ if (!ok) std::fprintf(stderr, "Windows affinity test: %s (error %lu)\n", message, (unsigned long) GetLastError());
+ return ok;
+}
+
+static bool select(const std::vector& ids) {
+ return SetThreadSelectedCpuSets(GetCurrentThread(), ids.empty() ? nullptr : ids.data(), (ULONG) ids.size()) != 0;
+}
+
+static bool selected_is(std::vector expected) {
+ std::vector actual;
+ if (!cpu::detail::get_thread_cpu_sets(actual)) return false;
+ std::sort(actual.begin(), actual.end());
+ std::sort(expected.begin(), expected.end());
+ return actual == expected;
+}
+
+// Establish reachability before the round trip: CPU Sets are soft affinity and cannot override an
+// existing job/hard-affinity restriction. Afterwards, require the same observable reachability.
+static bool observe(int core) {
+ for (int i = 0; i < 200; ++i) {
+ Sleep(1);
+ PROCESSOR_NUMBER current{};
+ GetCurrentProcessorNumberEx(¤t);
+ if ((int) current.Group * 64 + current.Number == core) return true;
+ }
+ return false;
+}
+
+static bool host_round_trips(const std::vector& cores, const std::vector& ids) {
+ if (!check(select({}), "clear initial thread CPU Sets")) return false;
+ std::vector reachable;
+ for (size_t i = 0; i < cores.size(); ++i) {
+ if (!check(select({ids[i]}), "select baseline CPU Set")) return false;
+ reachable.push_back(observe(cores[i]));
+ }
+ if (!check(select({}), "clear baseline CPU Sets")) return false;
+
+ for (size_t i = 0; i < cores.size(); ++i) {
+ const auto previous = cpu::pin_current_thread(cores[i]);
+ const bool pinned = previous.valid && previous.cpu_sets.empty() && selected_is({ids[i]});
+ cpu::restore_thread_affinity(previous);
+ if (!check(pinned && selected_is({}), "restore empty thread assignment")) return false;
+ }
+
+ // GetThreadGroupAffinity alone misses a lost implicit all-group state. Selecting each CPU Set
+ // again must still permit execution there, without ever setting a hard group affinity.
+ for (size_t i = 0; i < cores.size(); ++i) {
+ if (!check(select({ids[i]}), "select CPU Set after restore")) return false;
+ if (reachable[i]) {
+ if (!check(observe(cores[i]), "lost CPU/group eligibility after host restore")) return false;
+ std::printf("Host restore: still executes on group %d processor %d\n", cores[i] / 64, cores[i] & 63);
+ } else {
+ std::printf("SKIP scheduling observation for group %d: not reachable before test\n", cores[i] / 64);
+ }
+ }
+
+ // A nonempty selection may span groups. Nested host selections must restore the entire set.
+ if (!check(select(ids), "set existing multi-CPU selection")) return false;
+ const auto outer = cpu::pin_current_thread(cores.front());
+ const auto inner = cpu::pin_current_thread(cores.back());
+ const bool inner_ok = inner.valid && selected_is({ids.back()});
+ cpu::restore_thread_affinity(inner);
+ const bool outer_ok = outer.valid && selected_is({ids.front()});
+ cpu::restore_thread_affinity(outer);
+ if (!check(inner_ok && outer_ok && selected_is(ids), "restore nested / existing CPU Set selections")) return false;
+ const auto invalid = cpu::pin_current_thread(-1);
+ cpu::restore_thread_affinity(invalid);
+ if (!check(!invalid.valid && selected_is(ids), "invalid target must not mutate selection")) return false;
+ return check(select({}), "clear final host selection");
+}
+
+static bool inherited_default(int core, ULONG id, int temporary_core, ULONG temporary_id) {
+ ULONG count = 0;
+ if (!GetProcessDefaultCpuSets(GetCurrentProcess(), nullptr, 0, &count) &&
+ GetLastError() != ERROR_INSUFFICIENT_BUFFER) return false;
+ std::vector saved(count);
+ if (count && !GetProcessDefaultCpuSets(GetCurrentProcess(), saved.data(), count, &count)) return false;
+ saved.resize(count);
+ if (!check(SetProcessDefaultCpuSets(GetCurrentProcess(), &id, 1) != 0, "set process default")) return false;
+ bool ok = false;
+ std::thread host([&] {
+ if (!select({})) return;
+ const bool reachable = observe(core);
+ const auto previous = cpu::pin_current_thread(temporary_core);
+ const bool overridden = previous.valid && selected_is({temporary_id});
+ cpu::restore_thread_affinity(previous);
+ ULONG inherited = 0, required = 0;
+ ok = overridden && previous.cpu_sets.empty() && selected_is({}) &&
+ GetProcessDefaultCpuSets(GetCurrentProcess(), &inherited, 1, &required) &&
+ required == 1 && inherited == id && (!reachable || observe(core));
+ });
+ host.join();
+ const bool restored = SetProcessDefaultCpuSets(GetCurrentProcess(), saved.empty() ? nullptr : saved.data(),
+ (ULONG) saved.size()) != 0;
+ return check(ok && restored, "restore inherited process default");
+}
+
+int main() {
+ const auto all = cpu::physical_cores(false);
+ std::vector cores;
+ std::vector ids;
+ for (int core : all) {
+ if (std::any_of(cores.begin(), cores.end(), [core](int other) { return other / 64 == core / 64; })) continue;
+ ULONG id = 0;
+ if (!cpu::detail::cpu_set_for_core(core, id)) continue;
+ cores.push_back(core);
+ ids.push_back(id);
+ }
+ if (!check(!cores.empty(), "no usable CPU Sets")) return 1;
+ if (!check(cores.size() == GetActiveProcessorGroupCount(), "CPU Sets do not cover all active groups")) return 1;
+
+ // Hard pins belong on pool-owned threads; never contaminate the caller under test with one.
+ for (size_t i = 0; i < cores.size(); ++i) {
+ bool ok = false;
+ std::thread worker([&] {
+ if (!cpu::detail::set_thread_group_affinity(cores[i], (int) i)) return;
+ GROUP_AFFINITY pinned{};
+ if (!GetThreadGroupAffinity(GetCurrentThread(), &pinned)) return;
+ ok = pinned.Group == cores[i] / 64 && pinned.Mask == (KAFFINITY(1) << (cores[i] & 63));
+ // Host placement must also preserve an existing restrictive hard affinity.
+ const auto previous = cpu::pin_current_thread(cores.back());
+ cpu::restore_thread_affinity(previous);
+ GROUP_AFFINITY after{};
+ ok = ok && previous.valid && GetThreadGroupAffinity(GetCurrentThread(), &after) &&
+ after.Group == pinned.Group && after.Mask == pinned.Mask;
+ });
+ worker.join();
+ if (!check(ok, "worker hard pin / caller hard-affinity preservation")) return 1;
+ }
+
+ bool host_ok = false;
+ std::thread host([&] { host_ok = host_round_trips(cores, ids); });
+ host.join();
+ if (!host_ok || !inherited_default(cores.back(), ids.back(), cores.front(), ids.front())) return 1;
+ std::printf("Windows affinity test: worker pins, host CPU Sets and process-default restoration passed (%zu groups)\n", cores.size());
+ return 0;
+}
diff --git a/src/kernels/cpu/pool_affinity_win.hpp b/src/kernels/cpu/pool_affinity_win.hpp
new file mode 100644
index 0000000000..6f0e402022
--- /dev/null
+++ b/src/kernels/cpu/pool_affinity_win.hpp
@@ -0,0 +1,64 @@
+#pragma once
+
+#if defined(_WIN32)
+#ifndef WIN32_LEAN_AND_MEAN
+#define WIN32_LEAN_AND_MEAN
+#endif
+#include
+
+#include
+#include
+#include