본문으로 건너뛰기

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은 카운터 책임을 호출자에게 넘기므로 docstring Note: 항목에 명시 필수. put_many도 카운터 관리 책임을 docstring에 추가.
  • Type hint: 모든 인자/반환 — 현재 시그니처 미변경이므로 유지만 확인.
  • assert 금지 (validation 용도) — 본 변경은 validation 추가 없음.
  • private 멤버 외부 접근 금지 — _write_one은 같은 클래스 내부 호출자만이라 SLF 위반 없음.

0.6 PR Scope

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 windowper-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_tsidle_ok
put_many 진입 직전00이전 값시간 조건만
allocate 후, I/O 전01 (이전엔 0)이전 값False (보수적)
I/O 진행 중11이전 값False
I/O 직후, commit 전0 (이전엔 0)1이전 값False (보수적)
commit 후00now시간 조건만

결론: 모든 변화는 "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현재 락 횟수변경 후감소율
14250%
10402050%
10040020050%

무경합 환경: 락 1회 ~100ns × 200회 절감 = N=100 배치당 ~20μs 절감. 경합 환경: 다른 thread 대기 감소 효과가 더 큼 (정량 측정 대상).

3.2 정성 효과

  • _write_one의 책임 범위 축소 → 단일 책임에 가까워짐 (I/O만 수행)
  • load_many_intoput_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):

    1. test_put_many_lock_acquisitions_are_two_per_key 공개 인터페이스 기준: core._lock을 wrapping한 CountingLock으로 swap한 뒤 put_many(N개) 호출. acquire 횟수가 정확히 2 * N인지 검증.

      • 외부 클래스 private 접근으로 보일 수 있으나, _lock은 RawBlockCore 자기 자신이 소유한 멤버이고 테스트는 동일 모듈에서 동작 — _FakeRawBlockDevice monkeypatch와 동일 수준의 internal hook. 정당화 코멘트 추가.
    2. test_put_many_releases_inflight_count_on_write_failure _FakeRawBlockDevice.pwrite_from_buffer가 예외를 throw하도록 monkeypatch. put_manycore.inflight_io_count() == 0 인지 검증 (공개 메서드 사용). 변경 전에도 통과해야 하지만, 변경 후 try/finally 누락 회귀 방지용.

    3. test_inflight_io_count_visible_during_concurrent_put pwrite_from_bufferthreading.Event 대기로 블록되도록 monkeypatch. 별도 thread에서 put_many([key]) 호출 → main thread에서 core.inflight_io_count() >= 1 관찰 → Event set → 결과 확인 → 카운터 0 복귀. "I/O 진행 중에는 카운터가 양수" 라는 docstring 계약 검증.

    4. test_checkpoint_idle_blocked_during_put_many meta_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-28ny초안 작성. 해석 A 채택 결정. 측정 절차 명시. 구현 미착수.
2026-05-28ny§0 (Contribution Rule 요약) 추가. 테스트 위치를 tests/v1/storage_backend/test_rust_raw_block_backend.py 로 확정. base branch develop 으로 명시.
2026-05-28nyTDD red 단계 — 4 테스트 추가. baseline 실행 결과 1 failed, 3 passed (예상대로). §8 (추가 이슈), §9.1 (변경 전 측정) 작성. 코드 변경은 다음 단계.
2026-05-28nyTDD green 단계 — core.pyput_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-29ny/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_keyFAIL (16 vs 기대 8 = 4N vs 2N)L1 적용 후 PASS — 락 횟수 계약
test_put_many_releases_inflight_count_on_write_failurePASS회귀 방지 — 예외 안전
test_inflight_io_count_visible_during_concurrent_putPASS회귀 방지 — 외부 가시성
test_checkpoint_idle_gate_blocks_during_put_manyPASS회귀 방지 — 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_keyPASS2N 계약 충족 (N=4 → 8 acquire)
test_put_many_releases_inflight_count_on_write_failurePASS예외 안전 유지
test_inflight_io_count_visible_during_concurrent_putPASSI/O 도중 카운터 ≥1 가시성
test_checkpoint_idle_gate_blocks_during_put_manyPASSidle 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 변경 요약 — 측정으로 확인된 사실

  1. 변경 전 N=4 put_many → lock acquire 16회 (= 4N). 기존 코드 추적 결과 (put_many lock×2 + _write_one lock×2)와 정확히 일치. 첫 테스트의 red 결과로 정량 baseline 확보.
  2. 변경 후 N=4 → lock acquire 8회 (= 2N). 50% 절감 검증.
  3. 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+F11 inflight_io_count() docstring (medium), F2 테스트 SLF 정당화 코멘트 (medium), F5 _CountingLock.__enter__ 1줄 (low). 선택: F9 docstring 스타일, F10 release.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 롤백 정책은 변함 없이 유효.