Lazy API

Lazy API

Polars는 lazy와 eager 두 가지 동작 모드를 지원해요. 지금까지 예시들은 쿼리를 즉시 실행하는 eager API를 썼죠. 반면 lazy API에서는 쿼리를 *수집(collect)*할 때까지 평가하지 않습니다. 실행을 최대한 미루는 것은 성능상 큰 이점이 있어서, 대부분의 경우 lazy API를 더 선호하는 이유이기도 해요. 예제로 이 차이를 확인해 볼게요.

출처: 공식문서

import polars as pl

df = pl.read_csv("docs/assets/data/iris.csv")
df_small = df.filter(pl.col("sepal_length") > 5)
df_agg = df_small.group_by("species").agg(pl.col("sepal_width").mean())
print(df_agg)

이 예제에서는 eager API로:

  1. iris 데이터셋을 읽고,
  2. sepal length를 기준으로 데이터를 필터링하고,
  3. 종(species)별 sepal width 평균을 구합니다.

모든 단계가 즉시 실행되면서 중간 결과를 반환하죠. 이는 실제로 사용하지 않는 데이터를 더 읽거나 작업을 하는 등 꽤 낭비가 될 수 있어요. 만약 lazy API를 써서 모든 단계가 정의될 때까지 실행을 미룬다면, 쿼리 플래너가 다양한 최적화를 수행할 수 있습니다. 이 경우에는:

  • Predicate pushdown: 데이터를 읽으면서 가능한 한 일찍 필터를 적용해, sepal length가 5보다 큰 행만 읽는다.
  • Projection pushdown: 데이터를 읽으면서 필요한 컬럼만 선택해, 추가 컬럼(예: petal length, petal width)을 불러올 필요를 없앤다.
q = (
    pl.scan_csv("docs/assets/data/iris.csv")
    .filter(pl.col("sepal_length") > 5)
    .group_by("species")
    .agg(pl.col("sepal_width").mean())
)

df = q.collect()

이런 최적화는 메모리와 CPU 부하를 크게 줄여서 더 큰 데이터셋을 메모리에 넣고 더 빨리 처리할 수 있게 해 줍니다. 쿼리를 정의한 뒤 collect를 호출하면 Polars에게 "이제 실행할 거야"라고 알려주는 겁니다. lazy API에 대해 더 자세히 배우고 싶다면 전용 챕터를 참고하세요.

Eager API

사실 eager API도 많은 경우 내부적으로 lazy API를 호출하고 그 결과를 즉시 collect하는 거예요. 이 덕분에 쿼리 내부에서도 쿼리 플래너가 만든 최적화가 그대로 적용될 수 있다는 장점이 있습니다.

언제 어떤 것을 써야 할까

일반적으로 lazy API를 쓰는 게 좋아요. 다만 중간 결과가 필요한 경우, 또는 탐색(exploratory) 작업이라 쿼리가 어떻게 생길지 아직 모르는 경우에는 eager API가 어울립니다.

쿼리 플랜 미리 보기

lazy API를 쓸 때 explain 함수를 호출하면, 결과를 collect했을 때 실행될 쿼리 플랜에 대한 설명을 만들 수 있어요. Polars가 쿼리에 어떤 최적화를 적용하는지 보고 싶을 때 유용하죠. 위에서 정의한 쿼리 q를 explain해 볼게요.

print(q.explain())
AGGREGATE[maintain_order: false] [col("sepal_width").mean()] BY [col("species")] FROM
 simple π 2/2 ["species", "sepal_width"]
  Csv SCAN [docs/assets/data/iris.csv]
  PROJECT 3/5 COLUMNS
  SELECTION: col("sepal_length") > 5.0
  ESTIMATED ROWS: 167

설명 결과에서 Polars가 predicate pushdown을 적용했다는 걸 바로 알 수 있어요. sepal length가 5보다 큰 행만 읽고 있으니까요. 또 projection pushdown도 적용됐죠 — 쿼리에 필요한 컬럼만 읽고 있습니다.

explain은 표현식 확장(expression expansion)이 특정 스키마에서 어떻게 펼쳐질지 보는 데도 쓸 수 있어요. 표현식 확장 섹션의 예제 표현식을 살펴볼게요.

(pl.col(pl.Float64) * 1.1).name.suffix("*1.1")

explain으로 이 표현식이 임의의 스키마에 대해 어떻게 평가되는지 볼 수 있습니다.

schema = pl.Schema(
    {
        "int_1": pl.Int16,
        "int_2": pl.Int32,
        "float_1": pl.Float64,
        "float_2": pl.Float64,
        "float_3": pl.Float64,
    }
)

print(
    pl.LazyFrame(schema=schema)
    .select((pl.col(pl.Float64) * 1.1).name.suffix("*1.1"))
    .explain()
)
SELECT [(col("float_1") * 1.1).alias("float_1*1.1"), (col("float_2") * 1.1).alias("float_2*1.1"), (col("float_3") * 1.1).alias("float_3*1.1")]
DF ["int_1", "int_2", "float_1", "float_2", ...]; PROJECT["float_1", "float_2", "float_3"] 3/5 COLUMNS

더 알아보기 (Learn more)

  • lazy 쿼리를 실제로 어떻게 실행하는지는 쿼리 실행 문서를 보세요.
  • lazy API의 스키마 검사와 제약은 스키마 문서에서 다룹니다.