Skip to content

feat: KV cache reuse and prompt prefill caching - #46

Open
Skyrion9 wants to merge 5 commits into
rodrigomatta:mainfrom
Skyrion9:KV-Reuse
Open

feat: KV cache reuse and prompt prefill caching#46
Skyrion9 wants to merge 5 commits into
rodrigomatta:mainfrom
Skyrion9:KV-Reuse

Conversation

@Skyrion9

@Skyrion9 Skyrion9 commented Aug 4, 2026

Copy link
Copy Markdown

In upstream we do redundant prompt prefills across repeated server requests. This PR caches the KV state and generation StepResult after the first prefill. On cache hit, the ~190–210 ms prefill pass is skipped entirely and generation resumes directly from the cached position.

This is more of an "edge" scenario in that you might want to regenerate the same input over again. For example this could be due to using a lower quant or expecting a more fitting reading of the sentence as AI can get the emotions or tonals wrong.

This PR builds upon and requires PR #44

You may revert to old behavior by using (--no-kv-reuse) to free and reallocate the KV buffer on every request.

Old behavior:

Request 1: clear_kv_cache → init_kv_cache (alloc ~168 MB Vulkan) → prefill (~200 ms) → generate
Request 2: clear_kv_cache → init_kv_cache (alloc ~168 MB Vulkan) → prefill (~200 ms) → generate  ← redundant

To solve this we cache the prefill result keyed by (voice_id | prompt_text + ref_codes) + text

After prefill, the KV state is serialized to a strided buffer in system RAM on a background thread that overlaps with generation. On cache hit, the state is restored via a single PCIe upload of the active positions.

New behavior:

Request 1: prefill (~200 ms) → generate → [async: save_kv_state to RAM]
Request 2: cache HIT → restore_kv_state (~15-30 ms PCIe) → generate   ← prefill skipped

When running with (--kv-cache-vram) the KV buffer stays allocated in VRAM between requests. free_compute_buffers() is skipped when keep_kv_on_gpu is set, so the next request with a matching key resumes without the PCIe transfer cost.

Request 1: prefill (~200 ms) → generate → [KV stays in VRAM]
Request 2: cache HIT → set_n_past() (~0 ms) → generate [no transfer cost]

This just consumes ~168 MB VRAM which would've been stored in RAM instead between requests without this flag set.

Architecture

Any change to voice, prompt, or text invalidates the cache.

Cache entry

struct PrefillCacheEntry {
    std::string cache_key;
    int32_t n_past;              // position after prefill
    int32_t max_seq_len;         // KV buffer capacity at save time
    StepResult state;            // hidden + logits from last prefill token
    std::vector<uint8_t> k_data; // compact KV serialization (RAM mode)
    std::vector<uint8_t> v_data;
    bool vram_resident;          // true if KV is still in VRAM
    bool valid;
};

KV lifecycle per request

need_fresh_kv = !kv_reuse || kv_max_seq_len() < max_seq_len || kv_max_seq_len() == 0

if need_fresh_kv:
    clear_kv_cache()          // free buffer
    init_kv_cache(max_seq_len) // allocate + zero
else:
    reset_kv_cache()          // memset only, no realloc

if cache HIT:
    VRAM mode: set_n_past(cached_n_past)
    RAM mode:  restore_kv_state(k_data, v_data, cached_n_past)
    → generate(..., &cached_state)   // skip prefill

if cache MISS:
    prefill_fast(...)                // full forward pass
    if kv_reuse:
        save cache entry
        RAM mode: spawn kv_save_thread (async, joined after generate)

end of request:
    if !more_segments_pending:
        if !kv_reuse:       clear_kv_cache()
        elif !keep_kv_on_gpu: set_n_past(0)   // keep buffer, reset position
        // keep_kv_on_gpu: do nothing, buffer stays

CLI flags

Flag Default Effect
--no-kv-reuse off (reuse enabled) Disables KV cache reuse and prefill caching. Restores old per-request free/realloc behavior.
--kv-cache-vram off (RAM mode) Pins the prefill KV cache in VRAM between requests instead of serializing to system RAM. Lower latency, higher VRAM idle.

Metrics

New fields in the [Metrics] Synthesis log line:

  • prefill=<ms> - prefill duration (0.0 on cache hit)
  • prefill=cached / prefill=computed - cache hit/miss indicator

New in [Metrics] Generate:

  • (prefill cached) suffix on the "Done" line when initial_state was used

Thread safety

  • kv_save_thread writes to prefill_cache_.k_data / v_data asynchronously. It is joined after generate() returns in both the overlapped and sequential decode paths, before any subsequent request can read the cache.
  • synthesize_mutex_ serializes all requests, so prefill_cache_ is never accessed concurrently.
  • save_kv_state / restore_kv_state operate on the KV buffer under the same mutex.

Interaction with more_segments_pending

Within a segmented server request (multiple sentences), more_segments_pending is true for all segments except the last. The KV cache and prefill state are preserved across segments — the end-of-request cleanup is deferred until the final segment, avoiding per-segment prefill for multi segmented generation.

Expected impact

Scenario Before After (RAM) After (VRAM)
Repeated request, same voice+text ~200 ms prefill ~15–30 ms restore ~0 ms
KV buffer realloc per request ~5–10 ms skipped (buffer reused) skipped
VRAM held between requests 0 0 ~168 MB

Summary by CodeRabbit

  • New Features
    • Added configurable CPU/GPU placement for decoder and embedding workloads.
    • Added VRAM swapping, hot-swapping, persistent pipelines, and GPU memory diagnostics.
    • Added prefill and KV-cache reuse to reduce repeated generation work.
    • Added memory-mapped model loading with lazy weight management.
    • Added controls for saving, restoring, and resetting generation state.
  • Performance
    • Improved synthesis through cached prefill states and overlapping generation with audio decoding.
    • Added page-cache management to improve model loading behavior.
  • CLI
    • Added command-line options for the new memory, caching, and hardware controls.

Skyrion9 added 5 commits July 19, 2026 22:49
Replaced fread pipeline with a cross-platform memory-mapped file/mmap. This reduces boot times by allowing lazy weight loading for sub-models and no copy DMA.

- Introducing MappedFile RAII wrapper for zero-copy memory-mapped file I/O supporting POSIX (mmap/madvise) and Windows (CreateFileMapping).
- Decoupled GGUF metadata parsing from VRAM/RAM buffer allocation.
- Audio Codec consumes 0 MB VRAM at init, Weight buffers are allocated on-demand, VQ codebook caches are populated directly from the mmap pointer into system RAM.
- Slow-AR model weights are page-faulted from the mmap pointer to VRAM, bypassing intermediate buffers.
- We've replaced read_all_tensor_data and read_tensor_data in favor of lazy loading.
… for server mode

Utilizes mmap and lazy loading introduced in the previous commit to dynamically manage VRAM occupancy. Intelligently swapping in and out the required submodels depending on which phase of the processing we're at. This minimizes both peak and idle VRAM usage, allowing running larger models without OOM and increases speeds by reducing memory pressure.

- Phase-Gated VRAM Swapping: Slow-AR weights and KV cache are freed immediately after generation completes, right before Audio Codec weights are restored for decode.
.. We don't need the 4.2 GB (Q8_0) SlowAR to occupy VRAM as we're running inference on the Audio Codec part and possibly crash via OOM.
.. Without this system, Q8_0 would hit 7-7.5 GB VRAM usage during final phase of the processing (Audio  Codec) in one sentence long generation. Now it's just ~2.3 GB (Vulkan, Linux latest MESA)

- CLI flags --no-vram-swap (opt out) and --hot-swap (opt in) to customize behavior.
- vram-swap retains the OS page cache between requests, pagefaulting we read from RAM instead of disk, this only takes a few seconds.
.. Also keeps compute buffers etc. in VRAM which are relatively small (~168 MB Vulkan) so we can immediately begin processing.
.. The gguf occupies system RAM instead of VRAM, however, this occupancy is not "locked" meaning OS will free it for other applications as needed.
.. This is basically tells the OS "Here's this memory pool that maps to compute buffer, keep it alive but also don't hesitate to free the memory if other apps need it."

- Aggressive hot-swap mode goes a step further and explicitly instructs the kernel to reclaim memory.
.. This is optimal if you want minimal, 100 MB RAM + 25 MB VRAM idles without bothering the OS and have the model on flash storage.

- Backend synchronization via ggml_backend_synchronize to ensure different backends (Vulkan, CUDA, Metal, etc.) reclaim memory sooner than later to prevent PCIe thrashing.
.. This is critical to reduce peak VRAM usage therefore allowing us to run larger quants without filling VRAM to the brim. Also reduces pressure on other apps and their VRAM occupancies.

- Background prefetching - spawns a background thread to restore Slow-AR weights concurrently with CPU-bound voice profile loading to hide PCIe latency.
- Thread safe, all synthesis entry points join pending_offload_thread_ before proceeding to prevent race conditions between background eviction and new weight restoration.
Replace MADV_RANDOM with MADV_SEQUENTIAL. Weight loading iterates tensors in sequential file order, so disabling readahead forced ~1.3M individual 4 KB I/O syscalls on
..cold reads after drop_page_cache(). MADV_SEQUENTIAL enables aggressive kernel readahead from byte 0, reducing syscall count.

- Add MADV_SEQUENTIAL (Linux+macOS), FILE_FLAG_SEQUENTIAL_SCAN (Windows) as the equivalent hint.
- Add MADV_HUGEPAGE (Linux) to reduce TLB pressure during multi-GB loads
- Add MADV_DONTDUMP (Linux) to exclude the mapping from core dumps
…ipeline

Extended the phase-gated VRAM swap with finer-grained codec weight management, and concurrent decode threading.

- Codec encoder/decoder granular split: codec weights are classified at load time into encoder and decoder groups via tensor name prefixes.

- New methods (free/restore/is_on_gpu/get_bytes for each group) allowing the pipeline to load only the encoder for reference audio encoding, free it before generation, ..load only the decoder for the decode phase.

- Minimizing VRAM footprint at each step while catering to lower latency by utilizing background threads to lazy load as needed.

- The priority is to reduce Slow-AR processing stage's VRAM footprint as this is the heaviest model in the pipeline, so we never keep another (unnecessary) model loaded when that's on. And we free it before loading any new models. In effect, OOM is far less likely and as long as your GPU can fit the Slow-AR and its buffers it'll run the whole pipeline without issue.

- Deferred weight loading: when VRAM swap is active and the model prefers GPU, init() skips Slow-AR weight allocation entirely and calls warm_page_cache() to pre-fault mmap pages via
..MADV_WILLNEED + MADV_COLD (Linux), fcntl F_RDAHEAD (macOS), or PrefetchVirtualMemory
..(Windows). First-request restore hits warm RAM instead of cold disk. Replacing the old incorrect VirtualUnlock.

- prefers_gpu() provides an intent-based check that works before weights are loaded, replacing is_weights_on_gpu() for init decisions to handle edge cases better.

- Codec eviction + Slow-AR restore and KV cache init now runs in background threads, hiding PCIe latency behind the CPU bound prompt construction.

- Overlapped decode path: when Slow-AR is on GPU and codec is on CPU, a producer-consumer thread pair decodes audio frames concurrently with generation via mutex, condition variable, and atomic frame counters. This reduces Total RTF by starting the CPU codec work as early as we can instead of waiting for GPU to finish Slow-AR processing in its entirety.

