Polars lazy frame

문제: OOM

Databricks 파이프라인으로 모델 학습 노트북을 돌리던 중 OOM이 발생했다. 에러 메시지는 아래와 같았다.

Futures timed out after [30 seconds]

타임아웃이라서 처음엔 쿼리가 느려서 잘린 줄 알았는데, 코드에 타임아웃 설정은 없었다. 실제 흐름은 이랬다.

드라이버 Python 프로세스가 메모리를 다 쓰고 죽음 → 드라이버가 응답 불가 → Spark가 보낸 RPC가 응답을 못 받고 Futures timed out → 클러스터 다운 → 실행 중이던 셀과 이후 셀이 모두 cancelled.

정리하면, 에러 없는 cancelled 연쇄에 Futures timed out이 같이 보이면 OOM으로 보면 된다. 코드 버그가 아니라 프로세스가 메모리 부족으로 죽은 것이었다.

원인 분석

문제가 된 노트북의 toy 코드다.

import polars as pl

# (A) 큰 parquet을 전부 메모리로 로드
df = pl.scan_parquet("data/*.parquet").collect()   # 수천만 행 × 수백 컬럼

# (B) 파생 컬럼 추가
df2 = df.with_columns(
    pl.col("timestamp").str.slice(0, 10).alias("date")
)

# (C) 학습용/검증용으로 분리
train = df2.filter(pl.col("date") < "2026-05-18").select(feature_cols)
valid = df2.filter(pl.col("date") == "2026-05-18").select(feature_cols)

print(train.height, valid.height)   # ← 여기서 죽음

(A)에 scan_parquet이라는 lazy 함수를 쓰고도 바로 .collect()를 붙여 그 효과를 없애고 있었다.

(C)에서 프로세스가 죽었고, 이유는 그 순간 메모리에 큰 객체 여러개가 동시에 떠 있었기 때문이다.

객체 정체 메모리
df 전체 데이터 풀로드 big
df2 df + 파생 컬럼 (사실상 또 한 벌) big
train (C)에서 만든 또 한 벌 big
valid (C)에서 만든 또 한 벌 big

(A)~(C)를 거치며 같은 데이터의 복사본 4개가 동시에 메모리에 있었다. 각 셀의 출력은 정상이었지만 (C)에서 마지막 2개를 올리는 순간 드라이버 RAM을 넘긴 것 같다.

해결: Polars lazy evaluation

중간 peak이 문제였으므로 Polars의 lazy evaluation을 도입했다. 이미 scan_parquet을 쓰고 있었으므로 .collect()를 나중으로 옮기기만 하면 되었다.

DataFrame(eager) vs LazyFrame(lazy)

Polars엔 데이터를 다루는 객체가 두 종류 있다.

DataFrame (eager) LazyFrame (lazy)
생성 pl.read_parquet(), .collect() 결과 pl.scan_parquet()
데이터가 메모리에 있음 (이미 읽음) 없음 (아직 안 읽음)
.filter() / .select() 즉시 계산해 새 DataFrame 계산 안 하고 “할 일 목록”에만 기록
실제 실행 매 연산마다 .collect() 부를 때 한 번에

scan_parquet은 파일을 읽지 않고 “여길 읽을 것이다”라는 계획(LazyFrame)만 만든다. 이후 filter, select는 계획에 기록만 되고, .collect()를 부르는 순간 실행된다.

collect()가 하는 일

.collect()는 실행 직전에 쌓인 계획 전체를 보고 자동 최적화(query optimization)를 한다.

그래서 데이터 전체가 아니라 필요한 부분만 메모리에 올라으므로 메모리 피크가 작아진다. 기존 코드의 경우 (A)에서 조건을 걸기 전 .collect()를 먼저 불렀기 때문에 최적화가 불가능했던 것 같다.

Before / After

Before — 풀카피 4개

df = pl.scan_parquet("data/*.parquet").collect()   # 전체 풀로드
df2 = df.with_columns(pl.col("timestamp").str.slice(0, 10).alias("date"))
train = df2.filter(pl.col("date") < "2026-05-18").select(feature_cols)
valid = df2.filter(pl.col("date") == "2026-05-18").select(feature_cols)

After — .collect()를 끝으로 미룸

import gc

lf = pl.scan_parquet("data/*.parquet")             # 계획만 (데이터 0)
lf = lf.with_columns(pl.col("timestamp").str.slice(0, 10).alias("date"))

try:
    train = (
        lf.filter(pl.col("date") < "2026-05-18")
          .select(feature_cols)
          .collect(streaming=True)   # 걸러진 train만, 청크 단위로
    )
    valid = (
        lf.filter(pl.col("date") == "2026-05-18")
          .select(feature_cols)
          .collect(streaming=True)   # 걸러진 valid만, 청크 단위로
    )
finally:
    # lazy는 collect 시점에 파일을 읽으므로, 캐시 삭제는 반드시 collect 이후
    cleanup_temp_files()

# pandas로 넘긴 뒤 polars 쪽은 회수
train_pd, valid_pd = train.to_pandas(), valid.to_pandas()
del train, valid, lf
gc.collect()

After 시점에 동시에 살아있는 건 lf(계획만이라 거의 0) + train + valid 뿐이다. 전체 풀카피(df, df2)가 존재하지 않는다. 여기에 추가로 취할 수 있는 방법을 배웠다.

회고

이번에 정리한 것들

한 줄 정리

scan = 주문서 작성, filter / select = 주문서에 항목 추가, collect() = 주문서 제출(이때 실행). 주문서를 다 쓴 뒤 제출하면 필요한 것만 한 번에 받아옴.