본문으로 건너뛰기

[loki] Loki 데이터 객체 최적화: Predicate 컴파일을 통한 쿼리 성능 향상

PR 링크: grafana/loki#24982 상태: Merged | 변경: +657 / -160

Loki 데이터 객체 최적화: Predicate 컴파일을 통한 쿼리 성능 향상

들어가며

Grafana Loki는 로그 데이터를 효율적으로 저장하고 쿼리하는 데 특화된 시스템입니다. Loki에서 쿼리 성능은 사용자 경험에 직접적인 영향을 미치며, 특히 대량의 로그 데이터를 처리할 때 그 중요성이 더욱 부각됩니다. 데이터 쿼리 과정에서 Predicate는 특정 조건을 만족하는 데이터를 필터링하는 데 사용되는 핵심 요소입니다.

기존 Loki의 RowReader는 데이터를 읽어올 때 각 행(row)마다 Predicate 트리를 처음부터 다시 해석하는 비효율적인 방식을 사용하고 있었습니다. 이는 각 리프(leaf) Predicate마다 컬럼을 찾아내기 위해 맵(map) 룩업과 타입 스위치(type switch)를 반복적으로 수행해야 함을 의미합니다. 수많은 행을 처리해야 하는 상황에서는 이러한 반복적인 오버헤드가 누적되어 전체 쿼리 성능에 심각한 병목 현상을 초래할 수 있습니다.

이번 PR(perf(dataobj): Compile predicates once per read instead of once per row)은 이러한 문제를 해결하기 위해 Predicate 평가 로직을 최적화하는 것을 목표로 합니다. 핵심 아이디어는 Predicate 트리를 각 행마다 해석하는 대신, 한 번의 읽기(read) 작업당 한 번만 컴파일하여 컬럼 참조를 미리 인덱스로 변환하는 것입니다. 이를 통해 RowReader가 데이터를 필터링하는 주 루프에서 발생하는 행당(per-row) 비용을 제거하고, 쿼리 처리 속도를 크게 향상시킵니다.

코드 분석: 무엇이 달라졌나?

이 PR의 핵심 변경사항은 Predicate를 미리 컴파일하여 최적화된 형태로 사용하는 새로운 메커니즘을 도입하는 것입니다. 이를 위해 pkg/dataobj/internal/dataset/predicate_compiled.go 파일이 새로 추가되었습니다.

pkg/dataobj/internal/dataset/predicate_compiled.go (새 파일)

이 파일은 Predicate를 Row의 Values 배열 인덱스로 미리 매핑하여 런타임 오버헤드를 줄이는 compiledPredicate 인터페이스와 관련 구현체를 정의합니다.

compiledPredicate 인터페이스

compiledPredicate는 원본 Predicate의 컴파일된 버전으로, Row의 Values에 대한 컬럼 참조가 인덱스로 미리 결정되어 있습니다. 이 인터페이스는 단 하나의 메서드 eval을 가집니다.

Before (개념적):

type Predicate interface {
    // eval reports whether row passes the predicate. (내부적으로 컬럼 룩업 필요)
    eval(row Row, lookup map[Column]int) bool
}

After:

// compiledPredicate is a [Predicate] with its column references resolved to
// indexes into a [Row]'s Values.
type compiledPredicate interface {
	// eval reports whether row passes the predicate.
	eval(row Row) bool
}

compiledPredicate의 eval 메서드는 더 이상 컬럼 룩업 맵을 인자로 받지 않습니다. 이는 필요한 컬럼 인덱스가 이미 컴파일된 Predicate 내부에 저장되어 있음을 의미합니다.

compilePredicate 함수

이 함수는 원본 Predicate 트리를 순회하며 각 리프 Predicate의 Column을 Row의 Values 인덱스로 변환하여 compiledPredicate 형태로 반환합니다. 이 과정은 Predicate 세트가 처음 컴파일될 때 단 한 번만 발생합니다.

After:

func compilePredicate(p Predicate, lookup map[Column]int) (compiledPredicate, error) {
	switch p := p.(type) {
	case nil:
		return compiledConstPredicate(true), nil

	// ... (AndPredicate, OrPredicate, NotPredicate 등 다른 Predicate 타입 처리)

	case EqualPredicate:
		idx, err := lookupColumnIndex(lookup, p.Column)
		if err != nil {
			return nil, err
		}
		return compiledEqualPredicate{columnIndex: idx, value: p.Value}, nil

	// ... (InPredicate, GreaterThanPredicate, LessThanPredicate, FuncPredicate 등 처리)

	default:
		panic(fmt.Sprintf("dataset.compilePredicate: unsupported predicate type %T", p))
	}
}

func lookupColumnIndex(lookup map[Column]int, c Column) (int, error) {
	idx, ok := lookup[c]
	if !ok {
		return 0, fmt.Errorf("predicate column %v not found in RowReader columns", c)
	}
	return idx, nil
}

위 코드에서 EqualPredicate 케이스를 보면, p.Column을 직접 사용하는 대신 lookupColumnIndex를 통해 columnIndex를 한 번만 조회하여 compiledEqualPredicate에 저장하는 것을 볼 수 있습니다. 이 lookup 맵은 RowReader가 처리할 컬럼들의 Column 객체와 해당 Row 내에서의 인덱스를 매핑한 것입니다.

compiledXxxPredicate 구현체들

각 원본 Predicate 타입에 대응하는 compiledXxxPredicate 구조체들이 정의됩니다. 이들은 columnIndex를 직접 필드로 가지고 있어, eval 메서드에서 Row의 Values 배열에 직접 접근할 수 있게 합니다.

After (예시: compiledEqualPredicate):