- server-aware swapping: more_segments_pending server sets this flag on all sentence segments except the last within a single request, keeping Slow-AR resident in VRAM across segments and eliminating per-segment restore overhead.

- Streaming path: granular codec management (free encoder, restore decoder only), Slow-AR freed in non-hot-swap persistent mode (fixes VRAM leak where Slow-AR was never freed after streaming requests).

- pre_restore_thread removed from synthesize_raw/synthesize_streaming_raw;
  replaced with pending_offload_thread_ join before encoding to prevent
  races between background eviction and encoder weight restoration.

- --fast-decoder-cpu / --codebook-cpu CLI flags force specific tensor groups onto CPU, saving ~200-400 MB / ~56 MB VRAM respectively at the cost of PCIe transfers. Previously, any --gpu-layers value also offloaded fast-decoder and codebook tensors. These flags decouple that decision for finer-grained VRAM control.

- allocate_weight_buffers nulls stale tensor data/buffer pointers after freeing, preventing use-after-free when weights are re-allocated after a free/restore cycle.

- MappedFile::open uses CreateFileW with UTF-8 -> UTF-16 conversion, fixing model loading on Windows paths containing non-ASCII characters.
Caches the prompt prefill result across requests so repeated synthesis with the same voice and text prefix skipping the ~190-210 ms prefill pass. The KV buffer is reused when its capacity covers the new request, replacing per-request free/realloc with a memset reset.

- PrefillCacheEntry stores the prefill StepResult, n_past position, and KV cache contents keyed by voice_id (or prompt text + first 16 reference codes) concatenated with the synthesis text. Cache is invalidated on key mismatch or insufficient max_seq_len.

- Two residency modes controlled by --kv-cache-vram:
.. Default (system RAM): KV state is serialized to a compact strided buffer in system RAM via save_kv_state() after prefill, running on a background thread that overlaps with generation. Restored via restore_kv_state() on cache hit, takes a PCIe transfer instead of a full re-prefill.
.. --kv-cache-vram (VRAM pin flag): KV buffer stays allocated in VRAM between requests. free_compute_buffers() is skipped when keep_kv_on_gpu is set by this, so the next request with a matching key resumes generation immediately without PCIe transfer latency.

- generate() accepts an optional initial_state pointer; when provided, the internal prefill is skipped and the cached StepResult (hidden state + logits + n_past) is used directly. The pipeline performs the prefill itself on cache miss and passes the resulting state.

- reset_kv_cache() memsets the existing KV buffer to zero without freeing or reallocating it, replacing the old clear + init_kv_cache cycle when the buffer is already large enough for the new request.

- KV lifecycle respects more_segments_pending: within a segmented server request, the KV buffer and prefill cache are preserved across sentence segments so we don't incur transfer penalties knowing the full prompt is still processing.

- CLI flags: --no-kv-reuse (opt out, restores old per-request free/realloc behavior), --kv-cache-vram (pin prefill cache in VRAM between requests instead of serializing to system RAM).

- Metrics: prefill_ms and prefill=cached/computed added to the synthesis log line.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a cross-platform MappedFile abstraction and converts AudioCodec and SlowARModel to lazy, mmap-backed weight loading with GPU/CPU residency control. It adds KV-cache save/restore, prefill-state reuse in generate(), and reworks Pipeline synthesis to support VRAM swap, hot-swap offloading, and prefill caching, exposed via new CLI and server options.

Changes

Mmap-based lazy weight loading and VRAM swap pipeline

Layer / File(s) Summary
MappedFile cross-platform implementation
include/s2_mapped_file.h, src/s2_mapped_file.cpp, CMakeLists.txt
Adds the s2::MappedFile class with open, close, move semantics, and page-cache warm/drop operations for Windows and Unix, and wires the new source file into the build.
AudioCodec mmap-backed lazy weight loading
include/s2_codec.h, src/s2_codec.cpp
Removes read_tensor_data/refresh_host_caches; adds tensor offset tracking, GGUF mmap, deferred weight allocation, encoder/decoder GPU residency APIs, and on-demand weight loading during encode/decode.
SlowARModel mmap loading, KV-cache, GPU weight management
include/s2_model.h, src/s2_model.cpp
Adds fast_decoder_cpu/codebook_embeddings_cpu load options, mmap-based deferred weight loading, KV-cache reset/save/restore, GPU weight free/restore, compute-buffer teardown, and GPU memory reporting.
generate() prefill-state reuse
include/s2_generate.h, src/s2_generate.cpp
Adds optional initial_state parameter to skip prefill computation and reuse a cached state, with logging indicating cached prefill.
Pipeline VRAM swap and prefill-cache orchestration
include/s2_pipeline.h, src/s2_pipeline.cpp
Adds new PipelineParams fields, prefill-cache-key computation, GPU-preference state tracking, phase-based VRAM restoration threads, overlapped decode, hot-swap background offload, and expanded metrics for raw and streaming synthesis.
CLI flags and server segment wiring
src/main.cpp, src/s2_server.cpp
Adds usage text and handlers for the new CPU-placement, VRAM-swap, hot-swap, and KV-reuse options, marks server pipeline params persistent, and sets more_segments_pending on segment params.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • rodrigomatta/s2.cpp#40: Both PRs modify SlowARModel KV-cache and attention handling in src/s2_model.cpp; this PR's KV-cache save/restore APIs interact with the layout from that PR.
  • rodrigomatta/s2.cpp#44: Directly related, with matching changes across the same files, APIs, and mmap-based lazy-loading/VRAM-swap implementation.

Suggested reviewers: rodrigomatta, subspecs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the PR's main changes: KV cache reuse and prompt prefill caching.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/s2_codec.cpp (1)

202-225: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Free backend_cpu in reset_codec_impl.

load_shared creates impl_->backend_cpu at lines 958-960. reset_codec_impl frees backend, but not backend_cpu. The final impl = AudioCodec::Impl() overwrites the handle, so the CPU backend leaks on every reload and on destruction. If backend_cpu is not used by any code path, remove the field instead.

🔒️ Proposed fix to release the CPU backend
     if (impl.backend) {
         ggml_backend_free(impl.backend);
         impl.backend = nullptr;
     }
+    if (impl.backend_cpu) {
+        ggml_backend_free(impl.backend_cpu);
+        impl.backend_cpu = nullptr;
+    }
     impl = AudioCodec::Impl();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_codec.cpp` around lines 202 - 225, Update reset_codec_impl to release
impl.backend_cpu with ggml_backend_free and clear it before resetting the Impl
object, matching the existing backend cleanup. If backend_cpu has no consumers
beyond its creation in load_shared, remove the unused field and its
initialization instead.
src/s2_generate.cpp (1)

192-208: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the misleading prefill log on the supplied-state path.

src/s2_pipeline.cpp always passes a non-null initial_state: &cached_state on a cache hit, and &prefill_state that it just computed in this request (lines 1117-1118 and 1184-1185). So every Pipeline synthesis prints (prefill cached) and prefill=0 here, including requests that paid the full prefill cost. The [Generate] line then contradicts the [Metrics] Synthesis: ... prefill= line.

State only what this function knows.

🐛 Proposed log fix
         std::cout << "[Generate] Done: " << out.n_frames
                   << " frames generated."
-                  << (initial_state ? " (prefill cached)" : "") << std::endl;
+                  << (initial_state ? " (prefill supplied by caller)" : "") << std::endl;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_generate.cpp` around lines 192 - 208, Update the verbose logging in
the generation function around initial_state and prefill_ms so it does not label
every supplied state as “prefill cached.” Report only that an initial state was
supplied, and preserve the measured prefill value without inferring whether it
came from a cache; keep the [Generate] and [Metrics] lines consistent.
🧹 Nitpick comments (5)
include/s2_model.h (1)

113-115: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reject an out-of-range value in set_n_past.

set_n_past writes n_past_ without comparing it to max_seq_len_. The pipeline sets this value from cached prefill state, so a stale or oversized value is possible. eval_cached catches the overflow later and returns false, which makes the cause hard to locate. Validate at the setter.

♻️ Proposed refactor
-    void    set_n_past(int32_t n)    { n_past_ = n; }
+    void    set_n_past(int32_t n)    { n_past_ = (n < 0) ? 0 : std::min(n, max_seq_len_); }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@include/s2_model.h` around lines 113 - 115, Update set_n_past in the model
state API to validate the supplied value against max_seq_len_ before assigning
n_past_. Reject values outside the valid range, including oversized cached
prefill state, while preserving assignment for valid values and making the
rejection explicit to callers.
src/s2_codec.cpp (1)

966-966: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid copying the whole weight set in the ternary.

Model->weight_tensor_set() returns a reference, but the conditional expression materializes a std::unordered_set prvalue, so this line copies every model tensor pointer and binds model_weights to the temporary. Lifetime extension applies, so cppcheck's danglingTemporaryLifetime hint at line 979 is a false positive, but the copy is unnecessary and the construct is fragile. Use a function-local empty set as the fallback.

♻️ Proposed refactor
-        const auto & model_weights = Model ? Model->weight_tensor_set() : std::unordered_set<ggml_tensor*>();
+        static const std::unordered_set<ggml_tensor*> empty_weight_set;
+        const std::unordered_set<ggml_tensor*> & model_weights =
+            Model ? Model->weight_tensor_set() : empty_weight_set;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_codec.cpp` at line 966, Update the model_weights initialization near
the weight-processing logic to use a function-local empty std::unordered_set as
the fallback, then bind the reference through branches or equivalent
reference-preserving logic so Model->weight_tensor_set() is not copied. Keep the
existing behavior for both present and absent Model cases.

Source: Linters/SAST tools

src/s2_generate.cpp (1)

38-62: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Add a size guard on the supplied state.logits.

On the cached path, state is fully caller-supplied. Line 81 then indexes state.logits up to vocab_size without a size check. Current callers pass populated states, so this is defensive only.

♻️ Optional guard
     if (initial_state) {
         state = *initial_state;
+        if (static_cast<int32_t>(state.logits.size()) < vocab_size) {
+            std::cerr << "[Generate] initial_state has invalid logits size." << std::endl;
+            return out;
+        }
     } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_generate.cpp` around lines 38 - 62, Add a defensive size check for
caller-supplied state.logits in the initial_state branch before the later
vocab_size-indexed access. Ensure the logits collection contains at least
vocab_size entries; otherwise handle the invalid cached state through the
existing failure path without indexing it. Keep the normal prefill path and
valid cached-state behavior unchanged.
src/s2_pipeline.cpp (2)

1105-1114: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Publish frames_available under decode_mtx.

The callback stores frames_available and calls notify_one() without holding decode_mtx. The decode thread evaluates its predicate under the lock at line 1088. A notification that lands between the predicate check and the wait is lost, so that decode batch waits for the next frame or for gen_done.

The final gen_done.store(true) at line 1128 does hold the lock, so the thread always wakes and drains. The effect is added latency, not lost audio. Publish the counter the same way as gen_done.

♻️ Proposed change
         gen_params.on_frame = [&](const FrameCallbackData & fcd) -> bool {
             {
                 std::lock_guard<std::mutex> lock(decode_mtx);
                 for (int32_t cb = 0; cb < fcd.num_codebooks; ++cb)
                     accum[cb].push_back(fcd.codes[cb]);
+                frames_available.store(fcd.total_frames);
             }
-            frames_available.store(fcd.total_frames);
             decode_cv.notify_one();
             return true;
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_pipeline.cpp` around lines 1105 - 1114, Update the on_frame callback
in the gen_params setup to assign frames_available while holding decode_mtx,
alongside the accum updates, before calling decode_cv.notify_one(). Preserve the
existing atomic value and notification behavior, and do not move unrelated
callback logic.

