본문으로 건너뛰기

[Loki] 새 쿼리 엔진 메모리 할당 최적화: 객체 수 32% 감소

PR 링크: grafana/loki#20321 상태: Merged | 변경: +175 / -100

들어가며

Go의 가비지 컬렉션 시간은 할당된 바이트보다 객체 수에 더 큰 영향을 받습니다. Loki의 새 쿼리 엔진 코드를 프로파일링한 결과, Arrow 데이터 조작이 아닌 부수적인 연산에서 대량의 할당이 발생하고 있었습니다. 이 PR은 여러 파일에 걸쳐 할당을 줄이는 기법들을 적용합니다.

핵심 코드 분석

Arrow 빌더 사전 할당

Before (reader.go):

builder := array.NewRecordBuilder(r.opts.Allocator, r.schema)
n, readErr := r.inner.Read(ctx, r.buf)
for rowIndex := range n {
    // 각 Append가 내부 버퍼를 동적으로 확장

After:

builder := array.NewRecordBuilder(r.opts.Allocator, r.schema)
n, readErr := r.inner.Read(ctx, r.buf)
for _, columnBuilder := range builder.Fields() {
    columnBuilder.Reserve(n)
}

반복되는 문자열 Clone 캐싱

Before (aggregator.go):

// 매번 새로운 문자열을 clone
labelValuesCopy[i] = strings.Clone(v)

After:

cloned, ok := a.clonedLabelValues[v]
if !ok {
    cloned = strings.Clone(v)
    a.clonedLabelValues[v] = cloned
}
labelValuesCopy[i] = cloned

불필요한 슬라이스 생성 제거

Before (range_aggregation.go):

return []window{windows[0]}  // 새 슬라이스 할당

After:

return windows[0:1]  // 기존 슬라이스의 서브슬라이스

스트림 라벨 캐싱

Before (streams_view.go):

func (v *streamsView) Labels(ctx context.Context, id int64) (iter.Seq[labels.Label], error) {
    // 매번 이터레이터 생성

After:

func (v *streamsView) Labels(ctx context.Context, id int64) ([]labels.Label, error) {
    lbs, ok := v.streamLabels[id]
    if ok {
        return lbs, nil  // 캐시 히트
    }
    // ...

왜 이게 좋은가

프로파일링 결과:

지표 Before After 개선
총 할당 객체 수 4.7B 3.2B -32%
총 할당 메모리 705GB 610GB -13%
테스트 실행 시간 2m 14s 2m 1s -10%
  • GC 시간은 객체 수에 비례하므로 32% 객체 감소의 실질적 영향은 매우 큽니다
  • Reserve(n)으로 Arrow 빌더의 내부 재할당 횟수를 크게 줄입니다
  • strings.Clone 캐싱은 동일한 스트림 라벨이 반복 등장하는 로그 쿼리에서 특히 효과적입니다
  • 서브슬라이스 반환은 기존 배열의 메모리를 재사용하므로 할당이 0입니다

참고 자료

댓글

관련 포스트

PR Analysis 의 다른글