type compiledEqualPredicate struct {
	columnIndex int
	value       Value
}

func (p compiledEqualPredicate) eval(row Row) bool {
	return CompareValues(&row.Values[p.columnIndex], &p.value) == 0
}

이전에는 p.Column을 사용하여 map 룩업을 통해 row.Values의 올바른 위치를 찾아야 했지만, 이제는 p.columnIndex를 통해 row.Values[p.columnIndex]로 직접 접근할 수 있게 됩니다. 이는 eval 호출마다 반복되던 맵 룩업과 타입 스위치 오버헤드를 완전히 제거합니다.

RowReader의 변경 (PR 설명 기반)

제공된 diff에는 RowReader 파일의 직접적인 변경사항이 포함되어 있지 않지만, PR 설명에 따르면 RowReader는 이제 Predicate 세트가 처음 컴파일될 때 compilePredicate 함수를 호출하여 compiledPredicate를 얻고 이를 내부적으로 유지합니다. 또한, Reset 메서드는 기존의 Columns 및 Predicates 상태와 함께 컴파일된 Predicate도 지워, 재사용되는 RowReader가 항상 새로운 옵션으로 다시 컴파일되도록 하여 오래된 캐시를 유지하지 않도록 합니다.

왜 이게 좋은 최적화인가?

성능 수치 분석

이 PR은 벤치마크를 통해 명확한 성능 개선 효과를 보여줍니다.

PredicateExecution 벤치마크 (필터 루프 자체):

                                           │ before_pred.txt │            after_pred.txt             │
                                           │     sec/op      │    sec/op      vs base                │
PredicateExecution/selectivity=high-11          27.30m ± 17%   17.59m ±  33%  -35.58% (p=0.003 n=15)
PredicateExecution/selectivity=low-11           35.94m ±  2%   25.55m ±   2%  -28.90% (p=0.000 n=15)
geomean                                         32.95m         22.19m         -32.67%

PredicateExecution 벤치마크는 필터 루프 자체를 격리하여 측정하며, 통계적으로 유의미한 두 가지 경우에서 28%에서 36%에 달하는 현저한 시간 감소(sec/op)를 보여줍니다. 이는 Predicate 평가 로직의 최적화가 직접적으로 성능 향상으로 이어졌음을 의미합니다.

Reader 벤치마크 (전체 읽기 경로):

                    │ before_reader.txt │          after_reader.txt          │
                    │      sec/op       │   sec/op     vs base               │
Reader/batch=100-11        226.8m ±  2%   221.2m ± 1%  -2.49% (p=0.021 n=15)
geomean                    248.3m         240.9m       -2.98%

Reader 벤치마크는 전체 읽기 경로를 측정하며, PredicateExecution만큼 극적이지는 않지만 약 2~3%의 시간 감소를 보여줍니다. 이는 Predicate 평가가 전체 읽기 경로의 한 부분일 뿐이지만, 여전히 전체 시스템 성능에 기여하는 중요한 요소임을 나타냅니다.

메모리 사용량(B/op)은 컴파일된 Predicate 트리의 할당으로 인해 5~10% 증가했지만, 이는 CPU 시간 감소라는 큰 이점에 비하면 합리적인 트레이드오프입니다.

일반적 교훈

이 최적화는 소프트웨어 개발에서 매우 중요한 몇 가지 교훈을 제공합니다.

  1. "Compile once, execute many" 원칙: 반복적으로 수행되는 작업에서 초기 설정 또는 해석 단계를 한 번만 수행하고, 그 결과를 재사용하여 실행 단계의 오버헤드를 최소화하는 것은 고성능 시스템 설계의 기본입니다. 이 PR은 Predicate 해석을 한 번의 컴파일 단계로 옮겨 이 원칙을 충실히 따랐습니다.
  2. 데이터 구조 최적화: 런타임에 동적인 맵 룩업이나 타입 스위치와 같은 비용이 큰 작업을, 미리 계산된 정적 인덱스 접근으로 대체함으로써 CPU 캐시 효율성을 높이고 명령어 파이프라인을 더욱 원활하게 만듭니다.
  3. 병목 지점 식별 및 제거: 프로파일링을 통해 RowReader의 Predicate 평가 로직이 병목 지점임을 정확히 식별하고, 해당 부분에 집중적인 최적화를 적용하여 전체 시스템 성능을 향상시켰습니다.
  4. 트레이드오프 이해: 성능 최적화는 종종 메모리 사용량 증가와 같은 다른 자원과의 트레이드오프를 수반합니다. 이 PR은 CPU 시간 감소라는 큰 이득을 위해 약간의 메모리 사용량 증가를 감수했으며, 이는 합리적인 결정입니다.

리뷰어 피드백 반영

제공된 리뷰 댓글 중 이 PR의 코드 변경사항에 대한 유의미한 기술적 피드백이나 논의는 없었습니다.

결론

이번 PR은 Loki의 RowReader에서 Predicate 평가 로직을 "컴파일" 방식으로 전환함으로써 쿼리 성능을 크게 향상시켰습니다. 각 행마다 반복되던 비효율적인 맵 룩업과 타입 스위치를 제거하고, 미리 계산된 인덱스를 사용하여 데이터 접근을 최적화한 것이 핵심입니다. 이는 고성능 데이터 처리 시스템에서 반복적인 작업을 효율적으로 관리하고, 데이터 구조를 실행 환경에 맞게 최적화하는 것의 중요성을 다시 한번 일깨워주는 좋은 사례입니다. 이러한 최적화는 Loki 사용자들이 더 빠르고 효율적인 쿼리 경험을 할 수 있도록 기여할 것입니다.

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

댓글

관련 포스트

PR Analysis 의 다른글