1163-1178: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the hot-swap offload into one helper.

This lambda body is duplicated verbatim at lines 1220-1229 and at lines 1604-1613 in synthesize_streaming_prompt_codes_locked. Three copies of a VRAM/RAM release sequence will drift.

Each site also move-assigns into pending_offload_thread_. A move-assign onto a joinable std::thread calls std::terminate. Every current caller joins at request entry, so the target is empty today. A single helper lets you assert that invariant once.

♻️ Proposed helper
// include/s2_pipeline.h, private section
void spawn_hot_swap_offload_locked();
// src/s2_pipeline.cpp
void Pipeline::spawn_hot_swap_offload_locked() {
    if (pending_offload_thread_.joinable()) {
        pending_offload_thread_.join();
    }
    safe_print_ln("[Pipeline] Hot-Swap: Releasing compute resources...");
    model().free_compute_buffers();
    safe_print_ln("[Pipeline] Hot-Swap: Spawning background thread to free VRAM & RAM...");
    pending_offload_thread_ = std::thread([this]() {
        if (model().is_weights_on_gpu())   model().free_gpu_weights();
        if (codec().is_decoder_on_gpu())   codec().free_decoder_weights();
        if (codec().is_encoder_on_gpu())   codec().free_encoder_weights();
        if (codec().is_weights_on_gpu())   codec().free_gpu_weights();
        model().mapped_file().drop_page_cache();
        codec().mapped_file().drop_page_cache();
        safe_print_ln("[Pipeline] Hot-Swap: Background VRAM & RAM free complete.");
    });
}

Then call spawn_hot_swap_offload_locked() at lines 1163-1178, 1216-1229, and 1599-1613.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_pipeline.cpp` around lines 1163 - 1178, Extract the duplicated
hot-swap VRAM/RAM release logic from the three call sites in
synthesize_streaming_prompt_codes_locked into a private Pipeline helper named
spawn_hot_swap_offload_locked(). Have the helper join any existing
pending_offload_thread_ before releasing compute resources and spawning the
background offload thread, then replace each inline lambda and move-assignment
at the three sites with calls to the helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/s2_codec.cpp`:
- Around line 1677-1715: Update read_f32 in refresh_host_caches_from_mmap to
throw for tensor types other than GGML_TYPE_F32 and GGML_TYPE_F16, matching
tensor_to_f32 behavior, instead of returning zero-filled data. In load_vq,
validate that the loaded codebook contains at least cb_size * cb_dim elements
before computing codebook_norm, and throw on mismatch to prevent out-of-bounds
access.
- Around line 1717-1740: Fix ownership tracking across ensure_weights_loaded,
restore_weights_to_gpu, free_encoder_weights, and free_decoder_weights. Do not
treat encoder_on_gpu or decoder_on_gpu alone as proof that all_codec_weights
remain attached: restore or reload the missing partition after detachment, free
and clear model_buf before switching to partition buffers, and ensure each
free_* method releases model_buf ownership when it is the active backing
allocation before clearing tensor data.
- Around line 138-183: The allocate_codec_buffers function currently attempts to
allocate all tensors in a single ggml_backend_buft_alloc_buffer call, which can
fail on backends with allocation size limits. Follow the same chunking approach
as allocate_weight_buffers by checking ggml_backend_buft_get_max_size(buft) and
splitting tensor allocation into multiple buffers when total_bytes exceeds the
backend limit. Additionally, ensure tensor->data and tensor->buffer are reset to
initial values (likely nullptr) before attempting allocation and when allocation
or tensor placement fails, matching the model allocator's cleanup behavior.

In `@src/s2_mapped_file.cpp`:
- Around line 41-58: Update MappedFile’s open/mapping flow to keep the object
closed when no mapped view exists: store the file length in a local variable,
reject zero-length files unless an explicit open-state mechanism supports them,
and assign size_ only after MapViewOfFile or mmap succeeds. Apply the same
ordering and failure-state behavior to the corresponding path around the later
mapping logic.
- Around line 190-198: Update the Windows branch of
MappedFile::drop_page_cache() so it does not call SetProcessWorkingSetSizeEx
with -1, -1 and trim the entire process working set. Make this per-mapping
cache-drop operation a no-op on Windows, unless an existing application-wide
eviction policy explicitly requires the process-wide call.
- Around line 221-225: Update the Windows configuration before the `<windows.h>`
include so `_WIN32_WINNT` targets Windows 8 or later, enabling the
`PrefetchVirtualMemory` declaration used in the `_WIN32` branch of
`S2MappedFile`; preserve the existing call and behavior.
- Around line 204-210: Remove the conditional MADV_COLD madvise call from
warm_page_cache(), while preserving the existing MADV_WILLNEED and
MADV_SEQUENTIAL hints.

In `@src/s2_model.cpp`:
- Around line 671-702: Fix KV-cache serialization in src/s2_model.cpp lines
671-702 within SlowARModel::save_kv_state by using a per-layer layer_bytes value
based on n_positions * memory_k_->nb[2], removing the head loop, and copying one
contiguous block per layer from l * memory_k_->nb[3]. Apply the same per-layer
layout and recompute expected in src/s2_model.cpp lines 704-735 within
restore_kv_state; reject n_past values below zero or above max_seq_len_ before
any memcpy.
- Around line 1288-1320: Update SlowARModel::allocate_and_load_weights and the
shared tensor-upload path used by restore_encoder_weights,
restore_decoder_weights, and ensure_weights_loaded to validate that
gguf_data_offset_ + tensor offset + ggml_nbytes(t) stays within
mapped_gguf_.size() before calling ggml_backend_tensor_set, failing the load
when the range is invalid. Initialize the local output variables b and m to zero
before allocation.
- Around line 1254-1272: Change SlowARModel::acquire_compute_resources to return
a success boolean, validate the result of each ggml_backend_sched_new call, and
report failure when sched_ or fast_sched_ cannot be created. Update
allocate_and_load_weights to propagate that boolean and return false instead of
allowing execution with a null scheduler; preserve successful resource
initialization behavior.

In `@src/s2_pipeline.cpp`:
- Around line 1596-1622: Update the streaming cleanup block around
enable_vram_swap and the hot-swap offload thread to skip VRAM/RAM release when
params.more_segments_pending is true. Preserve cleanup for the final segment,
including synchronous releases and background offload behavior, so multi-segment
requests retain weights and mapped pages between segments.
- Around line 835-922: The vram_phase1_thread and kv_init_thread concurrently
mutate the same SlowARModel state, creating an unsafe race. In the generation
flow around vram_phase1_thread and the KV-cache initialization, ensure one
thread is joined or otherwise completed before starting the other; preserve the
existing failure checks while making
acquire_compute_resources/restore_weights_to_gpu and
init_kv_cache/reset_kv_cache execute sequentially.
- Around line 980-994: Move the asynchronous save_kv_state operation in the
prefill cache save flow below generate() completes, before prefill_cache_.valid
is set true, so KV backup serialization cannot overlap GPU computation. Update
the logic around save_n_past, kv_save_thread, and model().save_kv_state while
preserving the keep_kv_on_gpu path and existing cache status logging.
- Around line 885-947: Invalidate prefill_cache_ before every destructive KV
operation, including clear_kv_cache(), reset_kv_cache(), streaming-synthesis KV
clearing, and overwriting an existing valid entry; clear both valid and
vram_resident state. Also mark the cache invalid whenever prefill_fast or
eval_cached fails, so later requests cannot reuse stale KV data.
- Around line 305-322: Update compute_prefill_cache_key to make the cache key
cover the full prompt identity. The current implementation hashes only the first
16 frames of codebook-0 from ref_codes and omits T_prompt, causing different
prompts with identical first 16 codebook-0 codes to collide. Add num_codebooks
as a function parameter (either by passing it to the static method or converting
compute_prefill_cache_key to non-static), then update the loop that currently
iterates for only the first 16 entries to iterate through all codebooks and all
T_prompt frames instead. Include T_prompt in the key by appending it before or
after the code iteration to ensure the full prompt dimensionality is captured in
the cache lookup.
- Around line 440-483: The codec_prefers_gpu_ flag at line 473 is being set from
use_gpu_codec, which records the requested GPU placement, not whether the codec
actually ended up on GPU. When GPU codec loading fails at line 421 and falls
back to CPU at line 437, use_gpu_codec remains true but the codec resides on
CPU, causing codec_prefers_gpu_ to incorrectly reflect GPU placement and
misleading downstream weight restoration calls and the VRAM state machine
banner. Introduce a separate bool variable to track the actual achieved codec
placement (whether GPU loading succeeded or fell back to CPU), initialize it
near codec_loaded around line 363, update it based on the success or failure of
GPU vs CPU codec loading paths, and then use this achieved-placement variable
instead of use_gpu_codec when setting codec_prefers_gpu_ at line 473.
- Around line 1361-1382: Handle the boolean results from
model().restore_weights_to_gpu() and codec().restore_decoder_weights() in the
VRAM restoration block before streaming generation. If either restore fails,
stop the streaming path and propagate a descriptive error containing the restore
failure reason instead of continuing to generate or decode with incomplete GPU
state; preserve the existing successful restore flow.

---

Outside diff comments:
In `@src/s2_codec.cpp`:
- Around line 202-225: Update reset_codec_impl to release impl.backend_cpu with
ggml_backend_free and clear it before resetting the Impl object, matching the
existing backend cleanup. If backend_cpu has no consumers beyond its creation in
load_shared, remove the unused field and its initialization instead.

In `@src/s2_generate.cpp`:
- Around line 192-208: Update the verbose logging in the generation function
around initial_state and prefill_ms so it does not label every supplied state as
“prefill cached.” Report only that an initial state was supplied, and preserve
the measured prefill value without inferring whether it came from a cache; keep
the [Generate] and [Metrics] lines consistent.

---

Nitpick comments:
In `@include/s2_model.h`:
- Around line 113-115: Update set_n_past in the model state API to validate the
supplied value against max_seq_len_ before assigning n_past_. Reject values
outside the valid range, including oversized cached prefill state, while
preserving assignment for valid values and making the rejection explicit to
callers.

In `@src/s2_codec.cpp`:
- Line 966: Update the model_weights initialization near the weight-processing
logic to use a function-local empty std::unordered_set as the fallback, then
bind the reference through branches or equivalent reference-preserving logic so
Model->weight_tensor_set() is not copied. Keep the existing behavior for both
present and absent Model cases.

In `@src/s2_generate.cpp`:
- Around line 38-62: Add a defensive size check for caller-supplied state.logits
in the initial_state branch before the later vocab_size-indexed access. Ensure
the logits collection contains at least vocab_size entries; otherwise handle the
invalid cached state through the existing failure path without indexing it. Keep
the normal prefill path and valid cached-state behavior unchanged.

In `@src/s2_pipeline.cpp`:
- Around line 1105-1114: Update the on_frame callback in the gen_params setup to
assign frames_available while holding decode_mtx, alongside the accum updates,
before calling decode_cv.notify_one(). Preserve the existing atomic value and
notification behavior, and do not move unrelated callback logic.
- Around line 1163-1178: Extract the duplicated hot-swap VRAM/RAM release logic
from the three call sites in synthesize_streaming_prompt_codes_locked into a
private Pipeline helper named spawn_hot_swap_offload_locked(). Have the helper
join any existing pending_offload_thread_ before releasing compute resources and
spawning the background offload thread, then replace each inline lambda and
move-assignment at the three sites with calls to the helper.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d9d3df6a-f592-4fa2-8542-778904f10322

📥 Commits

Reviewing files that changed from the base of the PR and between 2c33261 and 0dc1aee.

📒 Files selected for processing (13)
  • CMakeLists.txt
  • include/s2_codec.h
  • include/s2_generate.h
  • include/s2_mapped_file.h
  • include/s2_model.h
  • include/s2_pipeline.h
  • src/main.cpp
  • src/s2_codec.cpp
  • src/s2_generate.cpp
  • src/s2_mapped_file.cpp
  • src/s2_model.cpp
  • src/s2_pipeline.cpp
  • src/s2_server.cpp

