본문으로 건너뛰기

[feast] Feast 성능 최적화: Timestamp 변환 비용 절감으로 온라인 피처 서빙 가속화

PR 링크: feast-dev/feast#6003 상태: Merged | 변경: +181 / -11

Feast 성능 최적화: Timestamp 변환 비용 절감으로 온라인 피처 서빙 가속화

들어가며

Feast는 머신러닝 모델에 필요한 피처(feature)를 효율적으로 관리하고 서빙하는 오픈소스 피처 스토어입니다. 특히 온라인 서빙(Online Serving)은 모델 추론 시 실시간으로 피처를 제공해야 하므로, 지연 시간(latency)이 매우 중요합니다. 이번 PR은 Feast의 핵심 온라인 서빙 경로 중 하나인 _convert_rows_to_protobuf 함수의 성능을 최적화하여, 대규모 피처 요청에 대한 응답 시간을 크게 단축했습니다.

기존 구현에서는 여러 피처를 요청할 때 각 피처마다 동일한 엔티티(entity)의 타임스탬프를 반복적으로 datetime 객체에서 Timestamp Protobuf 객체로 변환하는 비효율적인 과정이 있었습니다. 이로 인해 요청하는 피처의 수가 많아질수록 Timestamp 변환 오버헤드가 선형적으로 증가하여 전체 응답 시간에 병목 현상을 일으켰습니다. 이 PR은 이러한 중복 변환을 제거하여 O(features * entities) 복잡도를 O(entities)로 개선하는 것을 목표로 합니다.

코드 분석: Timestamp 변환 최적화

핵심 변경사항은 sdk/python/feast/utils.py 파일의 _convert_rows_to_protobuf 함수에 있습니다.

sdk/python/feast/utils.py

Before:

 def _convert_rows_to_protobuf(
     requested_features: List[str],
     read_rows: List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]],
 ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[ValueProto]]]:
     # Pre-calculate the length to avoid repeated calculations
     n_rows = len(read_rows)
 
     # Create single instances of commonly used values
     null_value = ValueProto()
     null_status = FieldStatus.NOT_FOUND
     null_timestamp = Timestamp()
     present_status = FieldStatus.PRESENT
 
     requested_features_vectors = []
     for feature_name in requested_features:
         ts_vector = [null_timestamp] * n_rows
         status_vector = [null_status] * n_rows
         value_vector = [null_value] * n_rows
         for idx, read_row in enumerate(read_rows):
             row_ts_proto = Timestamp()
             row_ts, feature_data = read_row
             # TODO (Ly): reuse whatever timestamp if row_ts is None?
             if row_ts is not None:
                 row_ts_proto.FromDatetime(row_ts)
             ts_vector[idx] = row_ts_proto
             if (feature_data is not None) and (feature_name in feature_data):
                 status_vector[idx] = present_status
                 value_vector[idx] = feature_data[feature_name]
         requested_features_vectors.append((ts_vector, status_vector, value_vector))
 
     return requested_features_vectors

After:

 def _convert_rows_to_protobuf(
     requested_features: List[str],
     read_rows: List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]],
 ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[ValueProto]]]:
     # Pre-calculate the length to avoid repeated calculations
     n_rows = len(read_rows)
 
     # Create single instances of commonly used values
     null_value = ValueProto()
     null_status = FieldStatus.NOT_FOUND
-    null_timestamp = Timestamp()
     present_status = FieldStatus.PRESENT
 
+    # Pre-compute timestamps once per entity (not per feature)
+    # This reduces O(features * entities) to O(entities) for timestamp conversion
+    row_timestamps = []
+    for row_ts, _ in read_rows:
+        ts_proto = Timestamp()
+        if row_ts is not None:
+            ts_proto.FromDatetime(row_ts)
+        row_timestamps.append(ts_proto)
+
     requested_features_vectors = []
     for feature_name in requested_features:
-        ts_vector = [null_timestamp] * n_rows
+        ts_vector = list(row_timestamps)  # Shallow copy of pre-computed timestamps
         status_vector = [null_status] * n_rows
         value_vector = [null_value] * n_rows
-        for idx, read_row in enumerate(read_rows):
-            row_ts_proto = Timestamp()
-            row_ts, feature_data = read_row
-            # TODO (Ly): reuse whatever timestamp if row_ts is None?
-            if row_ts is not None:
-                row_ts_proto.FromDatetime(row_ts)
-            ts_vector[idx] = row_ts_proto
+        for idx, (_, feature_data) in enumerate(read_rows):
             if (feature_data is not None) and (feature_name in feature_data):
                 status_vector[idx] = present_status
                 value_vector[idx] = feature_data[feature_name]
         requested_features_vectors.append((ts_vector, status_vector, value_vector))
 
     return requested_features_vectors

무엇이 왜 좋은 최적화인가?

