L2 Store 및 Prefetch 컨트롤러 설계
변경 검증 가이드 (다음 fetch 후):
git log eaa2bfee..HEAD -- lmcache/v1/distributed/l2_adapters/ lmcache/v1/distributed/storage_controllers/ docs/design/v1/distributed/l2_adapters/overall.md
base.py의 invariant 10개 (eventfd 전역 고유, prefix-only loading, unlock 결국 성공 등) 가 깨지는 변경이면 "가정 및 Invariant 요약" 섹션 통째로 재검토.storage_controllers/store_controller.py/prefetch_controller.py의 lock invariant 표가 흐름 변경되면 본 문서의 Lock Invariant 표도 갱신.l2_adapters/에 어댑터가 추가/삭제되면 "새로운 L2 어댑터 구현하기" 섹션의 레퍼런스 (mock/raw_block/nixl_dynamic) 재확인.
이 문서는 StoreController 와 PrefetchController 가 L2 어댑터와 어떻게 상호작용하는지, 어떤 invariant 를 유지하는지, 그리고 어떤 가정을 전제로 하는지를 설명합니다. 새로운 L2 어댑터를 구현하거나 컨트롤러 로직을 수정하는 개발자를 대상으로 합니다.
아키텍처 개요
┌────────────────────────┐
│ StorageManager │
│ submit_prefetch_task │
│ query_prefetch_status │
│ reserve/finish_write │
└────┬──────────┬─────────┘
│ │
┌──────────┘ └──────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ StoreController │ │ PrefetchController │
│ (백그라운드 스레드) │ │ (백그라운드 스레드) │
│ │ │ │
│ L1 쓰기 완료 │ │ 외부 submit │
│ → L2 에 저장 │ │ → L2 조회 │
│ → 락 해제 │ │ → 계획 + 로드 │
└────┬───────────────┘ │ → L1 read-lock │
│ └──────┬──────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────┐
│ L2AdapterInterface(s) │
│ store / lookup_and_lock / load / unlock │
│ │
│ 각 어댑터는 3개의 독립된 eventfd 를 가짐: │
│ store_efd, lookup_efd, load_efd │
└─────────────────────────────────────────────┘
두 컨트롤러는 각각 백그라운드 스레드 하나로 실행되며, eventfd 에 대해 select.poll() 을 사용해 이벤트 기반 I/O 를 처리합니다. 같은 L2 어댑터 인스턴스 집합을 공유하지만 (계약상 thread-safe), 사용하는 eventfd 는 서로 다릅니다.
L2 어댑터 인터페이스
L2AdapterInterface (l2_adapters/base.py) 는 두 컨트롤러가 호출하는 논블로킹 I/O 기본 연산을 제공합니다. 모든 작업은 submit → eventfd poll → 결과 조회 패턴을 따릅니다.
Event FD
각 어댑터는 3개의 독립된 eventfd 를 노출합니다:
| 메서드 | 사용 주체 | 시그널 조건 |
|---|---|---|
get_store_event_fd() | StoreController | store 작업 완료 시 |
get_lookup_and_lock_event_fd() | PrefetchController | lookup 작업 완료 시 |
get_load_event_fd() | PrefetchController | load 작업 완료 시 |
핵심 invariant: 모든 어댑터의 모든 eventfd 는 전역적으로 고유해야 합니다. 컨트롤러는 fd → adapter_index 맵을 만들기 때문에, fd 가 중복되면 이벤트가 잘못된 어댑터로 조용히 전달됩니다.
Store 작업
submit_store_task(keys, objects) -> L2TaskId
pop_completed_store_tasks() -> dict[L2TaskId, bool]
- 버퍼는 호출자가 제공:
objects리스트는 StoreController 가 L1 read lock 을 걸어 관리하는MemoryObj참조를 담습니다. - 성공/실패는 작업 단위: store 작업은 전체 성공이거나 전체 실패입니다. 완료 딕셔너리의 bool 값이
True면 성공,False면 실패. - pop 의미론:
pop_completed_store_tasks()는 완료된 모든 작업을 한 번에 드레인합니다. 각 작업은 정확히 한 번만 나타납니다.
Lookup and Lock 작업
submit_lookup_and_lock_task(keys) -> L2TaskId
query_lookup_and_lock_result(task_id) -> Bitmap | None
submit_unlock(keys) -> None
- 락 획득:
lookup_and_lock은 어떤 key 가 존재하는지 확인하고 발견된 key 에 L2 측 락을 원자적으로 획득합니다. lookup 과 load 사이에 L2 eviction 이 발생하는 것을 방지합니다. - 세밀한 결과:
keys[i]가 발견되어 락이 걸렸으면 비트i가 set 된Bitmap을 반환합니다. - 일회성 조회:
query_lookup_and_lock_result는 진행 중이면None을 반환하다가, 완료되면Bitmap을 정확히 한 번 반환합니다. 이후 호출은 다시None. - Unlock 계약:
submit_unlock은 fire-and-forget 입니다. 어댑터는 반드시 결국 성공을 보장해야 합니다 (필요하면 내부적으로 재시도). 호출자는 절대 재시도하지 않습니다.
Load 작업
submit_load_task(keys, objects) -> L2TaskId
query_load_result(task_id) -> Bitmap | None
- 버퍼는 호출자가 제공:
objects리스트는 미리 할당된 L1 write 버퍼를 담습니다. 어댑터는 로드한 데이터를 이 버퍼에 직접 씁니다. - 세밀한 결과:
keys[i]가 성공적으로 로드되었으면 비트i가 set 된Bitmap을 반환합니다. - 일회성 조회: lookup 과 동일한 의미론 —
Bitmap을 정확히 한 번 반환합니다.
Thread Safety
어댑터는 StoreController 스레드와 PrefetchController 스레드의 동시 호출에 안전해야 합니다. 실제로는 store 작업과 lookup/load 작업이 별도의 내부 상태를 사용하므로, 작업별 락이나 락-프리 큐로 구현하면 대체로 어렵지 않습니다.
Task ID 범위
L2TaskId 값은 단일 어댑터 내에서만 고유합니다. 여러 어댑터에 걸쳐 작업을 추적할 때는 복합 키 (adapter_index, task_id) 를 사용하세요.
StoreController
목적: L1 쓰기가 완료된 후 L1 데이터를 비동기로 L2 에 복제합니다.
소스: storage_controllers/store_controller.py
라이프사이클
StorageManager.__init__
→ StoreController(l1_manager, l2_adapters, descriptors, policy)
→ controller.start() # 백그라운드 스레드 시작
...
StorageManager.close()
→ controller.stop() # 스레드 종료, 락 해제
이벤트 기반 루프
StoreController 의 백그라운드 스레드는 다음을 poll 합니다:
- StoreListener eventfd —
finish_write()완료 시 L1Manager 가 발화. 리스너는 L1Manager 에 등록된L1ManagerListener입니다. - 어댑터별 store eventfd — L2 store 작업 완료 시 발화.
데이터 흐름
L1 finish_write()
│
▼ (L1Manager 리스너 콜백, L1 lock 내부 실행 — 논블로킹 필수)
StoreListener.on_l1_keys_write_finished(keys)
│ keys 추가 + eventfd 신호
▼
_store_loop: poll 깨어남
│
▼
_process_new_keys(keys)
│
├─ 1. key 를 shape 별로 그룹화 (현재: (model_name, kv_rank))
│ L1 는 공유 풀이라 한 번의 drain 에 shape/dtype 이 다른 model/parallelism 이
│ 섞일 수 있으며, submit_store_task 는 균일한 (shape, dtype) 을 전제로 합니다.
│
├─ 2. 각 shape 그룹에 대해:
│ StorePolicy.select_store_targets(group_keys, adapters)
│ → dict[adapter_index, list[ObjectKey]]
│
├─ 3. 각 어댑터 타깃에 대해:
│ L1Manager.reserve_read(target_keys) → MemoryObj + read lock 획득
│ adapter.submit_store_task(keys, objs)
│ InFlightStoreTask 로 추적
│
▼ (이후, store_efd 가 시그널된 각 어댑터에 대해)
_drain_l2_store_completions(signaled_adapters)
│ adapter.pop_completed_store_tasks() → 각 InFlightStoreTask 에
│ l2_store_result 성공/실패 기록
│
▼
_advance_request(task_key, task) [상태 전환]
│ l2_store_result 가 아직 None 이면 건너뜀
│
▼
_finalize_store(task_key, task) [최종 처리]
│
├─ 4. L1Manager.finish_read(read_locked_keys) → read lock 해제
│
├─ 5. 성공 시: StorePolicy.select_l1_deletions(keys) → L1 에서 삭제
│ 실패 시: 경고 로그 (best-effort, 재시도 없음)
│
▼
완료. policy 가 삭제하지 않는 한 key 는 L1 에 남습니다.
Lock Invariant
| 단계 | L1 Lock 상태 | L2 Lock 상태 |
|---|---|---|
| store 전 | Unlocked | N/A |
| store 중 | Read-locked | N/A |
| store 후 | Unlocked | N/A |
- store 중 read lock 은 어댑터가 읽는 동안 L1 데이터가 eviction 되는 것을 방지합니다.
- 항상 해제됨:
stop()이_cleanup_in_flight_tasks()를 호출하여 작업이 완료되지 않았더라도 모든 in-flight read lock 을 해제합니다.
StorePolicy
policy 는 두 가지를 결정합니다:
-
select_store_targets(keys, adapters) → dict[int, list[ObjectKey]]어떤 어댑터가 어떤 key 를 받을지. 한 key 가 여러 어댑터에 갈 수 있습니다.DefaultStorePolicy: 모든 key → 모든 어댑터. -
select_l1_deletions(keys) → list[ObjectKey]L2 store 성공 후 L1 에서 evict 할 key.DefaultStorePolicy: 삭제 안 함 (빈 리스트).
policy 는 --l2-store-policy 로 이름으로 선택합니다 (기본값: "default").
새 policy 는 import 시점에 register_store_policy(name, cls) 로 자기 등록하며,
storage_controllers/__init__.py 가 자동으로 발견합니다.
PrefetchController
목적: 서빙 요청이 오기 전에 L2 에서 L1 으로 KV 캐시 데이터를 비동기로 로드합니다.
L1 에 없는 key 에 대해 StorageManager.submit_prefetch_task() 가 호출합니다.
소스: storage_controllers/prefetch_controller.py
라이프사이클
StorageManager.__init__
→ PrefetchController(l1_manager, l2_adapters, descriptors, policy)
→ controller.start() # 백그라운드 스레드 시작
...
StorageManager.close()
→ controller.stop() # 스레드 종료, 모든 락 해제
외부 API (Thread-Safe)
# 서빙 스레드에서 호출
request_id = controller.submit_prefetch_request(keys, layout_desc)
# 서빙 스레드에서 폴링
result = controller.query_prefetch_result(request_id) # int | None
submit_prefetch_request는 요청을 큐에 넣고 eventfd 로 백그라운드 스레드에 신호를 보낸 후 즉시 반환합니다.query_prefetch_result는 진행 중이면None을 반환하다가, prefix hit count 를 정확히 한 번 반환합니다 (pop 의미론).
Prefix 전용 로딩
핵심 invariant: 발견된 key 중 연속된 prefix 만 로드합니다.
L2 에 key {0, 1, 3, 4} 가 있고 key 2 가 없다면, key {0, 1} 만 로드합니다.
인덱스 2 의 gap 이 있으면 vLLM 엔진은 key 3, 4 를 사용할 수 없습니다 (연속된 KV 캐시 prefix 가 필요하기 때문). 이것들을 로드하면 I/O 대역폭과 L1 메모리만 낭비됩니다.
이는 policy 가 raw load plan 을 계산한 후 trim_load_plan_to_prefix() 로 강제합니다.
이벤트 기반 루프
PrefetchController 의 백그라운드 스레드는 다음을 poll 합니다:
- Submission eventfd —
submit_prefetch_request()가 신호. - 어댑터별 lookup eventfd — lookup 작업 완료 시 신호.
- 어댑터별 load eventfd — load 작업 완료 시 신호.
요청 상태 머신
각 요청은 두 단계를 거칩니다:
LOOKUP ──────────────────────────► PLAN_AND_LOAD ──────────► COMPLETED
│ │
│ 모든 어댑터에 lookup_and_lock 제출 │ load plan 계산
│ │ L1 write 버퍼 예약
│ 모든 lookup 완료 대기 │ load 작업 제출
│ │ 모든 load 완료 대기
▼ │ 최종 처리
▼
데이터 흐름
submit_prefetch_request(keys, layout_desc)
│
▼ (크로스 스레드: 제출 큐 + eventfd 신호)
_drain_submission_queue → _pending_queue
│
▼ (max_in_flight 미만인 경우)
_start_lookup_phase(request_id, keys, layout_desc)
│
├─ 모든 어댑터에 lookup_and_lock_task(keys) 제출
│
▼ (모든 어댑터 lookup 완료 대기)
_advance_request(request, signaled_adapters) [LOOKUP 단계]
│ _poll_lookup_results(request, signaled_adapters[LOOKUP])
│ all_lookups_done() 시:
│
▼
_transition_to_load_phase(request)
│
├─ 1. PrefetchPolicy.select_load_plan(keys, lookup_results, adapters)
│ → dict[adapter_index, Bitmap]
│
├─ 2. trim_load_plan_to_prefix()
│ → 연속된 prefix key 만 유지
│
├─ 3. L1Manager.reserve_write(keys, is_temporary=True, mode="new")
│ → L1 write 버퍼 할당
│
├─ 4. 성공적으로 예약된 key 만 남도록 plan 재트리밍
│
├─ 5. Phase 1 unlock: lookup 에서 잠겼지만 load plan 에 없는 L2 key unlock
│
├─ 6. 어댑터별 load_task(keys, objs) 제출
│
▼ (모든 어댑터 load 완료 대기)
_advance_request(request, signaled_adapters) [PLAN_AND_LOAD 단계]
│ _poll_load_results(request, signaled_adapters[PLAN_AND_LOAD])
│ all_loads_done() 시:
│
▼
_finalize_load(request)
│
├─ 7. Phase 2 unlock: load plan 의 모든 L2 key unlock
│
├─ 8. L1Manager.finish_write_and_reserve_read(loaded_keys)
│ → 원자적으로: write unlock + read lock
│
├─ 9. L1Manager.finish_write(failed_keys) + delete(failed_keys)
│ → 부분 실패 정리
│
├─ 10. prefix 이후의 로드된 key 에 대한 read lock 해제
│ (부분 load 실패로 gap 이 생길 수 있음)
│
▼
_complete_request(request_id, prefix_hits)
│ 결과 저장, in-flight 추적에서 제거
Lock Invariant
| 단계 | L1 Lock 상태 | L2 Lock 상태 |
|---|---|---|
| Lookup | 없음 | 발견된 key 는 locked |
| plan 이후 | Write-locked (예약된 버퍼) | plan key locked; 나머지 unlocked (phase 1) |
| load 중 | Write-locked | plan key locked |
| load 후 | Read-locked (prefix key) | 모두 unlocked (phase 2) |
| 최종 처리 후 | Read-locked (prefix 만) | 모두 unlocked |
- L1 write 버퍼는 임시:
is_temporary=True로 필요 시 eviction controller 가 회수 가능하지만, 보통 수명이 짧습니다. - 원자적 write→read 전환:
finish_write_and_reserve_read()는 write 완료와 read lock 획득 사이에 eviction 이 발생하는 window 를 없앱니다. - 최종 처리 후 read lock: prefix key 는 서빙 엔진이 사용할 수 있도록 L1 에 read-locked 상태를 유지합니다. 호출자(
StorageManager) 는 사용 후finish_read_prefetched()로 이를 해제합니다.
L2 Lock 관리
L2 lock 은 lookup 과 load 사이에 어댑터 측 eviction 을 방지합니다. 성공, 실패, 종료 모든 경우에 반드시 해제되어야 합니다.
Phase 1 unlock (_unlock_unneeded_keys): load plan 계산 후, lookup 에서는 잠겼지만 최종 load plan 에 포함되지 않는 key 를 즉시 unlock. 다음 경우에 발생합니다:
- policy 가 해당 key 를 다른 어댑터에 할당.
- prefix 트리밍으로 제외됨.
- L1 write 예약 실패.
Phase 2 unlock (_unlock_all_plan_keys): load 완료 후 (성공/실패 무관) load plan 의 모든 key 를 unlock.
종료 정리 (_cleanup_in_flight_requests): in-flight 요청에 대해 보유한 모든 L1, L2 lock 해제.
PrefetchPolicy
select_load_plan(keys, lookup_results, adapters) → dict[int, Bitmap]
모든 어댑터의 lookup bitmap 을 받아 key 를 어댑터에 중복 없이 할당합니다. 각 key 는 최대 하나의 어댑터 bitmap 에만 나타납니다.
DefaultPrefetchPolicy: 각 key 를 해당 key 를 가진 가장 낮은 인덱스의 어댑터에 할당하는 단순 greedy 방식.
policy 는 --l2-prefetch-policy 로 이름으로 선택합니다 (기본값: "default").
새 policy 는 import 시점에 register_prefetch_policy(name, cls) 로 자기 등록하며,
storage_controllers/__init__.py 가 자동으로 발견합니다.
최대 In-Flight 제한
컨트롤러는 동시 prefetch 요청을 max_in_flight (기본값: 8) 로 제한합니다.
이를 초과하는 요청은 _pending_queue 에 쌓이며, in-flight 요청이 완료될 때 dequeue 됩니다.
참고: 이는 단순 카운트 기반 제한입니다. 향후 개선 방향으로 in-flight 요청의 L1 메모리 사용량에 기반한 동적 admission controller 가 있습니다.
통합: StorageManager
StorageManager (storage_manager.py) 는 모든 것을 연결하는 최상위 진입점입니다.
Prefetch 흐름 (서빙 엔진 관점)
# 1. Submit: L1 먼저 확인, 나머지를 L2 에 위임
handle = sm.submit_prefetch_task(keys, layout_desc)
# 2. Poll: 완료까지 대기
while True:
found_count = sm.query_prefetch_status(handle)
if found_count is not None:
break
# 3. Read: prefetch 된 데이터 접근 (read lock 보유)
with sm.read_prefetched_results(keys[:found_count]) as objs:
# objs 사용 ...
pass
# 4. Release: read lock 해제
sm.finish_read_prefetched(keys[:found_count])
PrefetchHandle
@dataclass(frozen=True)
class PrefetchHandle:
request_id: int # L2 요청이 필요 없으면 -1
l1_prefix_hit_count: int # 이미 L1 에 있는 앞쪽 key 수
total_requested_keys: int
submit_time: float # 지연 시간 로깅용
submit_prefetch_task 는 먼저 L1 에서 연속된 prefix hit 를 확인합니다:
- 모든 key 가 L1 hit:
request_id=-1인 handle 반환 (L2 작업 불필요). - 일부 miss: 나머지 key 를 PrefetchController 에 제출.
query_prefetch_status 는 L1 hit 과 L2 결과를 합산합니다:
total_hits = l1_prefix_hit_count + l2_prefix_hits.
가정 및 Invariant 요약
-
모든 eventfd 는 전역 고유해야 합니다. 모든 어댑터와 모든 작업 유형에 걸쳐 중복이 있으면 poll 기반 dispatch 가 깨집니다.
-
L2 task ID 는 어댑터별, 전역이 아닙니다.
(adapter_index, task_id)를 복합 키로 사용하세요. -
조회 결과는 일회성입니다.
query_lookup_and_lock_result()와query_load_result()는 작업당 non-None 값을 정확히 한 번 반환합니다. -
submit_unlock은 결국 반드시 성공해야 합니다. 컨트롤러는 절대 재시도하지 않습니다. 어댑터가 내부적으로 재시도를 처리해야 합니다. -
Prefix 전용 로딩. L2 에서 발견된 key 의 연속된 prefix 만 로드합니다. Gap 이 있으면 prefix 가 끊깁니다.
-
리스너 콜백은 L1Manager 의 lock 내부에서 실행됩니다.
StoreListener는 논블로킹이어야 합니다 (추가 + eventfd 신호만). L1Manager 메서드를 절대 호출해서는 안 됩니다 (데드락). -
prefetch 의 L1 write 버퍼는 임시입니다. 필요 시 eviction 을 허용하기 위해
is_temporary=True로 할당됩니다. -
원자적 write→read 전환.
finish_write_and_reserve_read()는 prefetch write 완료와 서빙 엔진을 위한 read lock 획득 사이의 eviction 을 방지합니다. -
두 컨트롤러 모두 종료 시 모든 lock 을 해제합니다.
stop()은 완료 상태에 관계없이 항상 in-flight 작업을 정리합니다. -
L2 어댑터는 thread-safe 해야 합니다. StoreController 스레드와 PrefetchController 스레드의 동시 호출이 전제됩니다.
새로운 L2 어댑터 구현하기
순수 Python 어댑터
L2AdapterInterface 를 직접 구현하세요. 인메모리 어댑터의 레퍼런스 구현은 mock_l2_adapter.py 를, 내구성 있는 로컬 디바이스 어댑터는 raw_block.md 를 참고하세요.
기존 파일을 수정할 필요가 없습니다. l2_adapters/ 패키지에 새 모듈(예: my_l2_adapter.py)을 만들고 모듈 레벨에서 자기 등록하면 됩니다:
# 모듈 하단:
register_l2_adapter_type("my_type", MyL2AdapterConfig)
register_l2_adapter_factory("my_type", _create_my_l2_adapter)
__init__.py 는 pkgutil.iter_modules() 로 *_l2_adapter.py 모듈을 모두 자동 발견하지만, lazy import 합니다 — 해당 adapter type 이 실제로 런타임에 요청될 때만 모듈을 로드합니다 (third-party 의존성 보호).
Persist / Recover (선택)
재시작 시 캐시된 데이터를 유지하는 어댑터는 PersistConfig 를 사용합니다 (JSON 키 "persist_enabled" 에서 파싱, 기본값 True). Lookup 은 miss 시 항상 보조 스토리지를 확인합니다. 전용 인터페이스 메서드는 없으며, 어댑터는 기존 close() 경로에 persist 를 통합합니다. 레퍼런스 구현은 nixl_store_dynamic_l2_adapter.py, 설계 세부사항은 nixl_store.md 를 참고하세요.
Native (C++/Rust) 스토리지 백엔드
C++ 또는 Rust 로 작성된 고성능 백엔드는 공유 native connector 프레임워크를 사용하세요. 단일 C++ connector 구현이 비-MP 모드 (ConnectorClientBase 경유) 와 MP 모드 (NativeConnectorL2Adapter 경유) 모두 에서 동작합니다.
전체 가이드: csrc/storage_backends/README.md
NativeConnectorL2Adapter (native_connector_l2_adapter.py) 는 pybind 래핑된 IStorageConnector 를 L2AdapterInterface 로 연결합니다:
- connector 의 단일 eventfd 에서 3개의 Python eventfd 생성
- 백그라운드 demux 스레드로 작업 유형별 완료 라우팅
ObjectKey직렬화 및MemoryObj버퍼 추출 처리- 원격 백엔드를 위한 클라이언트 측 locking (refcount 딕셔너리)
레퍼런스 구현: csrc/storage_backends/redis/ 의 Redis (RESP) connector 가 통합 가이드 5단계를 모두 보여줍니다.
새로운 Store 또는 Prefetch Policy 구현하기
두 policy 모두 이름 기반 레지스트리와 자동 모듈 발견을 사용합니다. 새 policy 를 추가하려면 storage_controllers/ 에 파일 하나만 만들면 됩니다 — 기존 파일을 수정할 필요가 없습니다.
Store Policy
- 새 파일 생성 (예:
storage_controllers/store_policy_tiered.py). StorePolicy를 상속하고select_store_targets()와select_l1_deletions()를 구현합니다.- 모듈 레벨에서
register_store_policy("tiered", TieredStorePolicy)를 호출합니다.
from lmcache.v1.distributed.storage_controllers.store_policy import (
StorePolicy,
AdapterDescriptor,
register_store_policy,
)
from lmcache.v1.distributed.api import ObjectKey
class TieredStorePolicy(StorePolicy):
def select_store_targets(self, keys, adapters):
# 커스텀 로직 ...
...
def select_l1_deletions(self, keys):
return []
register_store_policy("tiered", TieredStorePolicy)
이제 --l2-store-policy tiered 로 사용할 수 있습니다.
Prefetch Policy
동일한 패턴: PrefetchPolicy 를 상속하고, select_load_plan() 을 구현한 후, register_prefetch_policy("name", cls) 를 호출합니다.
발견 메커니즘
storage_controllers/__init__.py 는 pkgutil.iter_modules() 로 import 시점에 패키지의 모든 모듈을 import 합니다. 모듈이 import 되면 모듈 레벨의 register_*_policy() 호출이 해당 policy 를 레지스트리에 등록합니다. --l2-store-policy 와 --l2-prefetch-policy CLI 인자는 레지스트리를 사용해 choices 목록을 구성합니다.