L1 — put_many 락 횟수 4N → 2N 축소
한 줄 요약:
_write_one이 단독으로 잡던_inflight_io_count락 두 번을put_many루프의 기존 락 구간(allocate-lock과 commit-lock)으로 흡수하여 key 1개당 락 획득 4회를 2회로 줄인다.
0. Contribution Rule 요약 (이 PR 작성 시 준수 사항)
근거: CONTRIBUTING.md, AGENTS.md, docs/coding_standards.md, DCO.
0.1 Branch / Base
- 공식 가이드:
dev브랜치 base (CONTRIBUTING.md:22). - 실제 origin 상황:
origin/develop가 최신, 현 PR base 기준은develop로 진행. - 현재 작업 브랜치:
priv/ny/lock_put_many← 이 브랜치에서 commit/PR.
0.2 DCO Sign-off (필수)
- 모든 commit에
Signed-off-by: <Name> <email>trailer 필요. git commit -s또는--signoff사용.- DCO 본문은 DCO v1.1.
0.3 Commit Message 스타일
- 최근 패턴:
[Tag][SubTag] Subject(예:[Perf],[Fix],[MP][Core],[Doc]). - L1 PR 제안 제목:
[Perf][RawBlock] Reduce put_many lock count from 4N to 2N
0.4 Linting (CI에서 검사)
pre-commit run --all-files— 통과 필수.- 구성:
ruff check+ruff format(line-length 88) +isort+mypy+codespell. - 모든 Python 파일 첫 줄:
# SPDX-License-Identifier: Apache-2.0. - Import 헤더:
# Standard / # Third Party / # First Party / # Local.
0.5 Coding Standard (이 변경에 직접 적용되는 항목)
- modified 함수의 docstring 갱신 의무 —
_write_one은 카운터 책임을 호출자에게 넘기므로 docstringNote:항목에 명시 필수.put_many도 카운터 관리 책임을 docstring에 추가. - Type hint: 모든 인자/반환 — 현재 시그니처 미변경이므로 유지만 확인.
assert금지 (validation 용도) — 본 변경은 validation 추가 없음.- private 멤버 외부 접근 금지 —
_write_one은 같은 클래스 내부 호출자만이라 SLF 위반 없음.
0.6 PR Scope
- "PRs must be small and focused" — L1 단독 PR로 분리.
- T1 (delete TOCTOU, private/reviews/9fc5a901-rawblock-delete-toctou.md) 와 L2 (legacy batched put 분해)는 본 PR에 포함하지 않음.
0.7 테스트 규칙
- 위치: 소스 트리 미러링 →
RawBlockCore변경이므로 테스트는 tests/v1/storage_backend/test_rust_raw_block_backend.py. - 같은 파일에 이미
_FakeRawBlockDevice,_install_fake_raw_block_device,_make_raw_block_core,_make_byte_obj헬퍼가 있어 그대로 재사용. - 별도 신규 파일을 만들지 않고 기존 파일에 함수 단위로 추가.
- 테스트는 docstring 계약 기준:
put_many/load_many_into/inflight_io_count/_checkpoint_once의 외부 관찰 가능한 동작만 검증. - 외부 클래스 private 멤버 접근 금지 — RawBlockCore 내부
_inflight_io_count등은inflight_io_count()공개 메서드를 통해 관찰 (이미 core.py:398 에 노출됨).
0.8 Caller Impact 분석 (Coding Standards §6.2 의무)
_write_one호출자:RawBlockCore.put_many단독. 같은 파일 내grep _write_one core.py로 검증. 외부 호출자 없음 → public contract 변경 영향 없음.put_many호출자: storage manager / L2 adapter / legacy backend — 시그니처/리턴 타입 미변경, 외부 관찰 가능 동작 (락 횟수 외) 동일.inflight_io_count()호출자:_checkpoint_once단독. 카운터의 의미는 동일 ("진행 중 put I/O 개수") 하지만 ON window가 약간 길어짐 — §2.2 의 시점별 분석 참조.
1. 변경 배경
1.1 현재 구조 — key 1개당 4회 락
core.py:463-516 (put_many) + core.py:898-938 (_write_one).
key 1개를 처리하는 동안 다음 4개의 임계 구역이 직렬로 발생한다:
per key:
lock#1 put_many : check + allocate slot + inflight 등록
lock#2 _write_one 진입: _inflight_io_count += 1
(lock 밖) : pwrite(header) → pwrite(payload)
lock#3 _write_one 종료: _inflight_io_count -= 1, _last_io_ts = now
lock#4 put_many : inflight.pop, _index 갱신
배치 크기 N = 100이면 락 획득/해제가 400회. Python threading.Lock 무경합
시 ~100ns지만, load_many_into/exists_many/get_metadata_* 등 다른 경로가
같은 lock을 두고 경합하면 μs 단위로 늘어난다.
1.2 lock#2 / lock#3가 분리된 이유
_write_one 입구/출구에서 _inflight_io_count 카운터를 수동 관리하는 구조다.
이 카운터는 단 1곳, core.py:1204
의 _checkpoint_once에서만 읽힌다:
idle_ok = self._inflight_io_count == 0 and (
time.monotonic() - self._last_io_ts
) >= (self.meta_idle_quiet_ms / 1000.0)
즉 "현재 disk I/O 진행 중이 아님 + 일정 시간 idle"일 때만 메타데이터 checkpoint를 쓰기 위한 게이트다.
1.3 다른 곳에서 같은 패턴
load_many_into(core.py:584-634)
은 이미 흡수된 형태로 짜여 있다 — 입구의 lock에서 카운터를 한 번만 += 하고
finally에서 한 번만 -= 한다. _write_one만 per-call 카운터 관리를 그대로
유지하고 있어 일관성이 깨진 상태.
2. 검토 내용
2.1 두 가지 접근 (해석 A vs B)
원본 노트의 pseudocode는 두 가지로 읽힐 수 있어, 각각의 영향을 따로 평가했다.
| 항목 | 해석 A: per-key 흡수 (interleaved) | 해석 B: 진짜 배치 |
|---|---|---|
| 구조 | 루프 유지, lock#1에 += 1 / lock#4에 -= 1 흡수 | 전부 allocate → 전부 I/O → 전부 commit |
| 락 횟수 | 4N → 2N (목표 달성) | 4N → 2 (더 큼) |
| 카운터 ON window | per-key 약간 길어짐 | 배치 전체 동안 N |
_last_io_ts 갱신 | 키마다 갱신 (현재와 동일) | 배치 끝에서만 갱신 |
| idle 판단 영향 | 거의 없음 | "배치 도중 last_io_ts stale" caveat 발동 |
| 실패 처리 시맨틱 | 키별 독립 (현재 보존) | 정책 재정의 필요 |
| 슬롯 할당 실패 시 영향 | 키 단위 free 복귀 | N개 동시 free 복귀 → free_slots 헤딩 |
| 다른 lock holder와 경합 | 거의 동일 | lock#4가 N배 길어져 exists_many 등 대기 ↑ |
→ 해석 A 채택. 노트의 caveat "배치 I/O 중간에 inflight_io_count가 실제 진행 상황을 반영하지 않음"은 해석 B에서만 발생하는 문제이며, 해석 A에서는 caveat 자체가 해소된다.
2.2 해석 A의 idle 판단 안전성 분석
흡수 후 카운터의 ON window는 약간 커진다 (현재: I/O 직전 → 직후, 변경 후: allocate 직후 → commit 직전). 모든 시점에서 idle_ok에 끼치는 영향:
| 시점 | 현재 카운터 | 변경 후 카운터 | 변경 후 last_io_ts | idle_ok |
|---|---|---|---|---|
| put_many 진입 직전 | 0 | 0 | 이전 값 | 시간 조건만 |
| allocate 후, I/O 전 | 0 | 1 (이전엔 0) | 이전 값 | False (보수적) |
| I/O 진행 중 | 1 | 1 | 이전 값 | False |
| I/O 직후, commit 전 | 0 (이전엔 0) | 1 | 이전 값 | False (보수적) |
| commit 후 | 0 | 0 | now | 시간 조건만 |
결론: 모든 변화는 "idle 아님" 방향으로만 작용 → checkpoint가 더 늦게 또는 동일하게 발생할 뿐, 더 일찍 발생하지는 않는다. 데이터 손실 위험을 키우지 않는다. 오히려 현재의 "allocate 후 lock#2 이전" 구간이 카운터에 안 잡히던 미세한 버그가 함께 해소된다.
2.3 예외 안전성
변경 후엔 put_many가 카운터의 += 와 -= 를 함께 책임지므로 try/finally가
필수다. 현재 _write_one은 내부에서 모든 예외를 catch하지만, 호출자
(put_many)에서 _prepare_write_payload 등 prep 단계가 throw할 경우를 위해
finally 블록이 -= 를 보장해야 한다.
2.4 동시성
put_many는 storage manager가 이미 직렬화하여 호출 — 동시에 여러 worker가
같은 RawBlockCore에 들어오는 시나리오는 현재 워커 풀 구조상 발생하지 않지만,
설령 발생해도 카운터는 일반 정수 += / -=로 lock 안에서 처리되므로 thread-safe.
2.5 검토에서 배제한 항목
- batch I/O로 묶기 (해석 B): pwrite_batch Rust API가 추가되면 가치 있음. 현재는 별도 PR(L3 / P3)로 분리.
_last_io_ts갱신 시점 변경: 현재 갱신 위치(I/O 종료 직후)가 가장 정확. 흡수해도 commit 직전에 갱신하므로 의미 동일.- Lock 분리 (per-region lock): D3 항목으로 별도 검토. L1과 직교.
3. 효과
3.1 정량 효과 (예상)
| 배치 크기 N | 현재 락 횟수 | 변경 후 | 감소율 |
|---|---|---|---|
| 1 | 4 | 2 | 50% |
| 10 | 40 | 20 | 50% |
| 100 | 400 | 200 | 50% |
무경합 환경: 락 1회 ~100ns × 200회 절감 = N=100 배치당 ~20μs 절감. 경합 환경: 다른 thread 대기 감소 효과가 더 큼 (정량 측정 대상).
3.2 정성 효과
_write_one의 책임 범위 축소 → 단일 책임에 가까워짐 (I/O만 수행)load_many_into와put_many의 카운터 관리 패턴 통일- "lock#2 직전 prep 단계가 카운터에 안 잡히는" 미세 격차 해소
3.3 비효과 (영향 없음을 명시)
- checkpoint 빈도 (동일 또는 약간 보수적)
- delete/exists/load 등 다른 경로 — 미변경
- public API 시그니처 — 미변경
- on-disk format — 미변경
4. 변경점 (계획)
4.1 put_many (lmcache/v1/storage_backend/raw_block/core.py)
# 현재 (의사코드)
for i, (key, obj) in enumerate(zip(keys, objs)):
if self._closed: break
with self._lock: # lock#1
# check + allocate + inflight 등록
...
success = self._write_one(key, obj, offset) # 내부에서 lock#2/#3
with self._lock: # lock#4
# inflight.pop + index 갱신
...
# 변경 후 (의사코드)
for i, (key, obj) in enumerate(zip(keys, objs)):
if self._closed: break
with self._lock: # lock#1 (확장)
# check + allocate + inflight 등록
self._inflight_io_count += 1
...
try:
success = self._write_one(key, obj, offset) # 내부 lock 제거됨
finally:
with self._lock: # lock#4 (확장)
self._inflight_io_count -= 1
self._last_io_ts = time.monotonic()
# inflight.pop + index 갱신
...
4.2 _write_one
with self._lock: self._inflight_io_count += 1제거 (core.py:915-916)finally: with self._lock: self._inflight_io_count -= 1; self._last_io_ts = ...제거 (core.py:931-934)- 본문은 header/payload pwrite만 남음 — 순수 I/O 함수
- docstring에 "caller is responsible for inflight_io_count accounting" 명시
4.3 load_many_into
- 변경 없음 (이미 흡수된 형태). 일관성 비교용 reference로 유지.
4.4 테스트
-
위치: tests/v1/storage_backend/test_rust_raw_block_backend.py.
-
기존 헬퍼 (
_install_fake_raw_block_device,_make_raw_block_core,_make_byte_obj) 그대로 재사용. 별도 conftest 추가 없음. -
추가할 테스트 (기존 파일 끝에 함수 단위로 append):
-
test_put_many_lock_acquisitions_are_two_per_key공개 인터페이스 기준:core._lock을 wrapping한CountingLock으로 swap한 뒤put_many(N개)호출. acquire 횟수가 정확히2 * N인지 검증.- 외부 클래스 private 접근으로 보일 수 있으나,
_lock은 RawBlockCore 자기 자신이 소유한 멤버이고 테스트는 동일 모듈에서 동작 —_FakeRawBlockDevicemonkeypatch와 동일 수준의 internal hook. 정당화 코멘트 추가.
- 외부 클래스 private 접근으로 보일 수 있으나,
-
test_put_many_releases_inflight_count_on_write_failure_FakeRawBlockDevice.pwrite_from_buffer가 예외를 throw하도록 monkeypatch.put_many후core.inflight_io_count() == 0인지 검증 (공개 메서드 사용). 변경 전에도 통과해야 하지만, 변경 후 try/finally 누락 회귀 방지용. -
test_inflight_io_count_visible_during_concurrent_putpwrite_from_buffer가threading.Event대기로 블록되도록 monkeypatch. 별도 thread에서put_many([key])호출 → main thread에서core.inflight_io_count() >= 1관찰 → Event set → 결과 확인 → 카운터 0 복귀. "I/O 진행 중에는 카운터가 양수" 라는 docstring 계약 검증. -
test_checkpoint_idle_blocked_during_put_manymeta_idle_quiet_ms=0,meta_enable_periodic=False로 core 생성. I/O 블록 mock 상태에서 put 진행 중core._checkpoint_once(force=False)가 False 반환 (idle 아님으로 판단)을 확인. put 종료 후엔 dirty 가 있으므로 True 반환 가능 — 이 부분은 test 4의 reference만 명시하고 별도 assertion은 안 둠._checkpoint_once도 private이지만 모듈 내부 동작 확인용 — 정당화 코멘트.
-
-
기존 put_many 정합성 테스트 (
test_raw_block_core_non_odirect_*,test_raw_block_core_*등 동일 파일 내) 모두 그대로 통과해야 함 — 회귀 방지.
4.4.1 테스트에서 private 멤버 접근 정당화
docs/coding_standards.md §5.2 는 "공개 인터페이스를 통해 검증" 을 원칙으로
하지만, 락 획득 횟수와 같이 외부에서 직접 관찰할 수 없는 동작 특성 검증은
예외적으로 허용. _lock 와 _checkpoint_once 접근 시 테스트 함수 docstring 에
"checks lock-acquisition contract that has no public observable" 명시 후 사용.
5. 개선 포인트 확인 방법 (수치 증명)
노트의 "수치 증명 ✅" 기준을 만족시키기 위한 측정 절차.
5.1 무경합 락 횟수 측정
# tests/perf/test_l1_lock_count.py 형태로
import threading
class CountingLock:
def __init__(self):
self._lock = threading.Lock()
self.acquire_count = 0
def __enter__(self):
self.acquire_count += 1
return self._lock.__enter__()
def __exit__(self, *a):
return self._lock.__exit__(*a)
# core._lock = CountingLock() 으로 swap
# put_many(keys=[...100개], objs=[...100개])
# assert lock.acquire_count == 200 (변경 후) / 400 (변경 전)
5.2 경합 시 latency 측정
# 변경 전/후 각각:
# - put_many(100개) 와 동시에 별도 thread에서 exists_many 1000회 호출
# - perf_counter로 exists_many 평균/p99 latency 비교
기대치: exists_many p99 latency가 변경 후 측정 가능한 수준으로 감소 (정확한 수치는 측정 후 기록).
5.3 checkpoint 빈도 동일성 확인
# meta_idle_quiet_ms를 짧게(예: 10ms) 설정하고
# 일정 워크로드 (put 100 → idle 100ms → put 100 → ...) 반복
# 변경 전/후 _checkpoint_once가 호출된 횟수를 비교
# expected: 동일 또는 변경 후 ≤ 변경 전
5.4 통합 테스트
- 기존
pytest tests/v1/storage_backend/raw_block/전체 통과. pytest tests/v1/distributed/l2_adapters/test_raw_block_l2_adapter.py통과.- legacy path 회귀 방지를 위해
tests/v1/storage_backend/test_rust_raw_block_backend.py통과.
6. 위험 / 롤백
6.1 위험 항목
| 위험 | 가능성 | 영향 | 완화 |
|---|---|---|---|
| try/finally 누락으로 카운터 누수 | 낮음 | checkpoint 영구 차단 | 테스트 5.1에서 예외 케이스 검증 |
_write_one docstring 갱신 누락 | 중간 | 외부 구현이 카운터 직접 관리 시도 | 코딩 표준 docstring 의무 + 리뷰 |
예상치 못한 _write_one 외부 호출자 | 낮음 | 카운터 정합 깨짐 | grep으로 사전 확인 (현재 호출자: put_many 단독) |
| benchmark 환경에서 효과 미관측 | 중간 | 가치 입증 실패 | 경합 시나리오 측정 5.2 필수 |
6.2 롤백
변경이 단일 파일/단일 함수 쌍에 국한되므로 PR 단위 revert로 충분. on-disk format/API 미변경이라 운영 중 롤백 시 추가 마이그레이션 불필요.
7. 변경 로그
| 일자 | 작성자 | 내용 |
|---|---|---|
| 2026-05-28 | ny | 초안 작성. 해석 A 채택 결정. 측정 절차 명시. 구현 미착수. |
| 2026-05-28 | ny | §0 (Contribution Rule 요약) 추가. 테스트 위치를 tests/v1/storage_backend/test_rust_raw_block_backend.py 로 확정. base branch develop 으로 명시. |
| 2026-05-28 | ny | TDD red 단계 — 4 테스트 추가. baseline 실행 결과 1 failed, 3 passed (예상대로). §8 (추가 이슈), §9.1 (변경 전 측정) 작성. 코드 변경은 다음 단계. |
| 2026-05-28 | ny | TDD green 단계 — core.py 의 put_many 와 _write_one 변경 적용. _write_one 에서 카운터 lock 제거, put_many 의 allocate-lock/commit-lock 에 흡수. try/finally 로 예외 안전 보장. docstring 갱신. 4 테스트 PASS, 동일 파일 전체 회귀 없음 (9 passed, 26 skipped). ruff check/format 통과. §9.2-9.5 결과 작성. status: implemented. |
| 2026-05-29 | ny | /code-review --xhigh 수행. 11 findings (CONFIRMED 5 / PLAUSIBLE 6). 결과를 private/reviews/cr-5a27732f-rawblock-put-many-lock-coalesce.md 에 별도 정리, 본 문서에는 §11 cross-link 만 추가. 본 PR 내 보강 권고 4건 (F1/F3/F2/F5) 과 후속 PR 후보 4건 (F4/F6/F7/F8) 식별. F1 (_last_io_ts 회귀) 가 §3.3 주장을 부분 override. |
8. 추가 이슈 / 후속 작업 (구현 중 발견)
8.1 테스트 환경 셋업
- 작업 머신에
pytest미설치 →uv venv --python 3.12 .venv로 가상환경 생성 후--system-certs플래그로pytest pytest-asyncio torch설치, 그리고NO_NATIVE_EXT=1 uv pip install --system-certs -e . --no-build-isolation로 lmcache editable 설치. - CUDA 12.9 (system) ↔ torch 13.0 (default install)이 맞지 않아 native ext 빌드
실패 →
NO_NATIVE_EXT=1로 회피. 본 PR은 native ext 미사용이라 OK. - 활성화 명령:
source .venv/bin/activate.
8.2 fake device 용량 한계
- 기존 헬퍼
_make_raw_block_core가 64 KiB device + 16 KiB metadata + 8 KiB slot → 6 slot 만 사용 가능. 락 횟수 테스트 N=8 으로 작성했더니 마지막 2개가 "no free slot available" 로 fail. - N=4 로 줄여 슬롯 한계 회피. 락 횟수 측정 자체에는 N 값이 영향 없음 (per-key invariant).
- 후속 정리 후보: 헬퍼에
slots=N옵션을 받아 device 크기를 키울 수 있게 하면 이런 트랩을 줄일 수 있지만, 본 PR scope 외 — 별도 cleanup PR 후보.
9. 측정 결과
9.1 변경 전 baseline (TDD red 단계)
명령:
source .venv/bin/activate
python3 -m pytest -v \
tests/v1/storage_backend/test_rust_raw_block_backend.py::test_put_many_acquires_two_locks_per_key \
tests/v1/storage_backend/test_rust_raw_block_backend.py::test_put_many_releases_inflight_count_on_write_failure \
tests/v1/storage_backend/test_rust_raw_block_backend.py::test_inflight_io_count_visible_during_concurrent_put \
tests/v1/storage_backend/test_rust_raw_block_backend.py::test_checkpoint_idle_gate_blocks_during_put_many
결과:
| 테스트 | 변경 전 (현재 코드) | 의도 |
|---|---|---|
test_put_many_acquires_two_locks_per_key | FAIL (16 vs 기대 8 = 4N vs 2N) | L1 적용 후 PASS — 락 횟수 계약 |
test_put_many_releases_inflight_count_on_write_failure | PASS | 회귀 방지 — 예외 안전 |
test_inflight_io_count_visible_during_concurrent_put | PASS | 회귀 방지 — 외부 가시성 |
test_checkpoint_idle_gate_blocks_during_put_many | PASS | 회귀 방지 — idle gate |
요약: 1 failed, 3 passed. 첫 테스트의 실제 측정값 16은 N=4 일 때 4N=16 과
정확히 일치 → 현재 코드의 lock 구조가 실제로 4N 임을 코드 추적 외에도 측정으로
재확인.
9.2 변경 후 결과 (TDD green 단계)
같은 4 테스트 재실행:
| 테스트 | 변경 후 | 비고 |
|---|---|---|
test_put_many_acquires_two_locks_per_key | PASS | 2N 계약 충족 (N=4 → 8 acquire) |
test_put_many_releases_inflight_count_on_write_failure | PASS | 예외 안전 유지 |
test_inflight_io_count_visible_during_concurrent_put | PASS | I/O 도중 카운터 ≥1 가시성 |
test_checkpoint_idle_gate_blocks_during_put_many | PASS | idle gate 막힘 유지 |
4 passed.
9.3 회귀 테스트 — raw_block 관련 전체
source .venv/bin/activate
python3 -m pytest -v \
tests/v1/storage_backend/test_rust_raw_block_backend.py \
tests/v1/storage_backend/test_raw_block_key_codec.py
결과: 9 passed, 26 skipped. skip 26개는 모두 lmcache_rust_raw_block_io
extension 미설치로 인한 @pytest.mark.skipif(not _has_ext(), ...) —
환경 의존이며 변경과 무관. CI 머신/extension 빌드 환경에서는 추가로 실행
필요 (별도 환경에서 검증 권장).
9.4 lint / type check
source .venv/bin/activate
ruff check lmcache/v1/storage_backend/raw_block/core.py \
tests/v1/storage_backend/test_rust_raw_block_backend.py
# All checks passed!
ruff format --check lmcache/v1/storage_backend/raw_block/core.py \
tests/v1/storage_backend/test_rust_raw_block_backend.py
# 2 files already formatted
mypy / codespell / pre-commit 전체는 commit 직전 추가 실행 예정.
9.5 변경 요약 — 측정으로 확인된 사실
- 변경 전 N=4 put_many → lock acquire 16회 (= 4N). 기존 코드 추적 결과
(
put_manylock×2 +_write_onelock×2)와 정확히 일치. 첫 테스트의 red 결과로 정량 baseline 확보. - 변경 후 N=4 → lock acquire 8회 (= 2N). 50% 절감 검증.
- checkpoint idle gate / 예외 안전 / 외부 카운터 가시성은 변경 전후 모두 PASS — L1 의 caveat ("checkpoint 빈도 영향")가 실제로 발생하지 않음을 측정으로 재확인.
11. 코드 리뷰 결과
private/reviews/cr-5a27732f-rawblock-put-many-lock-coalesce.md 에 별도 정리. 요약:
- 11 findings (CONFIRMED 5 / PLAUSIBLE 6).
- 본 PR 내 보강 권고: F1
_last_io_ts회귀 (high), F3+F11inflight_io_count()docstring (medium), F2 테스트 SLF 정당화 코멘트 (medium), F5_CountingLock.__enter__1줄 (low). 선택: F9 docstring 스타일, F10release.set()중복. - 후속 PR 후보: F4 (
_read_slot_header통합), F6 (zip(strict=True)), F7 (load/put 카운터 의미 분기), F8 (fake device 헬퍼 보강). - §3.3 의 "checkpoint 빈도 비효과" 주장은 F1 한정으로 부분 false — F1 픽스 후 §3.3 와 일치. §6.2 롤백 정책은 변함 없이 유효.