headroom/wiki
Logan Kang c71592d421
feat(memory): add opt-in Apple-GPU (MPS) embedding runtime (#766)
## Description

On Apple-Silicon Macs — especially fanless models like the MacBook Air
(M5) — running the proxy with memory context injection can pin the CPU
while embedding. The embedding work runs an uncapped session on the CPU,
saturating multiple cores, which starves the proxy's asyncio loop and
leads to request timeouts.

This PR adds an **opt-in** runtime that offloads the memory embedder to
the Apple GPU (MPS). Setting `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`
routes embedding through the torch `sentence-transformers` backend on
MPS instead of the default ONNX CPU embedder, moving the work off the
CPU and keeping the proxy responsive.
The default behavior is unchanged — the feature is strictly opt-in,
env-var only, and falls through to the existing default embedder
selection (with a warning) whenever MPS or the torch dependencies are
unavailable.

Fixes: N/A — no tracking issue (surfaced while running codex auto-review
through
the proxy on a fanless MacBook Air (M5)).

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [x] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- **Runtime selection** (`headroom/proxy/memory_handler.py`): read
`HEADROOM_EMBEDDER_RUNTIME`; when set to `pytorch_mps` **and** MPS is
actually available, route the memory embedder to the torch
`sentence-transformers` backend (Apple GPU).
- If MPS is unavailable or torch/sentence-transformers is not installed,
log a warning and fall through to the existing default embedder
selection (ONNX when available, else the pre-existing local
sentence-transformers fallback) — no crash. The default (env var unset)
is unchanged. Env-var only
- **MPS serialization** (`headroom/memory/adapters/embedders.py`):
`LocalEmbedder` now funnels every `encode()` through a dedicated
single-worker `ThreadPoolExecutor` when the resolved device is MPS.
torch-MPS is not thread-safe, and the existing `run_in_executor(None,
...)` dispatch would otherwise let concurrent proxy requests call MPS
from multiple threads. CPU/CUDA keep the shared default executor
(behavior unchanged). `close()` also drops the cached model so re-use
after close re-initializes cleanly.
- **Packaging** (`pyproject.toml`): new `pytorch-mps` extra (`torch` +
`sentence-transformers`), **platform-gated to macOS** (`; sys_platform
== 'darwin'`) since MPS is Apple-Silicon-only. Deliberately left out of
`[all]` (its deps already arrive via `[ml]`/`[memory]`).
- **Tests** (`tests/test_memory/test_embedder_mps_serialization.py`):
regression coverage for the serialized executor, concurrency safety (no
SIGABRT), CPU-path default behavior, and close/re-use re-initialization.
- **Docs**: `wiki/{configuration,memory,macos-deployment}.md`,
`docs/content/docs/{configuration,installation,memory}.mdx`,
`README.md`, `CHANGELOG.md`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed (CPU-offload + concurrency profiling on
Apple Silicon)

## Test Output

```
$ pytest -v tests/test_memory/test_embedder_mps_serialization.py
collected 4 items
tests/test_memory/test_embedder_mps_serialization.py::test_cpu_uses_shared_executor PASSED            [ 25%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_creates_single_worker_executor PASSED  [ 50%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_concurrent_embeds_do_not_crash PASSED  [ 75%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_reembed_after_close_recreates_executor PASSED [100%]
============================== 4 passed in 6.92s ===============================

$ ruff check headroom/memory/adapters/embedders.py headroom/proxy/memory_handler.py tests/test_memory/test_embedder_mps_serialization.py
All checks passed!

$ mypy headroom/memory/adapters/embedders.py headroom/proxy/memory_handler.py
Success: no issues found in 2 source files

$ pytest -q tests/test_memory/ tests/test_memory_handler_concurrent_init.py tests/test_memory_handler_native_ops.py
553 passed, 1 skipped in 13.51s
```

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

**Why MPS (and not CoreML or a thread cap):** measured on an
Apple-Silicon Mac, the default uncapped CPU embedding session saturates
the cores; the same model on MPS runs at a fraction of the CPU (≈8x
lower sustained CPU utilization in profiling) while producing
**byte-identical embeddings** (cosine distance ≈ 0 across runtimes), so
relevance/ranking is unchanged. A CoreML execution-provider path was
evaluated and rejected: the default optimized ONNX model uses fused ops
that fall back to CPU under CoreML (no offload), and a full-precision
re-export was impractical (very low throughput + multi-GB memory). MPS
via `sentence-transformers` was the only practical GPU offload.

**Why serialization is mandatory:** torch-MPS is not thread-safe —
concurrent encode calls from a multi-worker executor abort with
`-[IOGPUMetalCommandBuffer validate]: failed assertion 'commit an
already committed command buffer'` (reproduced deterministically; a
single-worker executor resolves it).
Under concurrent load the serialized single-GPU-stream throughput meets
or exceeds the parallel CPU path while using a fraction of the cores.

**Scope / boundary:** this targets the Python **memory** embedder, which
is live on the proxy request path (memory context injection). The
Rust-backed SmartCrusher compression path is unaffected and remains
non-configurable from Python by design.

**Safety:** default behavior is unchanged (ONNX, no torch). The feature
is opt-in, env-var only, macOS-gated at the packaging layer, and
degrades gracefully (warn + the existing default embedder selection)
when MPS or the dependencies are unavailable.
2026-06-11 12:59:20 -05:00
..
overrides new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
plans fix: add anthropic pre-upstream timeouts 2026-04-21 09:48:32 +07:00
screenshots new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
stylesheets new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
agno.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
api.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
ARCHITECTURE.md fix(cli): proxy/perf/wrap UX cleanup + perf --hours correctness 2026-05-07 16:43:35 -07:00
benchmarks.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
ccr.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
cli.md docs: surface code-aware proxy flags 2026-05-07 00:18:50 +05:30
compression.md fix(cli): proxy/perf/wrap UX cleanup + perf --hours correctness 2026-05-07 16:43:35 -07:00
configuration.md feat(memory): add opt-in Apple-GPU (MPS) embedding runtime (#766) 2026-06-11 12:59:20 -05:00
docker-install.md fix: align docker image versions with releases 2026-04-17 12:17:49 -05:00
errors.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
filesystem-contract.md docs: document HEADROOM_CONFIG_DIR / HEADROOM_WORKSPACE_DIR filesystem contract 2026-04-16 19:24:17 -05:00
getting-started.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
image-compression.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
index.md docs: document HEADROOM_CONFIG_DIR / HEADROOM_WORKSPACE_DIR filesystem contract 2026-04-16 19:24:17 -05:00
integration-guide.md fix(copilot): restore generic endpoint for non-subscription OAuth (#610) (#612) 2026-06-04 16:27:54 -07:00
langchain.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
LATENCY_BENCHMARKS.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
learn.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
LIMITATIONS.md fix(cli): proxy/perf/wrap UX cleanup + perf --hours correctness 2026-05-07 16:43:35 -07:00
macos-deployment.md feat(memory): add opt-in Apple-GPU (MPS) embedding runtime (#766) 2026-06-11 12:59:20 -05:00
mcp.md docs: document HEADROOM_CONFIG_DIR / HEADROOM_WORKSPACE_DIR filesystem contract 2026-04-16 19:24:17 -05:00
memory.md feat(memory): add opt-in Apple-GPU (MPS) embedding runtime (#766) 2026-06-11 12:59:20 -05:00
metrics.md fix(cli): proxy/perf/wrap UX cleanup + perf --hours correctness 2026-05-07 16:43:35 -07:00
network-diff-capture.md feat: add differential network capture harness (#761) 2026-06-08 22:18:31 -07:00
persistent-installs.md docs: document HEADROOM_CONFIG_DIR / HEADROOM_WORKSPACE_DIR filesystem contract 2026-04-16 19:24:17 -05:00
proxy.md docs(proxy): document Anthropic API URL overrides 2026-06-04 12:07:16 +05:30
quickstart.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
sdk.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
shared-context.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
strands.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
text-compression.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
transforms.md fix(cli): proxy/perf/wrap UX cleanup + perf --hours correctness 2026-05-07 16:43:35 -07:00
troubleshooting.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
typescript-sdk.md new docs UI + ts doc coverage 2026-04-12 13:15:58 +06:00
vertex.md feat: add Vertex AI proxy routing (#793) 2026-06-09 23:05:30 -07:00