Skip to content

Long-context KeyDiff KV-cache compaction crashes with EXC_BAD_ACCESS in cactus::kvcompress::rerope_recent_int8 (multi-turn session, ~900 decode tokens) #777

Description

@Abusoss

Summary

cactus_complete (streaming) reliably crashes the host process with EXC_BAD_ACCESS (code=2) inside cactus::kvcompress::rerope_recent_int8, called from cactus::engine::Model::compress_kv_cache_keydiff, on the second turn of a multi-turn conversation once decode has produced ~900 tokens on top of an ~11.4k-character prompt. No Python exception is raised — the process is killed at the OS level (zsh: illegal hardware instruction when run without a debugger). This appears to be in the rolling KeyDiff KV-cache compaction path added in #686 ("Long-context: rolling KeyDiff KV-cache compaction + chunked prefill").

This is 100% reproducible on our machine across multiple runs with an identical two-turn session.

Environment

  • Repo / runtime version: <cactus commit hash or release tag you built/downloaded — run pip show cactusor checkweights//manifest.json/ yourcactus --version if applicable>
  • OS: macOS <your macOS version, e.g. 15.x>
  • Chip: <exact Apple Silicon chip, e.g. Apple M3 Pro — from "About This Mac">, arm64
  • Python: 3.12 (venv at .../cactus/venv), calling the engine via ctypes (_ctypes.cpython-312-darwin.so)
  • Model bundle: gemma-4-e2b-it-cq4-apple-uncalibrated (Gemma 4 E2B, CQ4, Apple, uncalibrated), loaded via cactus_init(model_path, None, False)
  • Integration: custom FastAPI OpenAI-compatible bridge server (cactus_agent_server.py) calling cactus_reset() + cactus_complete() (streaming, with a token callback) from a Python threading.Thread per request, one request per HTTP call, inference_lock serializes calls to a single loaded model handle.

Steps to Reproduce

  1. Load gemma-4-e2b-it-cq4-apple-uncalibrated via cactus_init.
  2. Turn 1: call cactus_reset() then cactus_complete() (streaming) with a 2-message conversation that renders to ~7,644 chars of messages_json, tools_json present (20 tool defs, ~19,633 chars), options={"auto_handoff": false, "enable_thinking_if_supported": true, "max_tokens": 64000}. Generation completes successfully after 849 decode tokens. No crash.
  3. Turn 2, same session (no reset in between other than the cactus_reset() the bridge issues before every completion call): call cactus_reset() then cactus_complete() again, now with a 4-message conversation (prior turn included) rendering to ~11,422 chars of messages_json, same tools/options. Generation proceeds normally — heartbeats confirm ~25 tokens/sec of clean decode — until ~900 decode tokens, at which point the process receives EXC_BAD_ACCESS and dies immediately, mid-stream, with no error returned to the caller.

We reproduced this 3 times in a row with lldb attached and it crashed at effectively the same point in the same code path every time (once at the tail end of a long recursive stack that we've since ruled out as a separate, already-mitigated issue — see "What we've ruled out" below).

Crash details (from lldb -- $(which python) cactus_agent_server.py)

Process stopped
* thread #7, stop reason = EXC_BAD_ACCESS (code=2, address=0x448424000)
    frame #0: 0x0000000107c21440 libcactus_engine.dylib`cactus::kvcompress::rerope_recent_int8(signed char*, float*, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, double, double) + 1104
libcactus_engine.dylib`cactus::kvcompress::rerope_recent_int8:
->  0x107c21440 <+1104>: ldr    s1, [x17], #0x4
    0x107c21444 <+1108>: ldr    q2, [x26, #0x70]
    0x107c21448 <+1112>: tbl.16b v1, { v1 }, v2
    0x107c2144c <+1116>: scvtf.4s v1, v1, #0x18

(lldb) bt
* thread #7, stop reason = EXC_BAD_ACCESS (code=2, address=0x448424000)
  * frame #0: 0x0000000107c21440 libcactus_engine.dylib`cactus::kvcompress::rerope_recent_int8(...) + 1104
    frame #1: 0x0000000107c11cf4 libcactus_engine.dylib`cactus::engine::Model::compress_kv_cache_keydiff(cactus::kvcompress::Params const&) + 1948
    frame #2: 0x0000000107c0c6d0 libcactus_engine.dylib`cactus::engine::Model::decode(std::__1::vector<unsigned int, ...> const&, float, float, unsigned long, std::__1::basic_string<...> const&, float*, float, float) + 1828
    frame #3: 0x0000000107c80ce0 libcactus_engine.dylib`cactus_complete + 8924
    frame #4: 0x000000019a0d0050 libffi.dylib`ffi_call_SYSV + 80
    frame #5: 0x000000019a0d95b8 libffi.dylib`ffi_call_int + 1220
    frame #6: 0x0000000106e9d890 _ctypes.cpython-312-darwin.so`_ctypes_callproc + 776
    frame #7: 0x0000000106e96a10 _ctypes.cpython-312-darwin.so`PyCFuncPtr_call + 216
    frame #8: 0x000000010075d944 Python`_PyObject_MakeTpCall + 124
    frame #9: 0x0000000100851464 Python`_PyEval_EvalFrameDefault + 38756
    frame #10: 0x0000000100760a5c Python`method_vectorcall + 368
    frame #11: 0x0000000100925534 Python`thread_run + 136
    frame #12: 0x00000001008ba430 Python`pythread_wrapper + 48
    frame #13: 0x00000001859a1c58 libsystem_pthread.dylib`_pthread_start + 136

The faulting instruction is a NEON 128-bit load (ldr q2, [x26, #0x70]) immediately followed by a table lookup (tbl.16b) inside rerope_recent_int8, which strongly suggests x26 is pointing at a buffer that is either undersized, misaligned, or has already been freed/moved relative to what the loop bound / stride computation in compress_kv_cache_keydiff expects for this position count.

What we've ruled out

We initially hit a different crash on the same two-turn scenario: EXC_BAD_ACCESS in cactus_metal_plan_build at the bottom of a ~7,440-frame-deep recursive stack inside CactusGraph::execute, which was overflowing the default (~512KB–2MB) stack of the worker thread created via Python's threading.Thread. We mitigated that specific crash on our side by calling threading.stack_size(16 * 1024 * 1024) before starting any threads, which resolved it completely (confirmed via multiple repro runs with no more cactus_metal_plan_build crashes). The rerope_recent_int8 crash above is a separate issue that only became visible once the stack-overflow crash was out of the way — it happens on a normal, shallow (~13 frame) call stack, so it isn't a stack-size problem.

Suspected location to investigate

  • cactus::kvcompress::rerope_recent_int8 (buffer indexing/stride at the NEON tbl/load site around the reported offset +1104/+1108)
  • cactus::engine::Model::compress_kv_cache_keydiff, specifically how it computes the recent-token window bounds passed down into rerope_recent_int8 for a KV cache that has already undergone rolling KeyDiff compaction on a prior turn (i.e. does the recent-window arithmetic account for a cache that was already compacted once, or does it assume the pre-compaction/original position indices?)
  • Given this reproduces specifically on turn 2+ of a session (i.e. after at least one prior compaction cycle has already run), it may be specific to repeated/rolling compaction rather than a single compaction pass — worth checking whether a single very long single-turn generation that crosses the same token count without a prior cactus_reset()/turn boundary also reproduces it, or only the multi-turn case does.

Question for maintainers

Is there any way to disable KeyDiff KV-cache compaction at runtime via options_json (e.g. something like a kv_cache_compression/compress_kv flag)? We checked docs/cactus_engine.md's options table and didn't find one documented — if one exists but is undocumented, that would be a usable workaround for us in the meantime. If not, is pinning to the commit immediately before #686 currently the only way to avoid this on long-context, multi-turn use?

Happy to provide the full runtime bundle manifest, a minimal repro script (no FastAPI, just direct cactus_init/cactus_reset/cactus_complete calls replaying the same two-turn message history), or register/memory dumps around 0x448424000 if useful — let me know what's most useful to attach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions