본문으로 건너뛰기

[feast] Feast Redis 온라인 스토어 Protobuf 파싱 최적화

PR 링크: feast-dev/feast#6023 상태: Merged | 변경: +138 / -17

들어가며

Feast는 ML feature store로, 온라인 서빙 시 Redis에서 feature 값을 읽어 Protobuf로 역직렬화합니다. 이 과정에서 Redis가 반환하는 바이너리 데이터를 ParseFromString()으로 파싱하는데, 기존 코드에는 불필요한 bytes() 변환이 포함되어 있었습니다.

이 PR은 세 가지 최적화를 수행합니다: 불필요한 타입 변환 제거, 리스트 컴프리헨션으로의 리팩토링, 그리고 불필요한 else 절 제거입니다.

핵심 코드 분석

1. 불필요한 bytes() 변환 제거

Before:

def _get_features_for_entity(self, values, feature_view, requested_features):
    # ...
    res_val = dict(zip(requested_features, values))

    res_ts = Timestamp()
    ts_val = res_val.pop(f"_ts:{feature_view}")
    if ts_val:
        res_ts.ParseFromString(bytes(ts_val))

    res = {}
    for feature_name, val_bin in res_val.items():
        val = ValueProto()
        if val_bin:
            val.ParseFromString(bytes(val_bin))
        res[feature_name] = val

After:

def _get_features_for_entity(self, values, feature_view, requested_features):
    # ...
    res_val = dict(zip(requested_features, values))

    res_ts = Timestamp()
    ts_key = f"_ts:{feature_view}"
    ts_val = res_val.pop(ts_key)
    if ts_val:
        res_ts.ParseFromString(ts_val)

    res: Dict[str, ValueProto] = {}
    for feature_name, val_bin in res_val.items():
        val = ValueProto()
        if val_bin:
            val.ParseFromString(val_bin)
        res[feature_name] = val

bytes(ts_val)bytes(val_bin) 호출이 제거되었습니다. Redis 클라이언트는 이미 bytes 또는 memoryview 객체를 반환하는데, Protobuf의 ParseFromString()은 두 타입 모두 직접 처리할 수 있습니다. bytes() 호출은 memoryview인 경우 전체 데이터의 복사본을 생성하므로, 대량의 feature를 처리할 때 불필요한 메모리 할당과 복사가 발생합니다.

2. 리스트 컴프리헨션으로 간소화

Before:

def _convert_redis_values_to_protobuf(
    self, redis_values, feature_view, requested_features,
):
    result: List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]] = []
    for values in redis_values:
        features = self._get_features_for_entity(
            values, feature_view, requested_features
        )
        result.append(features)
    return result

After:

def _convert_redis_values_to_protobuf(
    self, redis_values, feature_view, requested_features,
) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:
    return [
        self._get_features_for_entity(values, feature_view, requested_features)
        for values in redis_values
    ]

명시적 루프 + append가 리스트 컴프리헨션으로 교체되었습니다. CPython에서 리스트 컴프리헨션은 전용 바이트코드(LIST_APPEND)를 사용하여 일반 append() 메서드 호출보다 빠릅니다. 또한 반환 타입 어노테이션이 메서드 시그니처로 이동하여 코드 가독성이 향상되었습니다.

3. 불필요한 else 절 제거

Before:

if not res:
    return None, None
else:
    # reconstruct full timestamp including nanos
    total_seconds = res_ts.seconds + res_ts.nanos / 1_000_000_000.0
    timestamp = datetime.fromtimestamp(total_seconds, tz=timezone.utc)
    return timestamp, res

After:

if not res:
    return None, None

total_seconds = res_ts.seconds + res_ts.nanos / 1_000_000_000.0
timestamp = datetime.fromtimestamp(total_seconds, tz=timezone.utc)
return timestamp, res

early return 패턴 적용으로 불필요한 else 절과 들여쓰기 수준이 제거되었습니다. 기능적 변화는 없지만 코드 가독성이 향상됩니다.

4. 테스트 코드 추가

PR에는 memoryview 입력 처리, None 값 처리, 다중 엔티티 배치 변환 등을 검증하는 테스트가 추가되었습니다.

def test_get_features_for_entity_with_memoryview(
    redis_online_store: RedisOnlineStore, feature_view
):
    """Redis may return memoryview objects instead of bytes in some cases."""
    values = [
        memoryview(val1_bytes),
        memoryview(val2_bytes),
        memoryview(ts_bytes),
    ]

    timestamp, features = redis_online_store._get_features_for_entity(
        values=values,
        feature_view=feature_view.name,
        requested_features=requested_features,
    )
    assert features["feature_view_1:feature_10"].int32_val == 100

이 테스트는 bytes() 변환 제거 후에도 memoryview 입력이 정상 처리됨을 보장합니다.

왜 이게 좋은가

온라인 feature 서빙은 ML 추론 파이프라인의 핫 패스(hot path)입니다. 요청당 수십~수백 개의 feature를 조회하므로, feature 하나당 불필요한 bytes() 복사가 누적되면 레이턴시에 영향을 줍니다.

  • 메모리 할당 감소: memoryviewbytes 변환 시 발생하는 복사 제거
  • GC 압력 감소: 임시 bytes 객체가 생성되지 않아 가비지 컬렉션 부담 감소
  • 코드 단순화: 타입 어노테이션 추가, early return 패턴으로 유지보수성 향상

정리

  • 불필요한 타입 변환을 의심하라: bytes(), str(), list() 같은 변환이 정말 필요한지 확인하십시오. 다운스트림 API가 이미 해당 타입을 지원할 수 있습니다.
  • memoryview는 zero-copy의 핵심이다: bytes(memoryview_obj)는 복사를 강제합니다. Protobuf, NumPy 등 많은 라이브러리가 memoryview를 직접 처리할 수 있습니다.
  • 핫 패스의 마이크로 최적화는 유의미하다: 요청당 수백 번 호출되는 함수에서의 작은 개선은 전체 레이턴시에 측정 가능한 차이를 만듭니다.

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글