본문으로 건너뛰기

[Grafana Loki] 정규식 필터 평가에서 배치당 한 번만 컴파일하도록 최적화

PR 링크: grafana/loki#19644 상태: Merged | 변경: +137 / -29

들어가며

Grafana Loki의 쿼리 엔진에서 LogQL의 정규식 라인/라벨 필터(|~, !~)를 평가할 때, 매 행(row)마다 동일한 정규식 패턴을 regexp.Compile로 컴파일하고 있었습니다. 정규식 컴파일은 비싼 연산이며, 같은 패턴을 수천~수만 번 반복 컴파일하면 엄청난 메모리 할당과 CPU 낭비가 발생합니다. 이 PR은 우측 피연산자가 스칼라(상수)인 경우 배치당 한 번만 컴파일하도록 변경합니다.

핵심 코드 분석

Before: 행마다 컴파일

binaryFunctions.register(types.BinaryOpMatchRe, arrow.BinaryTypes.String,
    &genericBoolFunction[*array.String, string]{
        eval: func(a, b string) (bool, error) {
            reg, err := regexp.Compile(b)  // 매 행마다 컴파일
            if err != nil {
                return false, err
            }
            return reg.Match([]byte(a)), nil
        },
    })

After: 스칼라 감지 후 배치당 한 번 컴파일

새로운 regexpFunction 타입:

type regexpFunction struct {
    eval func(a, b string, reg *regexp.Regexp) (bool, error)
}

func (f *regexpFunction) Evaluate(lhs, rhs arrow.Array, _, rhsIsScalar bool) (arrow.Array, error) {
    var (
        re  *regexp.Regexp
        err error
    )

    // 스칼라인 경우 배치당 한 번만 컴파일
    if rhsIsScalar {
        re, err = regexp.Compile(rhsArr.Value(0))
        if err != nil {
            return nil, fmt.Errorf("failed to compile regular expression for batch: %w", err)
        }
    }

    for i := range lhsArr.Len() {
        if lhsArr.IsNull(i) || rhsArr.IsNull(i) {
            builder.Append(false)
            continue
        }
        res, err := f.eval(lhsArr.Value(i), rhsArr.Value(i), re)
        if err != nil {
            return nil, err
        }
        builder.Append(res)
    }
    return builder.NewArray(), nil
}

expression evaluator에서 스칼라 여부 전달:

// Check if lhs and rhs are Scalar vectors
_, lhsIsScalar := expr.Left.(*physical.LiteralExpr)
_, rhsIsScalar := expr.Right.(*physical.LiteralExpr)
return fn.Evaluate(lhs, rhs, lhsIsScalar, rhsIsScalar)

BinaryFunction 인터페이스에 lhsIsScalar, rhsIsScalar 파라미터를 추가하여, 정규식 함수에서만 이를 활용합니다.

등록부 변경

binaryFunctions.register(types.BinaryOpMatchRe, arrow.BinaryTypes.String,
    &regexpFunction{
        eval: func(a, b string, reg *regexp.Regexp) (bool, error) {
            if reg == nil {
                // 비스칼라인 경우 행별 컴파일로 폴백
                var err error
                reg, err = regexp.Compile(b)
                if err != nil {
                    return false, err
                }
            }
            return reg.Match([]byte(a)), nil
        },
    })

왜 이게 좋은가

1. 정규식 컴파일의 실제 비용

Go의 regexp.Compile은 NFA(Nondeterministic Finite Automaton)를 구성합니다. 복잡한 패턴의 경우 수백 바이트의 내부 상태를 할당하며, 이것이 매 행마다 반복되면:

  • 10,000행 배치 × 정규식 컴파일 1회 = 10,000번의 NFA 구성 + 할당 + GC 부담
  • pprof 결과에서 정규식 컴파일이 alloc space의 상당 부분을 차지하는 것이 확인됨

2. Loki 쿼리 특성

대부분의 LogQL 정규식 필터는 다음과 같은 형태입니다:

{app="nginx"} |~ "error|warn|fatal"

우측의 정규식 패턴은 리터럴(상수)입니다. 즉, 거의 모든 실사용 쿼리에서 이 최적화의 혜택을 받습니다.

3. 안전한 폴백 설계

비스칼라 표현식(예: 정규식 패턴 자체가 다른 컬럼에서 오는 경우)에서는 reg == nil로 감지하여 기존과 동일하게 행별 컴파일을 수행합니다. 새로운 타입 regexpFunctiongenericBoolFunction과 분리되어 있어 기존 함수에 영향을 주지 않습니다.

4. Arrow 배열 기반 배치 처리의 장점

Loki의 새 엔진은 Apache Arrow 기반 columnar 처리를 사용합니다. 배치 단위로 데이터를 처리하므로 "배치당 한 번"이라는 최적화가 자연스럽게 적용됩니다. 이는 columnar 엔진 설계의 본질적인 장점입니다.

참고 자료

댓글

관련 포스트

PR Analysis 의 다른글