Comment thread src/s2_codec.cpp
Comment on lines +138 to +183
static bool allocate_codec_buffers(ggml_backend_t backend,
const std::vector<ggml_tensor *> & tensors,
ggml_backend_buffer_t & out_buffer,
size_t & total_bytes,
std::string & error_message) {
out_buffer = nullptr;
total_bytes = 0;
error_message.clear();
if (backend == nullptr || tensors.empty()) return true;

const ggml_backend_buffer_type_t buft = ggml_backend_get_default_buffer_type(backend);
const size_t alignment = ggml_backend_buft_get_alignment(buft);

for (ggml_tensor * tensor : tensors) {
const size_t alloc_size = ggml_backend_buft_get_alloc_size(buft, tensor);
const size_t rem = total_bytes % alignment;
if (rem != 0) total_bytes += (alignment - rem);
total_bytes += alloc_size;
}

if (total_bytes == 0) return true;

out_buffer = ggml_backend_buft_alloc_buffer(buft, total_bytes);
if (!out_buffer) {
error_message = "failed to allocate codec buffer of size " + std::to_string(total_bytes);
return false;
}

ggml_backend_buffer_set_usage(out_buffer, GGML_BACKEND_BUFFER_USAGE_WEIGHTS);

char * base_ptr = static_cast<char *>(ggml_backend_buffer_get_base(out_buffer));
size_t current_offset = 0;

for (ggml_tensor * tensor : tensors) {
const size_t alloc_size = ggml_backend_buft_get_alloc_size(buft, tensor);
const size_t rem = current_offset % alignment;
if (rem != 0) current_offset += (alignment - rem);

tensor->data = base_ptr + current_offset;
tensor->buffer = out_buffer;

current_offset += alloc_size;
}

return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare the two weight-buffer allocators and check ggml's max-size API usage.
set -uo pipefail

rg -n -C 3 'ggml_backend_buft_get_max_size' 
echo '--- codec allocator ---'
ast-grep run --pattern 'static bool allocate_codec_buffers($$$) { $$$ }' --lang cpp src/s2_codec.cpp

Repository: rodrigomatta/s2.cpp

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "tracked files with s2:"
git ls-files | rg '(^|/)s2_(codec|model)\.(cpp|h)$|ggml.*backend' | sed -n '1,120p'

echo
echo "search codec/weight allocators:"
rg -n -C 4 'allocate_codec_buffers|allocate_weight_buffers|ggml_backend_buft_get_max_size|restore_encoder_weights|restore_decoder_weights' . --glob '!build/**' --glob '!dist/**' || true

echo
echo "s2 codec outline:"
ast-grep outline src/s2_codec.cpp 2>/dev/null | sed -n '1,200p' || true

echo
echo "s2 codec lines 1-230:"
sed -n '1,230p' src/s2_codec.cpp

echo
echo "s2 model lines 1-230:"
sed -n '1,230p' src/s2_model.cpp

Repository: rodrigomatta/s2.cpp

Length of output: 28386


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "tracked files with s2:"
git ls-files | rg '(^|/)s2_(codec|model)\.(cpp|h)$|ggml.*backend' | sed -n '1,120p'

echo
 echo "search codec/weight allocators:"
rg -n -C 4 'allocate_codec_buffers|allocate_weight_buffers|ggml_backend_buft_get_max_size|restore_encoder_weights|restore_decoder_weights' . --glob '!build/**' --glob '!dist/**' || true

echo
echo "s2_codec.cpp lines 100-210:"
sed -n '100,210p' src/s2_codec.cpp

echo
echo "s2_model.cpp lines 90-200:"
sed -n '90,200p' src/s2_model.cpp

Repository: rodrigomatta/s2.cpp

Length of output: 16850


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "codec restore/free/load restore paths:"
sed -n '1530,1760p' src/s2_codec.cpp

echo
echo "Codec impl buffer member usage:"
rg -n -C 2 'impl_->(encoder_buf|decoder_buf|model_buf)|encoder_buf|decoder_buf|model_buf|weights_allocated_|allocate_codec_buffers' src/s2_codec.cpp include/s2_codec.h

echo
echo "Static verifier: allocate_codec_buffers does not respect max_size or reset failed tensors"
python3 - <<'PY'
from pathlib import Path
p = Path('src/s2_codec.cpp')
src = p.read_text()
start = src.index('static bool allocate_codec_buffers')
end = src.index('static void reset_decode_cache', start)
body = src[start:end]
checks = {
    'clears_out_buffer_at_start': 'out_buffer = nullptr;' in body[:body.index('const ggml_backend_buffer_type_t buft')],
    'allocates_single_buffer': 'ggml_backend_buft_alloc_buffer(buft, total_bytes)' in body and body.count('ggml_backend_buft_alloc_buffer') == 1,
    'gets_max_size': 'ggml_backend_buft_get_max_size' in body,
    'chunks': 'BufferChunk' in body or 'ggml_tallocr_new' in body or '<vector>' in body and 'chunks' in body,
    'clears_tensor_data_on_failure': 'tensor->data   = nullptr;' in body or 'tensor->data = nullptr;' in body,
    'clears_tensor_buffer_on_failure': 'tensor->buffer = nullptr;' in body,
    'frees_out_buffer_on_exit_after_failure': 'ggml_backend_buffer_free' in body,
}
for k,v in checks.items():
    print(f'{k}: {v}')
PY

Repository: rodrigomatta/s2.cpp

Length of output: 19124


Handle per-backend buffer limits in allocate_codec_buffers.

allocate_weight_buffers splits tensors by ggml_backend_buft_get_max_size(buft), but this allocator requests total_bytes in one ggml_backend_buft_alloc_buffer call. Backends with allocation limits can fail at Restore, returning "failed to allocate codec buffer of size ...". Use the same chunking/tallocr approach, or change the storage shape accordingly.

Also reset tensor->data/tensor->buffer on allocation or tensor-placement failure; the model allocator does this before allocation and clears tensors on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_codec.cpp` around lines 138 - 183, The allocate_codec_buffers function
currently attempts to allocate all tensors in a single
ggml_backend_buft_alloc_buffer call, which can fail on backends with allocation
size limits. Follow the same chunking approach as allocate_weight_buffers by
checking ggml_backend_buft_get_max_size(buft) and splitting tensor allocation
into multiple buffers when total_bytes exceeds the backend limit. Additionally,
ensure tensor->data and tensor->buffer are reset to initial values (likely
nullptr) before attempting allocation and when allocation or tensor placement
fails, matching the model allocator's cleanup behavior.

Comment thread src/s2_codec.cpp
Comment on lines +1677 to +1715
bool AudioCodec::refresh_host_caches_from_mmap() {
if (!impl_ || !impl_->mapped_gguf_.is_open()) return false;
auto read_f32 = [&](const std::string& name) -> std::vector<float> {
ggml_tensor* t = ggml_get_tensor(impl_->ctx_w, name.c_str());
if (!t) throw std::runtime_error("missing vq tensor: " + name);
auto it = impl_->tensor_offsets.find(t);
if (it == impl_->tensor_offsets.end()) throw std::runtime_error("missing offset");
const size_t n = ggml_nelements(t);
std::vector<float> out(n);
const uint8_t* src = impl_->mapped_gguf_.data() + impl_->gguf_data_offset + it->second;
if (t->type == GGML_TYPE_F32) std::memcpy(out.data(), src, n * sizeof(float));
else if (t->type == GGML_TYPE_F16) {
const ggml_fp16_t* tmp = reinterpret_cast<const ggml_fp16_t*>(src);
for (size_t i = 0; i < n; ++i) out[i] = ggml_fp16_to_fp32(tmp[i]);
}
return out;
};
try {
auto load_vq = [&](const std::string& prefix, int32_t in_dim, int32_t cb_dim, int32_t cb_size) -> vq_cache {
vq_cache vq; vq.input_dim = in_dim; vq.codebook_dim = cb_dim; vq.codebook_size = cb_size;
vq.in_proj_weight = read_f32(prefix + ".in_proj.weight"); vq.in_proj_bias = read_f32(prefix + ".in_proj.bias");
vq.out_proj_weight = read_f32(prefix + ".out_proj.weight"); vq.out_proj_bias = read_f32(prefix + ".out_proj.bias");
vq.codebook = read_f32(prefix + ".codebook.weight");
vq.codebook_norm.resize(vq.codebook.size());
for (int32_t c = 0; c < cb_size; ++c) {
float norm = 0.0f; const size_t base = c * cb_dim;
for (int32_t d = 0; d < cb_dim; ++d) norm += vq.codebook[base+d] * vq.codebook[base+d];
norm = std::sqrt(std::max(norm, 1e-12f));
for (int32_t d = 0; d < cb_dim; ++d) vq.codebook_norm[base+d] = vq.codebook[base+d] / norm;
}
return vq;
};
impl_->semantic_vq = load_vq(impl_->tprefix + "quantizer.semantic_quantizer.quantizers.0", impl_->quantizer_input_dim, impl_->quantizer_codebook_dim, impl_->quantizer_semantic_codebook_size);
impl_->residual_vq.clear(); impl_->residual_vq.reserve(impl_->quantizer_residual_codebooks);
for (int32_t i = 0; i < impl_->quantizer_residual_codebooks; ++i)
impl_->residual_vq.push_back(load_vq(impl_->tprefix + "quantizer.quantizer.quantizers." + std::to_string(i), impl_->quantizer_input_dim, impl_->quantizer_codebook_dim, impl_->quantizer_residual_codebook_size));
} catch (const std::exception& e) { std::cerr << "[Codec] VQ mmap load failed: " << e.what() << std::endl; return false; }
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

read_f32 returns zeros for unsupported tensor types.

The lambda handles GGML_TYPE_F32 and GGML_TYPE_F16. For any other type it returns a zero-filled vector and reports success. load_vq then builds a codebook of zeros, codebook_norm divides by the 1e-12f floor, and quantize_with_vq selects code 0 for every frame. The failure is silent and produces wrong audio codes. Throw instead, as the previous tensor_to_f32 helper at lines 599-613 does.

The loop at lines 1701-1706 also assumes vq.codebook.size() >= cb_size * cb_dim. Validate the element count before the loop, because a metadata/tensor mismatch causes an out-of-bounds read.

🐛 Proposed fix to fail on unsupported types
         if (t->type == GGML_TYPE_F32) std::memcpy(out.data(), src, n * sizeof(float));
         else if (t->type == GGML_TYPE_F16) {
             const ggml_fp16_t* tmp = reinterpret_cast<const ggml_fp16_t*>(src);
             for (size_t i = 0; i < n; ++i) out[i] = ggml_fp16_to_fp32(tmp[i]);
         }
+        else throw std::runtime_error("unsupported vq tensor type for " + name + ": " +
+                                      std::string(ggml_type_name(t->type)));
         return out;
-            vq.codebook = read_f32(prefix + ".codebook.weight");
+            vq.codebook = read_f32(prefix + ".codebook.weight");
+            if (vq.codebook.size() < static_cast<size_t>(cb_size) * cb_dim) {
+                throw std::runtime_error("codebook too small for " + prefix);
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bool AudioCodec::refresh_host_caches_from_mmap() {
if (!impl_ || !impl_->mapped_gguf_.is_open()) return false;
auto read_f32 = [&](const std::string& name) -> std::vector<float> {
ggml_tensor* t = ggml_get_tensor(impl_->ctx_w, name.c_str());
if (!t) throw std::runtime_error("missing vq tensor: " + name);
auto it = impl_->tensor_offsets.find(t);
if (it == impl_->tensor_offsets.end()) throw std::runtime_error("missing offset");
const size_t n = ggml_nelements(t);
std::vector<float> out(n);
const uint8_t* src = impl_->mapped_gguf_.data() + impl_->gguf_data_offset + it->second;
if (t->type == GGML_TYPE_F32) std::memcpy(out.data(), src, n * sizeof(float));
else if (t->type == GGML_TYPE_F16) {
const ggml_fp16_t* tmp = reinterpret_cast<const ggml_fp16_t*>(src);
for (size_t i = 0; i < n; ++i) out[i] = ggml_fp16_to_fp32(tmp[i]);
}
return out;
};
try {
auto load_vq = [&](const std::string& prefix, int32_t in_dim, int32_t cb_dim, int32_t cb_size) -> vq_cache {
vq_cache vq; vq.input_dim = in_dim; vq.codebook_dim = cb_dim; vq.codebook_size = cb_size;
vq.in_proj_weight = read_f32(prefix + ".in_proj.weight"); vq.in_proj_bias = read_f32(prefix + ".in_proj.bias");
vq.out_proj_weight = read_f32(prefix + ".out_proj.weight"); vq.out_proj_bias = read_f32(prefix + ".out_proj.bias");
vq.codebook = read_f32(prefix + ".codebook.weight");
vq.codebook_norm.resize(vq.codebook.size());
for (int32_t c = 0; c < cb_size; ++c) {
float norm = 0.0f; const size_t base = c * cb_dim;
for (int32_t d = 0; d < cb_dim; ++d) norm += vq.codebook[base+d] * vq.codebook[base+d];
norm = std::sqrt(std::max(norm, 1e-12f));
for (int32_t d = 0; d < cb_dim; ++d) vq.codebook_norm[base+d] = vq.codebook[base+d] / norm;
}
return vq;
};
impl_->semantic_vq = load_vq(impl_->tprefix + "quantizer.semantic_quantizer.quantizers.0", impl_->quantizer_input_dim, impl_->quantizer_codebook_dim, impl_->quantizer_semantic_codebook_size);
impl_->residual_vq.clear(); impl_->residual_vq.reserve(impl_->quantizer_residual_codebooks);
for (int32_t i = 0; i < impl_->quantizer_residual_codebooks; ++i)
impl_->residual_vq.push_back(load_vq(impl_->tprefix + "quantizer.quantizer.quantizers." + std::to_string(i), impl_->quantizer_input_dim, impl_->quantizer_codebook_dim, impl_->quantizer_residual_codebook_size));
} catch (const std::exception& e) { std::cerr << "[Codec] VQ mmap load failed: " << e.what() << std::endl; return false; }
return true;
}
bool AudioCodec::refresh_host_caches_from_mmap() {
if (!impl_ || !impl_->mapped_gguf_.is_open()) return false;
auto read_f32 = [&](const std::string& name) -> std::vector<float> {
ggml_tensor* t = ggml_get_tensor(impl_->ctx_w, name.c_str());
if (!t) throw std::runtime_error("missing vq tensor: " + name);
auto it = impl_->tensor_offsets.find(t);
if (it == impl_->tensor_offsets.end()) throw std::runtime_error("missing offset");
const size_t n = ggml_nelements(t);
std::vector<float> out(n);
const uint8_t* src = impl_->mapped_gguf_.data() + impl_->gguf_data_offset + it->second;
if (t->type == GGML_TYPE_F32) std::memcpy(out.data(), src, n * sizeof(float));
else if (t->type == GGML_TYPE_F16) {
const ggml_fp16_t* tmp = reinterpret_cast<const ggml_fp16_t*>(src);
for (size_t i = 0; i < n; ++i) out[i] = ggml_fp16_to_fp32(tmp[i]);
}
else throw std::runtime_error("unsupported vq tensor type for " + name + ": " +
std::string(ggml_type_name(t->type)));
return out;
};
try {
auto load_vq = [&](const std::string& prefix, int32_t in_dim, int32_t cb_dim, int32_t cb_size) -> vq_cache {
vq_cache vq; vq.input_dim = in_dim; vq.codebook_dim = cb_dim; vq.codebook_size = cb_size;
vq.in_proj_weight = read_f32(prefix + ".in_proj.weight"); vq.in_proj_bias = read_f32(prefix + ".in_proj.bias");
vq.out_proj_weight = read_f32(prefix + ".out_proj.weight"); vq.out_proj_bias = read_f32(prefix + ".out_proj.bias");
vq.codebook = read_f32(prefix + ".codebook.weight");
if (vq.codebook.size() < static_cast<size_t>(cb_size) * cb_dim) {
throw std::runtime_error("codebook too small for " + prefix);
}
vq.codebook_norm.resize(vq.codebook.size());
for (int32_t c = 0; c < cb_size; ++c) {
float norm = 0.0f; const size_t base = c * cb_dim;
for (int32_t d = 0; d < cb_dim; ++d) norm += vq.codebook[base+d] * vq.codebook[base+d];
norm = std::sqrt(std::max(norm, 1e-12f));
for (int32_t d = 0; d < cb_dim; ++d) vq.codebook_norm[base+d] = vq.codebook[base+d] / norm;
}
return vq;
};
impl_->semantic_vq = load_vq(impl_->tprefix + "quantizer.semantic_quantizer.quantizers.0", impl_->quantizer_input_dim, impl_->quantizer_codebook_dim, impl_->quantizer_semantic_codebook_size);
impl_->residual_vq.clear(); impl_->residual_vq.reserve(impl_->quantizer_residual_codebooks);
for (int32_t i = 0; i < impl_->quantizer_residual_codebooks; ++i)
impl_->residual_vq.push_back(load_vq(impl_->tprefix + "quantizer.quantizer.quantizers." + std::to_string(i), impl_->quantizer_input_dim, impl_->quantizer_codebook_dim, impl_->quantizer_residual_codebook_size));
} catch (const std::exception& e) { std::cerr << "[Codec] VQ mmap load failed: " << e.what() << std::endl; return false; }
return true;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_codec.cpp` around lines 1677 - 1715, Update read_f32 in
refresh_host_caches_from_mmap to throw for tensor types other than GGML_TYPE_F32
and GGML_TYPE_F16, matching tensor_to_f32 behavior, instead of returning
zero-filled data. In load_vq, validate that the loaded codebook contains at
least cb_size * cb_dim elements before computing codebook_norm, and throw on
mismatch to prevent out-of-bounds access.

Comment thread src/s2_codec.cpp
Comment on lines +1717 to +1740
bool AudioCodec::ensure_weights_loaded() {
if (!impl_ || impl_->weights_allocated_) return true;
if (impl_->decoder_on_gpu || impl_->encoder_on_gpu) {
impl_->weights_allocated_ = true;
return true;
}
if (!impl_->mapped_gguf_.is_open()) return false;
S2_LOG_INFO_STREAM("[Codec] >>> Allocating and loading Audio Codec weights on demand..." << std::endl);
size_t b = 0; std::string e;
if (!allocate_codec_buffers(impl_->backend, impl_->all_codec_weights, impl_->model_buf, b, e)) {
std::cerr << "[Codec] Alloc failed: " << e << std::endl; return false;
}
const uint8_t* base = impl_->mapped_gguf_.data();
for (ggml_tensor * t : impl_->all_codec_weights) {
auto it = impl_->tensor_offsets.find(t);
if (it != impl_->tensor_offsets.end())
ggml_backend_tensor_set(t, base + impl_->gguf_data_offset + it->second, 0, ggml_nbytes(t));
}
impl_->weights_allocated_ = true;
impl_->weights_on_gpu = !ggml_backend_is_cpu(impl_->backend);
impl_->encoder_on_gpu = impl_->weights_on_gpu;
impl_->decoder_on_gpu = impl_->weights_on_gpu;
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

ensure_weights_loaded reports success while encoder or decoder tensors are detached.

The whole-model path and the partition paths use different buffers, but the residency flags do not record which buffer owns a tensor. This sequence produces null weight data:

  1. ensure_weights_loaded allocates model_buf for all_codec_weights and sets encoder_on_gpu = decoder_on_gpu = true (lines 1736-1738).
  2. free_encoder_weights (lines 1555-1573) frees encoder_buf, which is still nullptr in this state, sets every encoder tensor to data = nullptr, and clears weights_allocated_. model_buf stays allocated, so no VRAM is released.
  3. The next encode() calls ensure_weights_loaded. decoder_on_gpu is still true, so the function sets weights_allocated_ = true and returns true.
  4. The encoder graph then runs with encoder weights whose data is nullptr.

free_decoder_weights produces the mirror failure for decode(). restore_weights_to_gpu (lines 1543-1553) has the same gap: it clears weights_allocated_ and calls ensure_weights_loaded, which returns early when either partition flag is set, and it overwrites a non-null model_buf without freeing it.

Restore the missing partition instead of returning early, and make free_encoder_weights/free_decoder_weights release model_buf ownership before they detach tensors.

🐛 Proposed fix for the early-return path
 bool AudioCodec::ensure_weights_loaded() {
     if (!impl_ || impl_->weights_allocated_) return true;
-    if (impl_->decoder_on_gpu || impl_->encoder_on_gpu) {
-        impl_->weights_allocated_ = true;
-        return true;
-    }
+    if (impl_->encoder_on_gpu || impl_->decoder_on_gpu) {
+        if (!impl_->encoder_on_gpu && !restore_encoder_weights()) return false;
+        if (!impl_->decoder_on_gpu && !restore_decoder_weights()) return false;
+        impl_->weights_allocated_ = true;
+        return true;
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_codec.cpp` around lines 1717 - 1740, Fix ownership tracking across
ensure_weights_loaded, restore_weights_to_gpu, free_encoder_weights, and
free_decoder_weights. Do not treat encoder_on_gpu or decoder_on_gpu alone as
proof that all_codec_weights remain attached: restore or reload the missing
partition after detachment, free and clear model_buf before switching to
partition buffers, and ensure each free_* method releases model_buf ownership
when it is the active backing allocation before clearing tensor data.

Comment thread src/s2_mapped_file.cpp
Comment on lines +41 to +58
size_ = static_cast<size_t>(file_size.QuadPart);

if (size_ == 0) {
CloseHandle(fh);
return true;
}

HANDLE mh = CreateFileMappingW(fh, nullptr, PAGE_READONLY, 0, 0, nullptr);
if (!mh) {
CloseHandle(fh);
return false;
}

void* addr = MapViewOfFile(mh, FILE_MAP_READ, 0, 0, size_);
if (!addr) {
CloseHandle(mh);
CloseHandle(fh);
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep MappedFile closed when no view exists.

size_ is assigned before mapping. Empty files return true after the descriptor closes, while is_open() remains false. Mapping failures return false while size() retains the failed file length.

Store the length in a local variable. Assign size_ only after MapViewOfFile or mmap succeeds. Reject zero-length input unless an explicit open-state field supports it.

Also applies to: 76-87

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_mapped_file.cpp` around lines 41 - 58, Update MappedFile’s
open/mapping flow to keep the object closed when no mapped view exists: store
the file length in a local variable, reject zero-length files unless an explicit
open-state mechanism supports them, and assign size_ only after MapViewOfFile or
mmap succeeds. Apply the same ordering and failure-state behavior to the
corresponding path around the later mapping logic.

Comment thread src/s2_mapped_file.cpp
Comment on lines +190 to +198
#elif defined(_WIN32)
(void)data_;
(void)size_;
SetProcessWorkingSetSizeEx(
GetCurrentProcess(),
static_cast<SIZE_T>(-1),
static_cast<SIZE_T>(-1),
QUOTA_LIMITS_HARDWS_MIN_DISABLE);
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Trace callers. Expect: process-wide trimming is not required by a per-file cache-release path.
rg -n -C 4 \
  -g '*.cpp' -g '*.h' -g '*.hpp' \
  '\bdrop_page_cache\s*\(|SetProcessWorkingSetSizeEx' .

Repository: rodrigomatta/s2.cpp

Length of output: 4475


🌐 Web query:

Microsoft SetProcessWorkingSetSizeEx -1 minimum negative infinity maximum RemoveWorkingSetEx

💡 Result:

The function SetProcessWorkingSetSizeEx does not use "negative infinity" as an input parameter. Instead, Microsoft documentation specifies that setting both the dwMinimumWorkingSetSize and dwMaximumWorkingSetSize parameters to (SIZE_T)–1 is the mechanism used to instruct the system to remove as many pages as possible from the specified process's working set [1][2]. This operation effectively empties the working set, achieving the same result as calling the EmptyWorkingSet function [3]. There is no function named RemoveWorkingSetEx in the Windows API. It is likely that this is a misremembered reference to the functionality provided by SetProcessWorkingSetSizeEx or EmptyWorkingSet [4][3]. In summary: - To minimize a process's working set: Use SetProcessWorkingSetSizeEx (or SetProcessWorkingSetSize) with both size parameters set to (SIZE_T)–1 [1][2]. - Functionality: This forces the memory manager to trim as many pages as possible from the process [1][3]. - Terminology: The value (SIZE_T)–1 is a cast of the constant -1 to an unsigned size type (resulting in the maximum possible value for a SIZE_T), which is the documented sentinel value for this operation [1].

Citations:


Do not trim the whole process in MappedFile::drop_page_cache().

drop_page_cache() is called on individual model/codec mappings during hot-swap, but the Windows branch trims pages from the entire process. Use -1, -1 only if the application intentionally has an application-wide eviction policy, or make this branch a no-op.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_mapped_file.cpp` around lines 190 - 198, Update the Windows branch of
MappedFile::drop_page_cache() so it does not call SetProcessWorkingSetSizeEx
with -1, -1 and trim the entire process working set. Make this per-mapping
cache-drop operation a no-op on Windows, unless an existing application-wide
eviction policy explicitly requires the process-wide call.

Comment thread src/s2_pipeline.cpp
Comment on lines +835 to 922
std::thread vram_phase1_thread;
bool vram_phase1_ok = true;

if (params.enable_vram_swap) {
vram_phase1_thread = std::thread([this, &params, &vram_phase1_ok]() {
if (model_prefers_gpu_ && !model().is_weights_on_gpu()) {
safe_print_ln("[Pipeline] Restoring Slow-AR to VRAM for generation...");
model().acquire_compute_resources();
if (!model().restore_weights_to_gpu()) {
safe_print_error_ln("Pipeline error: Slow-AR weight restore failed.");
vram_phase1_ok = false;
return;
}
safe_print_ln("[VRAM Diag] Post-SlowAR restore: Slow-AR=" + std::to_string(model().get_gpu_memory_usage_bytes() / 1024 / 1024) + " MB, Codec=" + std::to_string(codec().get_gpu_memory_usage_bytes() / 1024 / 1024) + " MB");
}

if (!model_prefers_gpu_ && codec_prefers_gpu_ && !codec().is_decoder_on_gpu()) {
safe_print_ln("[Pipeline] Pre-loading Audio Codec decoder to VRAM (hiding behind CPU gen)...");
codec().restore_decoder_weights();
safe_print_ln("[VRAM Diag] Post-Decoder restore: Slow-AR=" + std::to_string(model().get_gpu_memory_usage_bytes() / 1024 / 1024) + " MB, Codec=" + std::to_string(codec().get_gpu_memory_usage_bytes() / 1024 / 1024) + " MB");
}

if (model_prefers_gpu_ && codec_prefers_gpu_) {
if (codec().is_encoder_on_gpu()) {
safe_print_ln("[Pipeline] Freeing codec encoder from VRAM (not needed during generation)...");
codec().free_encoder_weights();
}
if (codec().is_decoder_on_gpu()) {
safe_print_ln("[Pipeline] Freeing codec decoder from VRAM (not needed during generation)...");
codec().free_decoder_weights();
}
if (codec().is_weights_on_gpu()) {
safe_print_ln("[Pipeline] Freeing Audio Codec from VRAM for Slow-AR generation...");
codec().free_gpu_weights();
}
safe_print_ln("[VRAM Diag] Post-Codec free: Slow-AR=" + std::to_string(model().get_gpu_memory_usage_bytes() / 1024 / 1024) + " MB, Codec=" + std::to_string(codec().get_gpu_memory_usage_bytes() / 1024 / 1024) + " MB");
}

safe_print_ln("[VRAM Diag] End-Phase1: Slow-AR=" +
std::to_string(model().get_gpu_memory_usage_bytes() / 1024 / 1024) + " MB, Codec=" +
std::to_string(codec().get_gpu_memory_usage_bytes() / 1024 / 1024) + " MB");
});
}

const int32_t num_codebooks = model().hparams().num_codebooks;
PromptTensor prompt = build_prompt(
tokenizer(), params.text, params.prompt_text,
ref_codes,
num_codebooks, T_prompt);

ref_codes, num_codebooks, T_prompt);
int32_t max_seq_len = prompt.cols + params.gen.max_new_tokens;

const bool kv_reuse = params.enable_kv_reuse && params.is_persistent;
const bool keep_kv_on_gpu = kv_reuse && params.kv_cache_vram;

const bool need_fresh_kv = !kv_reuse ||
model().kv_max_seq_len() < max_seq_len ||
model().kv_max_seq_len() == 0;

if (need_fresh_kv) {
model().clear_kv_cache();
}

const auto kv_t0 = std::chrono::steady_clock::now();
if (!model().init_kv_cache(max_seq_len)) {
std::thread kv_init_thread;
bool kv_init_ok = true;

if (need_fresh_kv) {
kv_init_thread = std::thread([&]() {
kv_init_ok = model().init_kv_cache(max_seq_len);
});
} else {
kv_init_thread = std::thread([&]() {
model().reset_kv_cache();
});
}

if (vram_phase1_thread.joinable()) {
vram_phase1_thread.join();
}
if (!vram_phase1_ok) {
kv_init_thread.join();
return false;
}

kv_init_thread.join();
if (!kv_init_ok) {
safe_print_error_ln("Pipeline error: init_kv_cache failed.");
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect SlowARModel KV/weight lifecycle for shared backend state and any locking.
set -euo pipefail

fd -t f 's2_model\.(h|cpp)$' src include

ast-grep outline src/s2_model.cpp --items all

for fn in init_kv_cache reset_kv_cache restore_weights_to_gpu acquire_compute_resources free_compute_buffers save_kv_state restore_kv_state set_n_past n_past kv_max_seq_len; do
  echo "===== $fn ====="
  rg -nP -C 12 "SlowARModel::${fn}\s*\(" src/s2_model.cpp || true
done

echo "===== locking primitives in model =====";
rg -nP '\b(std::mutex|lock_guard|unique_lock|atomic<|ggml_backend_synchronize)\b' src/s2_model.cpp include/s2_model.h || true

Repository: rodrigomatta/s2.cpp

Length of output: 9807


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== s2_model.h member declarations ====="
ast-grep outline include/s2_model.h --items all
for fn in SlowARModel::init_kv_cache SlowARModel::clear_kv_cache SlowARModel::restore_weights_to_gpu SlowARModel::free_gpu_weights SlowARModel::free_compute_buffers SlowARModel::acquire_compute_resources SlowARModel::get_gpu_memory_usage_bytes SlowARModel::clear_kv_cache SlowARModel::is_weights_on_gpu SlowARModel::kv_max_seq_len; do
  echo "===== $fn ====="
  rg -n -C 8 "$fn" include/s2_model.h src/s2_model.cpp || true
done

echo "===== allocate_and_load_weights full ====="
rg -n -C 20 "bool SlowARModel::allocate_and_load_weights" src/s2_model.cpp

echo "===== model backend initialization/backend_gpu members ====="
rg -n -C 8 "backend_gpu_|backend_cpu_|kv_buf_|memory_k_|memory_v_|original_gpu_weights_|weights_allocated_" src/s2_model.cpp include/s2_model.h

echo "===== codec gpu memory/read/write symbols in pipeline/codec files ====="
rg -n "restore_decoder_weights|free_decoder_weights|free_gpu_weights|is_decoder_on_gpu|get_gpu_memory_usage_bytes|get_decoder_memory_usage|is_encoder_on_gpu|free_encoder_weights|restore_encoder_weights|load .*backend.*gpu|allocate_and_load_weights" src include --glob '*.{cpp,h,ht' || true

echo "===== Pipeline surrounding lines ====="
sed -n '800,935p' src/s2_pipeline.cpp

Repository: rodrigomatta/s2.cpp

Length of output: 50375


Do not run model state changes across two threads.

When enable_vram_swap is true, vram_phase1_thread can call acquire_compute_resources() / restore_weights_to_gpu(), while kv_init_thread allocates or resets the Slow-AR KV cache on the same SlowARModel. Those paths share backend buffers and tensor states, and the model has no synchronization around them. Join or disable one thread before starting the other, or add locking/backend handles that guarantee both paths run on disjoint resources.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_pipeline.cpp` around lines 835 - 922, The vram_phase1_thread and
kv_init_thread concurrently mutate the same SlowARModel state, creating an
unsafe race. In the generation flow around vram_phase1_thread and the KV-cache
initialization, ensure one thread is joined or otherwise completed before
starting the other; preserve the existing failure checks while making
acquire_compute_resources/restore_weights_to_gpu and
init_kv_cache/reset_kv_cache execute sequentially.

Comment thread src/s2_pipeline.cpp
Comment on lines +885 to +947
const bool kv_reuse = params.enable_kv_reuse && params.is_persistent;
const bool keep_kv_on_gpu = kv_reuse && params.kv_cache_vram;

const bool need_fresh_kv = !kv_reuse ||
model().kv_max_seq_len() < max_seq_len ||
model().kv_max_seq_len() == 0;

if (need_fresh_kv) {
model().clear_kv_cache();
}

const auto kv_t0 = std::chrono::steady_clock::now();
if (!model().init_kv_cache(max_seq_len)) {
std::thread kv_init_thread;
bool kv_init_ok = true;

if (need_fresh_kv) {
kv_init_thread = std::thread([&]() {
kv_init_ok = model().init_kv_cache(max_seq_len);
});
} else {
kv_init_thread = std::thread([&]() {
model().reset_kv_cache();
});
}

if (vram_phase1_thread.joinable()) {
vram_phase1_thread.join();
}
if (!vram_phase1_ok) {
kv_init_thread.join();
return false;
}

kv_init_thread.join();
if (!kv_init_ok) {
safe_print_error_ln("Pipeline error: init_kv_cache failed.");
return false;
}

const auto kv_t1 = std::chrono::steady_clock::now();

const auto gen_t0 = std::chrono::steady_clock::now();
GenerateResult res = generate(model(), tokenizer().config(), prompt, params.gen);
const auto gen_t1 = std::chrono::steady_clock::now();
const std::string cache_key = compute_prefill_cache_key(params, ref_codes, T_prompt);
bool prefill_hit = false;
StepResult cached_state;

if (res.n_frames == 0) {
safe_print_error_ln("Pipeline error: generation produced no frames.");
return false;
if (kv_reuse && prefill_cache_.valid &&
prefill_cache_.cache_key == cache_key &&
prefill_cache_.max_seq_len >= max_seq_len)
{
if (keep_kv_on_gpu && prefill_cache_.vram_resident) {
model().set_n_past(prefill_cache_.n_past);
cached_state = prefill_cache_.state;
prefill_hit = true;
safe_print_ln("[Pipeline] Prefill cache HIT (VRAM-pinned, key=" + cache_key + ")");
} else if (!keep_kv_on_gpu && !prefill_cache_.k_data.empty()) {
if (model().restore_kv_state(prefill_cache_.k_data, prefill_cache_.v_data,
prefill_cache_.n_past)) {
cached_state = prefill_cache_.state;
prefill_hit = true;
safe_print_ln("[Pipeline] Prefill cache HIT (system RAM, key=" + cache_key + ")");
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Determine whether reset_kv_cache preserves KV contents and what clear_kv_cache frees.
set -euo pipefail

rg -nP -C 20 'SlowARModel::(reset_kv_cache|clear_kv_cache)\s*\(' src/s2_model.cpp || true
rg -nP -C 6 '\b(reset_kv_cache|clear_kv_cache|set_n_past)\s*\(' src include --type cpp || true

Repository: rodrigomatta/s2.cpp

Length of output: 12640


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pipeline relevant sections =="
sed -n '850,980p' src/s2_pipeline.cpp
sed -n '1228,1270p' src/s2_pipeline.cpp
sed -n '1370,1412p' src/s2_pipeline.cpp
sed -n '1512,1568p' src/s2_pipeline.cpp

echo "== prefill fast definitions/usages =="
rg -n -C 4 'prefill_fast|bool prefill_fast|prefill_fast\s*(' src include --type cpp

echo "== prefill_cache declarations/assignments =="
rg -n -C 3 'prefill_cache_|struct .*Prefill|Cache .*Prefill' src/s2_pipeline.cpp include --type hpp --type h --type cpp

Repository: rodrigomatta/s2.cpp

Length of output: 11248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== prefill_fast definitions/usages =="
rg -n -C 4 'prefill_fast|bool prefill_fast' src include --type cpp --type h --type hpp

echo "== prefill_cache declarations/assignments/structures =="
rg -n -C 3 'prefill_cache_|struct .*Prefill|Cache .*Prefill|valid|vram_resident' src/s2_pipeline.cpp include --type hpp --type h --type cpp

echo "== source around VRAM offload and KV-save paths =="
sed -n '940,1025p' src/s2_pipeline.cpp
sed -n '1208,1228p' src/s2_pipeline.cpp

Repository: rodrigomatta/s2.cpp

Length of output: 227


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 'prefill_fast|bool prefill_fast' src include

rg -n -C 3 'prefill_cache_|struct .*Prefill|Cache .*Prefill|valid|vram_resident' src/s2_pipeline.cpp include

sed -n '940,1025p' src/s2_pipeline.cpp
sed -n '1208,1228p' src/s2_pipeline.cpp

Repository: rodrigomatta/s2.cpp

Length of output: 12865


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== class declaration and relevant member/function outlines =="
sed -n '118,142p' include/s2_pipeline.h
ast-grep outline src/s2_pipeline.cpp --match Pipeline::synthesize --view expanded || true
ast-grep outline src/s2_pipeline.cpp --match Pipeline::synthesize_streaming_prompt_codes_locked --view expanded || true
ast-grep outline src/s2_model.cpp --match SlowARModel::prefill_fast --view expanded || true
ast-grep outline src/s2_model.cpp --match SlowARModel::reset_kv_cache --view expanded || true

echo "== streaming function body =="
sed -n '1288,1398p' src/s2_pipeline.cpp

Repository: rodrigomatta/s2.cpp

Length of output: 6548


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== full synthesize flow =="
sed -n '814,994p' src/s2_pipeline.cpp

echo "== full prefill miss cache save section =="
sed -n '968,998p' src/s2_pipeline.cpp

Repository: rodrigomatta/s2.cpp

Length of output: 9584


Invalidate prefill_cache_ before reusing or clearing the KV cache.

The VRAM-pinned hit only calls set_n_past() and trusts the KV buffer still contains that key. prefill_cache_.valid and prefill_cache_.vram_resident should be cleared before each destructive KV operation: before clear_kv_cache(), before reset_kv_cache(), before streaming synthesis clears the KV cache, and before saving a new cache entry over an old valid entry. Mark the entry invalid on prefill_fast/eval_cached failure too. Without this, a later matching request can read another request’s KV state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_pipeline.cpp` around lines 885 - 947, Invalidate prefill_cache_ before
every destructive KV operation, including clear_kv_cache(), reset_kv_cache(),
streaming-synthesis KV clearing, and overwriting an existing valid entry; clear
both valid and vram_resident state. Also mark the cache invalid whenever
prefill_fast or eval_cached fails, so later requests cannot reuse stale KV data.

Comment thread src/s2_pipeline.cpp
Comment on lines +980 to +994
if (keep_kv_on_gpu) {
prefill_cache_.vram_resident = true;
} else {
const int32_t save_n_past = prefill_cache_.n_past;
kv_save_thread = std::thread([this, save_n_past]() {
model().save_kv_state(prefill_cache_.k_data,
prefill_cache_.v_data,
save_n_past);
});
}
prefill_cache_.valid = true;
safe_print_ln("[Pipeline] Prefill cache SAVED (key=" + cache_key +
", n_past=" + std::to_string(prefill_cache_.n_past) +
(keep_kv_on_gpu ? ", VRAM)" : ", RAM, async)"));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how save_kv_state reads KV tensors and whether it synchronizes the backend.
set -euo pipefail

rg -nP -C 25 'SlowARModel::save_kv_state\s*\(' src/s2_model.cpp || true
rg -nP -C 25 'SlowARModel::restore_kv_state\s*\(' src/s2_model.cpp || true
rg -nP -C 4 'ggml_backend_(tensor_get|tensor_set|synchronize|graph_compute)' src/s2_model.cpp || true

Repository: rodrigomatta/s2.cpp

Length of output: 8793


🏁 Script executed:

#!/bin/bash
# Check call sites and synchronization around save_kv_state/generate(), plus any backend synchronization in the save path.
set -euo pipefail

fd -a 'src/s2_pipeline.cpp|src/s2_model.cpp' . | sed 's#^\./##'

echo '--- s2_pipeline cache save/generate sites ---'
rg -n -C 12 'kv_save_thread|prefill_cache_.valid|generate\(|save_kv_state\(' src/s2_pipeline.cpp

echo '--- s2_model schedule fields/constructors'
rg -n -C 8 'ggml_backend_sched_|backend_gpu_|sched_|kv_save|save_kv_state\(' src/s2_model.cpp

echo '--- broader backend tensor graph calls ---'
rg -n 'ggml_backend_(sched_)?(tensor_get|tensor_set|graph_compute|synchronize|scheduler)?' src || true

echo '--- git diff stat/name/status if available ---'
git diff --stat || true
git diff -- src/s2_pipeline.cpp src/s2_model.cpp | sed -n '1,220p' || true

Repository: rodrigomatta/s2.cpp

Length of output: 40137


🌐 Web query:

ggml_backend_tensor_get documentation concurrent tensor get during graph compute

💡 Result:

The function ggml_backend_tensor_get is a synchronous operation used to copy data from a backend-allocated tensor to host memory [1][2]. Concurrent use of ggml_backend_tensor_get while a graph compute operation is running is generally unsafe and not recommended [3]. Backends, particularly hardware-accelerated ones like CUDA, operate asynchronously from the host [3][4]. Tensor memory contents are only guaranteed to be in a well-defined state between complete graph evaluations [3]. To safely access tensor data: 1. Ensure the backend has finished its computations. You can do this by calling ggml_backend_synchronize(backend) after initiating a graph compute operation [2][5]. 2. Alternatively, use the synchronous wrapper ggml_backend_graph_compute, which implicitly handles synchronization after execution is complete [2]. 3. For developers needing to inspect data during generation, ensure you synchronize the backend before calling ggml_backend_tensor_get to avoid race conditions or accessing uninitialized/in-progress memory [3][2]. Some backends support asynchronous versions of data access (e.g., get_tensor_async) for advanced use cases [6], but these still require proper synchronization primitives to ensure the graph computation has reached a point where the target tensor is ready for read access [3][2]. Always check ggml_backend_buffer_is_host(tensor->buffer) if you are attempting to read tensor data directly from memory without using the provided getter functions [3].

Citations:


Serialize the KV backup while generate() runs.

save_kv_state uses ggml_backend_tensor_get on the same GPU backend that generate() computes with. Calling an asynchronous tensor read during ggml_backend_sched_graph_compute can race with GPU work and produce a corrupted buffer or backend failure.

Move the save below generate() before prefill_cache_.valid = true, or snapshot the needed KV region before generation starts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_pipeline.cpp` around lines 980 - 994, Move the asynchronous
save_kv_state operation in the prefill cache save flow below generate()
completes, before prefill_cache_.valid is set true, so KV backup serialization
cannot overlap GPU computation. Update the logic around save_n_past,
kv_save_thread, and model().save_kv_state while preserving the keep_kv_on_gpu
path and existing cache status logging.

Comment thread src/s2_pipeline.cpp
Comment on lines +1361 to +1382
if (params.enable_vram_swap) {
if (pending_offload_thread_.joinable()) {
pending_offload_thread_.join();
}

if (model_prefers_gpu_ && !model().is_weights_on_gpu()) {
safe_print_ln("[Pipeline] Streaming: Restoring Slow-AR to VRAM...");
model().acquire_compute_resources();
model().restore_weights_to_gpu();
}

if (codec_prefers_gpu_) {
if (codec().is_encoder_on_gpu()) {
codec().free_encoder_weights();
}
if (!codec().is_decoder_on_gpu() && !codec().is_weights_on_gpu()) {
safe_print_ln("[Pipeline] Streaming: Restoring Audio Codec DECODER to VRAM...");
codec().restore_decoder_weights();
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the return types of the restore APIs used by the streaming path.
set -euo pipefail

rg -nP -C 2 '\b(restore_weights_to_gpu|restore_decoder_weights|restore_encoder_weights|acquire_compute_resources)\s*\(' include/s2_model.h include/s2_codec.h || true

Repository: rodrigomatta/s2.cpp

Length of output: 1189


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## src/s2_pipeline.cpp streaming restore path"
sed -n '1345,1390p' src/s2_pipeline.cpp | cat -n

echo
echo "## src/s2_pipeline.cpp non-streaming restore path"
sed -n '830,855p' src/s2_pipeline.cpp | cat -n

echo
echo "## s2_codec implementation restore/decode error paths"
fd -a 's2_codec' . | sed 's#^\./##'
rg -n -C 6 '\b(restore_decoder_weights|restore_encoder_weights|restore_weights_to_gpu|decode_audio|decode)\b' src include -g 's2_codec*' -g '*.cpp' -g '*.h' | head -n 220 || true

Repository: rodrigomatta/s2.cpp

Length of output: 18455


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## s2_codec restore implementations"
rg -n -C 5 'S2Codec::restore_(weights_to_gpu|decoder_weights|encoder_weights)_to_gpu|S2Codec::restore_decoder_weights|S2Codec::restore_encoder_weights' src include -g 's2_codec*' | sed -n '1,240p'

echo
echo "## s2_model restore implementation"
rg -n -C 8 'S2Model::restore_weights_to_gpu|model_restore_weights_to_gpu' src include -g 's2_model*' | sed -n '1,240p'

echo
echo "## pipeline generate call in streaming path"
sed -n '1390,1545p' src/s2_pipeline.cpp | cat -n

Repository: rodrigomatta/s2.cpp

Length of output: 193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## files matching s2_codec/s2_model"
git ls-files | rg '(^|/)(s2_codec|s2_model)(\.(cpp|h|hpp))?$' || true

echo
echo "## occurrences of restore decoder/encoder/weights to gpu"
rg -n -C 5 'restore_(decoder_weights|encoder_weights|weights_to_gpu)|free_decoder_weights|free_encoder_weights|free_gpu_weights' src include --glob 's2_codec.*' --glob 's2_model.*' | sed -n '1,260p' || true

echo
echo "## s2_model restore occurrences"
rg -n -C 8 'restore_weights_to_gpu|restore.*gpu|acquire_compute_resources' src include --glob 's2_model.*' | sed -n '1,260p' || true

echo
echo "## streaming generate call"
sed -n '1390,1545p' src/s2_pipeline.cpp | cat -n

Repository: rodrigomatta/s2.cpp

Length of output: 19269


Handle VRAM restore failures before streaming generation.

Lines 1368-1378 discard the bool results from model().restore_weights_to_gpu() and codec().restore_decoder_weights(). If either restore fails, generate() or codec decode can run without the expected GPU weight state, and streaming reports only generic on_error values without the actual restore failure reason.

🐛 Proposed fix
         if (model_prefers_gpu_ && !model().is_weights_on_gpu()) {
             safe_print_ln("[Pipeline] Streaming: Restoring Slow-AR to VRAM...");
             model().acquire_compute_resources();
-            model().restore_weights_to_gpu();
+            if (!model().restore_weights_to_gpu()) {
+                safe_print_error_ln("Pipeline error: Slow-AR weight restore failed.");
+                sink.on_error("Slow-AR weight restore failed");
+                return false;
+            }
         }
@@
             if (!codec().is_decoder_on_gpu() && !codec().is_weights_on_gpu()) {
                 safe_print_ln("[Pipeline] Streaming: Restoring Audio Codec DECODER to VRAM...");
-                codec().restore_decoder_weights();
+                if (!codec().restore_decoder_weights()) {
+                    safe_print_error_ln("Pipeline error: decoder weight restore failed.");
+                    sink.on_error("Decoder weight restore failed");
+                    return false;
+                }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (params.enable_vram_swap) {
if (pending_offload_thread_.joinable()) {
pending_offload_thread_.join();
}
if (model_prefers_gpu_ && !model().is_weights_on_gpu()) {
safe_print_ln("[Pipeline] Streaming: Restoring Slow-AR to VRAM...");
model().acquire_compute_resources();
model().restore_weights_to_gpu();
}
if (codec_prefers_gpu_) {
if (codec().is_encoder_on_gpu()) {
codec().free_encoder_weights();
}
if (!codec().is_decoder_on_gpu() && !codec().is_weights_on_gpu()) {
safe_print_ln("[Pipeline] Streaming: Restoring Audio Codec DECODER to VRAM...");
codec().restore_decoder_weights();
}
}
}
if (params.enable_vram_swap) {
if (pending_offload_thread_.joinable()) {
pending_offload_thread_.join();
}
if (model_prefers_gpu_ && !model().is_weights_on_gpu()) {
safe_print_ln("[Pipeline] Streaming: Restoring Slow-AR to VRAM...");
model().acquire_compute_resources();
if (!model().restore_weights_to_gpu()) {
safe_print_error_ln("Pipeline error: Slow-AR weight restore failed.");
sink.on_error("Slow-AR weight restore failed");
return false;
}
}
if (codec_prefers_gpu_) {
if (codec().is_encoder_on_gpu()) {
codec().free_encoder_weights();
}
if (!codec().is_decoder_on_gpu() && !codec().is_weights_on_gpu()) {
safe_print_ln("[Pipeline] Streaming: Restoring Audio Codec DECODER to VRAM...");
if (!codec().restore_decoder_weights()) {
safe_print_error_ln("Pipeline error: decoder weight restore failed.");
sink.on_error("Decoder weight restore failed");
return false;
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_pipeline.cpp` around lines 1361 - 1382, Handle the boolean results
from model().restore_weights_to_gpu() and codec().restore_decoder_weights() in
the VRAM restoration block before streaming generation. If either restore fails,
stop the streaming path and propagate a descriptive error containing the restore
failure reason instead of continuing to generate or decode with incomplete GPU
state; preserve the existing successful restore flow.

Comment thread src/s2_pipeline.cpp
Comment on lines +1596 to +1622

if (params.enable_vram_swap) {
if (params.is_persistent) {
if (params.enable_hot_swap) {
safe_print_ln("[Pipeline] Hot-Swap: Releasing compute resources...");
model().free_compute_buffers();

safe_print_ln("[Pipeline] Hot-Swap: Spawning background thread to free VRAM & RAM...");
std::thread offload_thread([this]() {
if (model().is_weights_on_gpu()) model().free_gpu_weights();
if (codec().is_decoder_on_gpu()) codec().free_decoder_weights();
if (codec().is_encoder_on_gpu()) codec().free_encoder_weights();
if (codec().is_weights_on_gpu()) codec().free_gpu_weights();
model().mapped_file().drop_page_cache();
codec().mapped_file().drop_page_cache();
safe_print_ln("[Pipeline] Hot-Swap: Background VRAM & RAM free complete.");
});
pending_offload_thread_ = std::move(offload_thread);
} else {
if (codec().is_decoder_on_gpu()) codec().free_decoder_weights();
model().free_gpu_weights();
model().free_compute_buffers();
}
} else {
safe_print_ln("[Pipeline] Single-shot mode: Skipping post-stream VRAM restore.");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Honour more_segments_pending in the streaming cleanup.

The non-streaming path gates every VRAM release on !params.more_segments_pending (lines 1148, 1164, 1216, 1233). This block ignores the flag. A multi-segment streaming request therefore frees the model weights and drops the mmap page cache after each segment, and line 1366 restores them again at the start of the next segment.

That reintroduces the per-segment restore cost the flag exists to prevent, and the vram=held metric has no streaming counterpart.

🐛 Proposed fix
     if (params.enable_vram_swap) {
         if (params.is_persistent) {
-            if (params.enable_hot_swap) {
+            if (params.more_segments_pending) {
+                safe_print_ln("[Pipeline] Streaming: More segments pending, holding VRAM.");
+            } else if (params.enable_hot_swap) {
                 safe_print_ln("[Pipeline] Hot-Swap: Releasing compute resources...");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (params.enable_vram_swap) {
if (params.is_persistent) {
if (params.enable_hot_swap) {
safe_print_ln("[Pipeline] Hot-Swap: Releasing compute resources...");
model().free_compute_buffers();
safe_print_ln("[Pipeline] Hot-Swap: Spawning background thread to free VRAM & RAM...");
std::thread offload_thread([this]() {
if (model().is_weights_on_gpu()) model().free_gpu_weights();
if (codec().is_decoder_on_gpu()) codec().free_decoder_weights();
if (codec().is_encoder_on_gpu()) codec().free_encoder_weights();
if (codec().is_weights_on_gpu()) codec().free_gpu_weights();
model().mapped_file().drop_page_cache();
codec().mapped_file().drop_page_cache();
safe_print_ln("[Pipeline] Hot-Swap: Background VRAM & RAM free complete.");
});
pending_offload_thread_ = std::move(offload_thread);
} else {
if (codec().is_decoder_on_gpu()) codec().free_decoder_weights();
model().free_gpu_weights();
model().free_compute_buffers();
}
} else {
safe_print_ln("[Pipeline] Single-shot mode: Skipping post-stream VRAM restore.");
}
}
if (params.enable_vram_swap) {
if (params.is_persistent) {
if (params.more_segments_pending) {
safe_print_ln("[Pipeline] Streaming: More segments pending, holding VRAM.");
} else if (params.enable_hot_swap) {
safe_print_ln("[Pipeline] Hot-Swap: Releasing compute resources...");
model().free_compute_buffers();
safe_print_ln("[Pipeline] Hot-Swap: Spawning background thread to free VRAM & RAM...");
std::thread offload_thread([this]() {
if (model().is_weights_on_gpu()) model().free_gpu_weights();
if (codec().is_decoder_on_gpu()) codec().free_decoder_weights();
if (codec().is_encoder_on_gpu()) codec().free_encoder_weights();
if (codec().is_weights_on_gpu()) codec().free_gpu_weights();
model().mapped_file().drop_page_cache();
codec().mapped_file().drop_page_cache();
safe_print_ln("[Pipeline] Hot-Swap: Background VRAM & RAM free complete.");
});
pending_offload_thread_ = std::move(offload_thread);
} else {
if (codec().is_decoder_on_gpu()) codec().free_decoder_weights();
model().free_gpu_weights();
model().free_compute_buffers();
}
} else {
safe_print_ln("[Pipeline] Single-shot mode: Skipping post-stream VRAM restore.");
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/s2_pipeline.cpp` around lines 1596 - 1622, Update the streaming cleanup
block around enable_vram_swap and the hot-swap offload thread to skip VRAM/RAM
release when params.more_segments_pending is true. Preserve cleanup for the
final segment, including synchronous releases and background offload behavior,
so multi-segment requests retain weights and mapped pages between segments.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant