[MP][Maru] Maru CXL shared L1 backend for MP mode#20
Conversation
2477191 to
ce54689
Compare
jooho-XCENA
left a comment
There was a problem hiding this comment.
docs/coding_standards.md 기준 리뷰입니다. error 항목만 코멘트로 답니다 (warning/info는 별도 공유).
[error] 신규 maru L1 백엔드에 in-repo 설계 문서가 없고, PR 설명이 인용하는 설계 정본이 dangling 참조임
PR 설명은 temp_docs/mp/design/maru_l1_manager.md · temp_docs/mp/design/maru_l1_api_mapping.md를 설계 정본으로 인용하지만, temp_docs/는 PR 트리·브랜치 히스토리 어디에도 없습니다 (git ls-tree -r HEAD | grep temp_docs → 없음). 이 PR은 maru_l1_manager.py(1,057줄) · maru_memory_allocator.py(243줄) · l1_protocol.py(113줄)를 추가하면서 docs/를 건드리지 않아, coding_standards §5.1(non-trivial 신규 기능은 docs/design/<path>/ 설계 문서 필수)에 걸리고 리뷰어가 설계 정합 검토(리뷰 Step 2)를 할 수 없습니다.
수정 제안: 설계 정본을 docs/design/v1/distributed/(예: maru_l1.md — MaruL1Manager / MaruMemoryAllocator / L1ManagerInterface seam / orphan sweeper·crash-recovery 전제 포함)로 이 PR에 추가하고, PR 설명의 참조를 in-repo 경로로 갱신해 주세요.
kihwan-XCENA
left a comment
There was a problem hiding this comment.
주호님이 위에 리뷰로 언급하신 것 처럼 _pending_write와 _pending_read가 동시에 존재 할 수 있는 상황이 되어도 괜찮을까요?
하나의 KV Cache가 _pending_write 및 _pending_read에 동시에 저장되는 경우가 있는 것 같습니다.
|
[error] 설계 문서 (in-repo) — 수정했습니다 (
|
|
@kihwan-XCENA 좋은 지적 감사합니다 — 실제로 가능한 버그였습니다(주호님 리뷰의 mid-write reserve_read 건과 동일 뿌리).
|
- l1_protocol.py: structural runtime_checkable Protocol mirroring L1Manager's 17-method surface, with per-method listener/lock contract docstrings - config.py: MaruL1Config + maru_config field, __post_init__ DRAM-clamp skip, maru CLI args (--maru-server-url/--maru-pool-size-gb/--maru-instance-id), parse_args_to_config wiring - tests: interface<->L1Manager method-set + signature conformance, maru config parsing Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- thin wrapper over external maru_lmcache.CxlMemoryAdapter, lazy-imported - two-phase init_layout: build MaruHandler + CxlMemoryAdapter on first layout (single-model; layout mismatch rejected) - free/batched_free no-op (page lifecycle owned by MaruServer); abort_alloc discards an allocated-but-unregistered page - MaruL1Config -> maru.MaruConfig mapping; maru-only get_by_location / create_store_handle / handler surface for MaruL1Manager - tests use mocked maru runtime (no CXL required) Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
…ifecycle) - sibling of L1Manager over the maru shared CXL pool: membership/read protection live in the MaruServer directory (pin_count), locally only in-flight staging (_pending_read refcount, _pending_write) - reserve_read: per-key independent pins (1+extra_count via one RPC), rollback on partial pin / retrieve failure / unresolvable page - reserve_write mode=new: local staged check + batch_exists dedup (cross-instance), all-or-nothing OOM; finish_write: batch_store, dup-skip is success, definitive False reclaims the page, unknown server state never recycles - delete: staged keys and pinned keys refuse with KEY_IS_LOCKED (exists() disambiguates the handler's pinned/missing conflation) - clear(force=False) keeps locked staging (stock parity); close drains - PARITY/MARU provenance comments; RPC reply length guards - tests: stateful fake maru runtime with fault-injection knobs, failure-path coverage, and a conformance suite parametrized over both L1Manager (CUDA) and MaruL1Manager Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- reserve_read/reserve_write/finish_write/finish_read/delete/clear now fire the on_l1_keys_* events (feeds the eviction LRU and the store controller), reporting only the keys that actually succeeded - finish_read: temporary reads (local staging, never directory-pinned) are reclaimed via abort_alloc at refcount zero and fire deleted_by_manager; normal reads still unpin -- _drain_staging mirrors the same branch - touch_keys: no-op -> fires on_l1_keys_accessed (unsynchronized, like stock) - event_bus (mp_observability) publish deferred: observability-only, no tiering-control impact - tests: RecordingListener fake; maru firing edge cases (hit-only, store-fail, temporary reclaim, removed-only, clear) + cross-backend conformance lifecycle test parametrized over stock and maru Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- promote branches on the staged is_temporary flag (Decision A): temporary (default prefetch) moves the loaded page straight to read staging with no batch_store and no pin -- finish_read reclaims it at refcount zero; retained (prefetch_policy: retain) registers via batch_store then re-resolves the authoritative page with pins so a dup-skip that auto-freed our page still yields the winning shared page - fires on_l1_keys_finish_write_and_reserve_read, never on_l1_keys_write_finished (the latter would make the store controller re-store the key to L2) - extract _pin_retrieve_stage (shared by reserve_read + retained promote) and _store_staged (shared by finish_write + retained promote); reserve_read and finish_write refactored onto them, behavior unchanged - document the load-failure cleanup gap (finish_write->delete on failed keys publishes then removes; caller-side batch-abort is the future fix) - tests: temporary/retained promote, dup-skip re-resolve, extra_count pins, wrong-state/unstaged guards, store-failure, anti re-store event check; cross-backend conformance for retained promote, temporary drop, promote event Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- daemon sweeper (started in __init__, stopped in close) scans staging under the manager lock and reclaims entries whose TTL has elapsed: an expired write page is returned to the owner (abort_alloc); an expired read releases its pins (a temporary read reclaims its private page instead) - _PendingRead/_PendingWrite carry a monotonic deadline (default never); set at reserve/promote and refreshed on overlapping reserve (mirrors a stock re-lock extending the TTL). abandonment is a time judgement -- a refcount says how many holds exist, not whether they will ever be released - no listener fires on sweep: a late finish_read/unsafe_read then sees KEY_NOT_EXIST and recomputes (same failure path as a stock TTL expiry), and firing across the daemon thread would be a novel hazard for stock listeners - tests: sweeper lifecycle, expired write/read/temporary reclaim, live staging left intact, overlapping reserve refreshes the deadline, late finish is KEY_NOT_EXIST Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- store/prefetch/eviction controllers type their l1_manager param as the structural L1ManagerInterface instead of the concrete L1Manager, so either L1Manager or MaruL1Manager can drive them; runtime behavior unchanged - the controllers call only Protocol methods (reserve/finish read+write, finish_write_and_reserve_read, delete, is_key_evictable, get_memory_usage, register_listener), so L1Manager still satisfies the param structurally Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
…_layout - StorageManager.__init__ selects the L1 backend: MaruL1Manager when memory_config.maru_config is set, else the stock L1Manager; typed as the shared L1ManagerInterface so the controllers drive either unchanged - new StorageManager.register_kv_layout: maru-gated (isinstance) forward that brings up the CXL pool once the layout is known, rejecting >1 object group (single-model maru limit); a no-op for stock. Engine call site lands later - get_l1_memory_desc is now L1MemoryDesc | None (maru has no single registerable region); the l1_memory_desc property raises if accessed while None (its only consumer, p2p, is rejected at startup for maru) - widen SerdeL2AdapterWrapper l1_manager param to L1ManagerInterface (like the controllers); collect its reserve_write buffers in one pass so the success path types as list[MemoryObj] - tests: maru vs stock selection, register_kv_layout forward / multi-group reject / stock no-op Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
…/p2p/engine) - validate_storage_manager_config: a maru L1 config rejects the other L1 backends (gds-l1-path, l1-devdax-path), store_policy=skip_l1, and L2 adapters that require a single registerable region (nixl / mooncake-rdma); copy-type L2 and the default store policy pass - l1_exposes_single_memory_region returns False for maru (the shared CXL pool has no single registerable region), so the existing p2p startup guard fires; its message now lists maru too - run_http_server rejects maru paired with a non-lmcache_driven transfer mode (engine-driven/auto assume engine-side buffers the shared pool lacks) - tests: maru + gds/devdax/skip_l1/registered-L2 raise; maru + copy-L2 and the default policy pass; l1_exposes False for maru Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
…ol bring-up - after the KV layout is resolved in register_kv_cache, call StorageManager.register_kv_layout with the layout desc, the storage MemoryFormat (KV_MLA_FMT / KV_2LTD via is_mla), chunk size, and object-group count; a no-op for the stock backend, idempotent for maru across instances - on failure (e.g. maru rejecting >1 object group) close the just-built cache context and re-raise so the instance is not left half-registered - runtime test deferred: importing this module needs a c_ops rebuild (built .so lacks execute_object_group_transfer); lands with the C9 G6 test post-rebuild Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
…ister_kv_layout) - G6 (C9): run_http_server rejects maru paired with a non-lmcache_driven transfer mode - C10: register_kv_cache forwards to StorageManager.register_kv_layout with the right MemoryFormat (KV_MLA_FMT / KV_2LTD via is_mla); on a rejected layout it closes the cache context and leaves the instance unregistered; stock registers normally - both were blocked by a stale c_ops build (missing execute_object_group_transfer); unblocked after the rebuild Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- MaruL1Manager now publishes the same L1 events to the shared event bus as stock L1Manager, alongside the listener notifications: L1_READ_RESERVED, L1_READ_FINISHED (+ L1_KEYS_EVICTED for freed temporaries), L1_WRITE_RESERVED, L1_WRITE_FINISHED, L1_WRITE_FINISHED_AND_READ_RESERVED, and L1_KEYS_EVICTED for delete/clear; touch_keys stays listener-only (matches stock) - add get_event_bus() in __init__ and a _publish helper mirroring stock - closes the observability gap deferred in C4 -- maru L1 ops now surface in the mp_observability dashboards - tests: write/read/delete lifecycle events, promote publishes the promote event (never L1_WRITE_FINISHED), temporary finish_read publishes evicted, touch_keys publishes nothing Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
…stack - a StorageManager harness backed by the maru fakes (fake CXL pool + MaruServer directory) + a mock L2, driving the real Store/Prefetch/Eviction controllers over MaruL1Manager - covers store -> maru directory registration, prefetch of directory-resident keys as a full L1 hit (maru reserve_read), and watermark eviction driving MaruL1Manager.delete on the shared directory - L1<->L2 byte movement (write-through, promote) is stock controller logic that needs real-memory-backed pages and is covered by the stock StorageManager tests, so it is not re-asserted here; extra_count and cross-node PINNED are covered at the manager level (C3/C5) Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
…acts) - add cross-backend conformance cases for the shared error paths: reserve_read of a mid-write key is KEY_NOT_READABLE (not a miss), finish_read/finish_write on an unstaged key is KEY_NOT_EXIST, delete of a missing key is KEY_NOT_EXIST - clear(force=True) is intentionally not conformance-tested: stock clears all objects while maru only drops local staging (the shared directory belongs to other instances) -- a legitimate divergence, not drift Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- get_memory_usage total = owned pool + CXL device free (cxl_pool.free_size from get_stats), so the eviction watermark tracks whole-device fill instead of just this instance's owned pool (which evicts prematurely while the device still has room) - cache the last-known free and reuse it when a get_stats RPC omits cxl_pool (transient timeout / older server) so total never momentarily collapses to the owned pool and fires a spurious eviction Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- Track server pins on _PendingRead.pinned separately from refcount, so a temporary stage that absorbs an overlapping reserve_read's pins releases them; previously those pins leaked and left the page un-evictable on MaruServer. - Exclude mid-write keys from reserve_read's pin/stage step: a key mid-write on this instance stays KEY_NOT_READABLE even when a peer has registered it, instead of being double-staged in both _pending_write and _pending_read (which stranded the in-flight write and failed its promote). - Add regression tests for both paths. Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- Add docstrings to allocate, batched_allocate, close, and memcheck. - Note the RuntimeError raised when called before init_layout(), and the out-of-memory -> None contract that reserve_write's all-or-nothing handling depends on. Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- Add docs/design/v1/distributed/maru_l1.md: the sibling + Protocol seam, the MaruL1Manager API contracts, the pending-read/pending-write state-machine invariants, the TTL sweeper, and the crash-recovery premises. - Document the Maru CXL shared L1 tier in the MP configuration guide and the architecture index; point the deprecated in-process maru page to it. Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- Add MaruL1Config.auto_expand (default True) with a --maru-auto-expand / --no-maru-auto-expand CLI flag, threaded into MaruConfig via the allocator. - Branch get_memory_usage on it: auto_expand on keeps the device-fill watermark (owned pool + CXL device free); off anchors total to the owned pool, so a hard-capped pool evicts before it is exhausted instead of OOMing while the device still has free space the pool cannot grow into. - Extend the fake handler with a cxl_free knob and add a watermark test. Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- Rewrite docs/design/v1/distributed/maru_l1.md to the l2_adapters house style (Overview + components + operation flow + configuration + limits); document the auto_expand knob and the single-device region bound, and drop the internals-heavy framing. - Correct MaruMemoryAllocator docstrings: page reclamation is driven by LMCache eviction via MaruL1Manager (delete / abort_alloc), not the allocator, so free/batched_free are no-ops. Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- Merge the maru-specific test files into one unit file (test_maru_l1_manager.py): fold in the allocator, config/startup-guard, and control-integration tests, and drop cases already covered by the shared L1Manager conformance suite (notably the notification tests: ~14 -> 4). - Absorb test_l1_protocol.py (structural conformance + shared helpers) into test_l1_manager_conformance.py. - Delete the now-empty test_maru_memory_allocator.py, test_l1_config_maru.py, test_maru_integration.py, and test_l1_protocol.py. Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- Add --maru-auto-expand / --no-maru-auto-expand to the Maru CXL Shared L1 argument table. - Tighten the single-CXL-device pool-size note and align the tier wording with the design doc (cross-node). Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
…gram Restore the word dropped when MaruL1Manager was added to the EvictionController line, for consistency with the rest of index.rst. Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- Add the required --l1-size-gb 0 to the quickstart command (ignored under Maru but the parser requires it). - Note that the example assumes a running MaruServer and that higher-level launchers derive --maru-server-url. Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
- lmcache_mp.l1_memory_usage_bytes / l1_usage_ratio are registered in L1Manager.__init__, but the maru path builds MaruL1Manager instead of L1Manager (mutually exclusive), so both gauges silently vanished whenever maru is the L1 backend - mirror the stock registration in MaruL1Manager.__init__: same meter and gauge names, singleton dispatch via _gauge_registered/_gauge_target, and a self-contained ratio helper (no reach into the stock private helper) - conftest: stub maru's register_gauge for distributed tests -- OTel honors only the first registration of a gauge name, so a test-built MaruL1Manager could otherwise claim the name and starve the stock gauge assertions in test_l1_l2_state_metrics.py - test: both gauges registered at construction and their callbacks read the live manager, not the zero fallback Signed-off-by: seohui-XCENA <seohui.son@xcena.com>
디자인 문서:
요약
MP mode의 L1 tier를 CPU DRAM 대신 cross-instance 공유 CXL 풀(Maru)로 교체한다.
그 위의 LMCache 동작 — L1↔L2 티어링(write-through / discard-evict / promote-on-miss),
컨트롤러, L2 어댑터, eviction — 은 기존 그대로 maru L1 위에서 돈다.
핵심 설계 결정:
컨트롤러가, MaruServer는 그 결정을 실행·기록하는 passive 공유 디렉터리(
key→region/offset+pin_count).설계 문서:
docs/design/v1/distributed/maru_l1.md(이 PR에 포함) — sibling + Protocol seam, API 계약, 상태머신 불변식, TTL sweeper·crash-recovery 전제.통합 방식 (sibling + 구조적 Protocol)
StorageManager가 config에 따라 L1을 한 줄로 선택:MaruL1Manager(cfg) if maru_config else L1Manager(cfg)— 둘 다L1ManagerInterface만족.l1_protocol.py(신규): 구조적typing.ProtocolL1ManagerInterface(17 메서드,@runtime_checkable).L1Manager·MaruL1Manager둘 다 상속 없이 구조적으로 만족.l1_manager파라미터 타입만L1ManagerInterface로확장(런타임 동작 동일).
코어 무침습
dev와 byte-identical (무변경):
l1_manager.py,l1_memory_manager.py,internal_api.py,모든 컨트롤러 로직, L2 어댑터.
실제로 편집되는 건 배선점뿐이고 전부 additive/behavior-neutral:
store/prefetch/eviction_controller.pystorage_manager.py— L1 선택 분기 + maru-onlyregister_kv_layoutwrapperconfig.py—maru_config필드 + CLI + startup guardlmcache_driven_transfer.py의register_kv_layout(default 백엔드엔 no-op)serde_wrapper.py— L2 어댑터l1_manager타입을 Protocol로 위젠(behavior-neutral)커밋 구성 (C1–C11)
L1ManagerInterfaceprotocol + maru L1 configMaruMemoryAllocator(CXL-backed allocator)MaruL1ManagerRPC control 표면 (read/write/delete/lifecycle)touch_keysreal화finish_write_and_reserve_read(L2→L1 promote, temporary/retained)l1_manager파라미터 →L1ManagerInterface위젠register_kv_layoutseamregister_kv_cache에register_kv_layout엔진 훅 배선 (+deferred 훅 테스트)테스트
maru_fakesin-memory 하네스 포함): manager 단위, Protocol 정합(test_l1_protocol),config guard, single-region, 제어 통합(기존 티어링 컨트롤러 위에서 maru 구동),
register_kv_layout,memory_allocator, conformance(같은 계약을 기존
L1Manager와MaruL1Manager에 동시 실행 → 드리프트 차단).CUDA_VISIBLE_DEVICES=1)에서 통과.범위 · 알려진 한계
init_layout에서 pool을 단일 layout으로 고정 →다른 모델·하이브리드(
num_object_groups>1)는 fail-fast ValueError. 같은 모델 멀티-인스턴스는 정상.client crash(프로세스 사망) 회수는 이 PR 범위 밖 — maru-side 전제에 의존한다:
(1) MaruServer가 client별 pin을 추적해 disconnect 시 일괄 해제, (2) 죽은 owner의 region이 RM으로 반납
(region owner-release)되며 미등록 write page 회수. 둘 다 maru-side(MaruServer) 책임.
남은 것
L1Manager에서L1StateBackendseam 추출 → 하나의L1Manager가 local/maru backend를모두 서빙(
MaruL1Manager삭제). sibling 머지 후 별도 작업.PR 초안
What this PR does / why we need it:
This PR adds Maru, a CXL shared-memory KV cache engine, as a shared L1 tier for MP mode — the shared-L1 counterpart to the in-process Maru backend from LMCache#2705. Today each MP server has a private, node-local L1 (pinned DRAM / Device-DAX / GDS). With Maru, MP servers on separate nodes
mmapone CXL pool and read each other's entries zero-copy: a node that never stored a key still hits it if a peer did, with no network transfer.Control stays entirely with LMCache. Maru is a sibling L1 manager (
MaruL1Manager) behind a new structuralL1ManagerInterfaceProtocol; the stock controllers, L1↔L2 tiering, eviction, and L2 adapters run unchanged on top. The L1 core (l1_manager.py,l1_memory_manager.py,internal_api.py) is byte-identical todev; the remaining edits are additive wiring only.Design doc:
docs/design/v1/distributed/maru_l1.md· Maru: docs / githubBenchmark:
setup
/dev/dax9.0, 500 GB), served bymaru-server+ maru-resource-managerQwen/Qwen3-8B(bf16 KV)lmcache bench engine,long-doc-qa— one exported config replayed unchanged against all three setups: 40 GB KV volume → 29 documents × 10k tokens (tokens_per_gb_kvcache=7281), 1 query/doc,tileorder, 4 in-flight, 128 output tokens (ignore_eos), seed 42--gpu-memory-utilization 0.5(GPU KV ≈ 30 GiB ≈ 220k tokens < 290k-token workload, so the GPU cache alone cannot hold the working set), default prefix caching, chunk size 256lmcache_driven): B--l1-size-gb 60(DRAM L1, default lazy allocator) · B′ same +--no-l1-use-lazy(preallocatedMixedMemoryAllocator) · C--maru-server-url maru://localhost:5555 --maru-pool-size-gb 60 --l1-size-gb 0(Maru CXL L1); same 60 GB capacity, no eviction in any runAll runs: 29/29 requests successful, 290,396 input tokens.
B′ = B with
--no-l1-use-lazy(preallocated pinned-DRAMMixedMemoryAllocatorinstead of the defaultLazyMemoryAllocator).Grafana (LMCache MP dashboard) during the runs — L1 usage filling over the warm-up pass and 100%-hit read phase:
Special notes for your reviewers:
MaruServeris only a shared directory (key → region/offset) plus a cross-nodepin_count. Page reclamation is driven by LMCache eviction, so the allocator'sfreeis a no-op.MaruMemoryAllocatorsupplies the CXL medium,MaruL1Managerthe shared control. (The MP Coordinator is unrelated: it plans fleet-wide L2 eviction on logical keys, not L1 physical-page state.) A laterL1StateBackendseam to shrink the sibling is possible follow-up, out of scope here.skip_l1store policy, registered/RDMA-type L2 (copy-type only), p2p, and engine_driven/auto transfer mode.If applicable:
docs/source/mp/configuration.rst+ design doc)