이 변경의 핵심은 Timestamp Protobuf 객체 변환 로직을 requested_features 루프 밖으로 이동시킨 것입니다.

  1. row_timestamps 리스트 도입: read_rows를 한 번만 순회하면서 각 엔티티의 datetime 객체를 Timestamp Protobuf 객체로 변환하여 row_timestamps 리스트에 저장합니다. 이 과정은 O(entities)의 시간 복잡도를 가집니다.

    row_timestamps = []
    for row_ts, _ in read_rows:
        ts_proto = Timestamp()
        if row_ts is not None:
            ts_proto.FromDatetime(row_ts)
        row_timestamps.append(ts_proto)
    
  2. ts_vector 초기화 변경: 각 feature_name에 대한 루프 내부에서 ts_vector를 초기화할 때, 더 이상 null_timestamp로 채우지 않고, 미리 계산된 row_timestamps를 얕은 복사(shallow copy)하여 사용합니다.

    ts_vector = list(row_timestamps)  # Shallow copy of pre-computed timestamps
    

    이전에는 각 피처마다 read_rows를 다시 순회하며 datetimeTimestamp로 변환하는 row_ts_proto.FromDatetime(row_ts) 호출이 O(features * entities)번 발생했습니다. 이제 이 변환은 O(entities)번만 발생하고, 각 피처는 이 결과를 재사용하게 됩니다.

  3. 중복 코드 제거: requested_features 루프 내부에 있던 Timestamp 변환 로직이 제거되어 코드가 더 간결해지고, 단일 책임 원칙(Single Responsibility Principle)에 더 부합하게 되었습니다.

이러한 변경은 datetime 객체를 Timestamp Protobuf 객체로 변환하는 작업이 비교적 비용이 큰 작업이기 때문에 특히 중요합니다. FromDatetime 메서드는 내부적으로 여러 계산을 수행하므로, 이를 반복적으로 호출하는 것은 성능 저하의 주요 원인이 됩니다.

sdk/python/tests/unit/test_utils.py

새로운 유닛 테스트 파일이 추가되어 _convert_rows_to_protobuf 함수의 변경사항에 대한 정확성을 보장합니다. 특히 test_multiple_features_same_timestamp 테스트는 이 최적화의 핵심 가정을 검증합니다.

    def test_multiple_features_same_timestamp():
        """Test that multiple features share the same pre-computed timestamp.

        This verifies the optimization: timestamps are computed once per entity,
        not once per feature per entity.
        """
        timestamp = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
        value1 = ValueProto(float_val=1.0)
        value2 = ValueProto(float_val=2.0)

        read_rows = [(timestamp, {"feature_1": value1, "feature_2": value2})]
        requested_features = ["feature_1", "feature_2"]

        result = _convert_rows_to_protobuf(requested_features, read_rows)

        assert len(result) == 2
        ts1 = result[0][0][0]
        ts2 = result[1][0][0]
        assert ts1.seconds == ts2.seconds
        assert ts1.seconds == int(timestamp.timestamp())

이 테스트는 두 개의 다른 피처(feature_1, feature_2)가 동일한 엔티티의 타임스탬프를 요청했을 때, 반환된 Timestamp 객체들이 동일한 값을 가지는지 확인합니다. 이는 타임스탬프가 피처별로 개별적으로 계산되지 않고, 엔티티별로 한 번만 계산되어 재사용된다는 최적화의 의도를 명확히 검증합니다.

왜 이게 좋은가?

이 최적화는 Feast의 온라인 피처 서빙 성능에 직접적인 영향을 미치며, 특히 많은 수의 피처를 동시에 요청하는 시나리오에서 큰 이점을 제공합니다.

성능 수치

벤치마크 결과는 이 최적화의 효과를 명확히 보여줍니다.

Profile Before p99 After p99 Improvement
20 features × 500 entities 48ms 38ms 21% faster
50 features × 500 entities 112ms 77ms 31% faster

스트레스 테스트에서 Timestamp 변환 단계가 27ms에서 ~5ms로 크게 감소했습니다. 이는 전체 응답 시간에서 Timestamp 변환이 차지하던 비중이 얼마나 컸는지를 보여주며, 이 최적화가 병목 지점을 정확히 해결했음을 의미합니다.

일반적 교훈

  1. 중복 계산 제거: 성능 최적화의 가장 기본적인 원칙 중 하나는 불필요한 중복 계산을 제거하는 것입니다. 특히 루프 내에서 동일한 값을 반복적으로 계산하는 경우, 이를 루프 밖으로 빼내어 한 번만 계산하고 재사용하는 패턴은 매우 효과적입니다.
  2. 프로파일링의 중요성: 이 PR은 Timestamp 변환이 병목이라는 것을 프로파일링을 통해 식별했기 때문에 가능했습니다. 막연한 추측보다는 실제 성능 데이터를 기반으로 최적화 대상을 선정하는 것이 중요합니다.
  3. 데이터 구조 및 알고리즘 선택: row_timestamps 리스트를 도입하고 이를 얕은 복사로 재사용하는 것은 적절한 데이터 구조와 알고리즘 선택이 성능에 미치는 영향을 보여줍니다. O(N*M)에서 O(N+M)으로 복잡도를 줄이는 것은 대규모 데이터 처리에서 필수적입니다.
  4. 유닛 테스트의 역할: 최적화와 함께 추가된 유닛 테스트는 변경사항이 올바른 동작을 유지하면서 성능을 개선했음을 보장합니다. 특히 test_multiple_features_same_timestamp와 같이 최적화의 핵심 가정을 검증하는 테스트는 매우 중요합니다.

이러한 최적화는 Feast와 같이 고성능이 요구되는 시스템에서 사용자 경험을 크게 향상시키고, 더 많은 피처와 엔티티를 효율적으로 처리할 수 있는 기반을 마련합니다. 작은 코드 변경처럼 보일 수 있지만, 시스템의 핵심 경로에 적용될 경우 그 파급 효과는 매우 큽니다.

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

댓글

관련 포스트

PR Analysis 의 다른글