data-engineering// zero-copy-etl-polars-duckdb-arrow

معماری خطوط پردازش Zero-Copy با Apache Arrow، Polars و DuckDB (گذر از پانداس برای سرعت ۱۰ برابری)

کالبدشکافی ساختار ستونی حافظه Apache Arrow، ارزیابی تنبل (Lazy Execution) در Polars و اجرای کوئریهای تحلیلی Out-of-Core روی فایلهای چندگیگابایتی Parquet با DuckDB بدون خطای OOM.

احسان شهبازی·۱۴۰۴/۰۸/۰۲·۱۴ دقیقه مطالعه
// SBU Data Science Lab

چرا Pandas در پردازش دادههای مدرن به گلوگاه تبدیل شده است؟

برای بیش از یک دهه، کتابخانه Pandas ابزار پیشفرض دستکاری داده در پایتون بود. اما معماری داخلی Pandas (مبتنی بر آرایههای NumPy) برای عصر دادههای چنددهگیگابایتی و پردازندههای چند هستهای مدرن طراحی نشده است. سه محدودیت ساختاری در Pandas باعث بروز خطاهای مکرر OOM (Out of Memory) و کندی شدید در پایپلاینهای پروداکشن میشود:

  • اجرای حریصانه (Eager Evaluation) و کپیهای مکرر حافظه: در Pandas هر زنجیره از عملیات (مانند فیلتر کردن، گروهبندی و تغییر نام ستون) بلافاصله اجرا شده و یک کپی کامل از دیتافریم در رم ایجاد میکند. به همین دلیل، برای پردازش یک فایل ۵ گیگابایتی، معمولاً به ۲۵ تا ۵۰ گیگابایت رم نیاز دارید.
  • محدودیت تکرشتهای (Single-Threaded Execution): بیشتر عملیاتهای Pandas به دلیل قفل مفسر سراسری پایتون (GIL) و طراحی قدیمی، تنها از یک هسته CPU استفاده میکنند.
  • سربار سنگین رشتههای متنی: در نسخههای سنتی Pandas، ستونهای متنی به صورت اشارهگر به اشیاء PyObject در حافظه Heap ذخیره میشوند که باعث از بین رفتن CPU Cache Locality و افت شدید عملکرد میگردد.

استاندارد Apache Arrow: زبان مشترک حافظه ستونی و انتقال Zero-Copy

انقلاب نسل جدید ابزارهای مهندسی داده (مانند Polars، DuckDB و DataFusion) بر پایه فرمت حافظه ستونی Apache Arrow بنا شده است. در استاندارد Arrow، دادههای هر ستون در بافرهای پیوسته (Contiguous Memory Buffers) در رم چیده میشوند. این چیدمان به پردازنده اجازه میدهد با استفاده از دستورالعملهای برداری SIMD (AVX-512 / NEON)، دهها مقدار را در یک سیکل ساعت پردازش کند.

مهمتر از آن، پروتکل Arrow C Data Interface به دو موتور کاملاً مجزا (مثلاً Polars نوشتهشده با Rust و DuckDB نوشتهشده با C++) اجازه میدهد بدون سریالایز کردن یا کپی کردن حتی یک بایت از داده، صرفاً اشارهگرهای آدرس حافظه (Memory Pointers) را در کسری از میلیثانیه به یکدیگر تحویل دهند.

ترکیب طلایی: بهینهساز Lazy در Polars و موتور Out-of-Core در DuckDB

در یک معماری مدرن ETL روی یک سرور واحد (Single-Node Scale-Up)، ترکیب Polars و DuckDB نیاز به کلاسترهای پرهزینه Spark را برای مجموعهدادههای زیر ۵۰۰ گیگابایت به طور کامل از بین میبرد:

  • موتور Lazy در Polars: با استفاده از pl.scan_parquet() به جای read_parquet()، پولارز یک درخت منطقی از کوئری (Query Plan) میسازد و دو بهینهسازی حیاتی Predicate Pushdown (اعمال شرط WHERE در لحظه خواندن دیسک) و Projection Pushdown (خواندن فقط ستونهای مورد نیاز) را انجام میدهد.
  • موتور Out-of-Core در DuckDB: زمانی که نیاز به اجرای JOINهای پیچیده یا توابع پنجرهای (QUALIFY / OVER) روی دادههایی بزرگتر از ظرفیت رم سرور دارید، DuckDB به صورت خودکار دادههای میانی را روی دیسک سرریز (Disk Spilling) کرده و کوئری را بدون کرش کردن به پایان میرساند.

پیادهسازی پایپلاین ETL بدون کپی حافظه (Zero-Copy) با Polars و DuckDB

در کد زیر، ابتدا Polars به صورت Lazy فایلهای حجیم Parquet را فیلتر و پاکسازی میکند، سپس استریم Arrow بدون هیچگونه کپی حافظهای مستقیماً وارد DuckDB میشود تا یک کوئری تحلیلی پیچیده با محدودیت سختگیرانه رم روی آن اجرا و خروجی نهایی مستقیماً به صورت Parquet ذخیره گردد:

zero_copy_pipeline.py
python
import polars as pl
import duckdb
from pathlib import Path

class ZeroCopyAnalyticalPipeline:
    def __init__(self, max_memory_gb: int = 8, threads: int = 8, temp_dir: str = '/tmp/duckdb_spill'):
        Path(temp_dir).mkdir(parents=True, exist_ok=True)
        self.con = duckdb.connect(database=':memory:')
        # Configure DuckDB for Out-of-Core execution to prevent OOM on massive joins
        self.con.execute(f"SET memory_limit='{max_memory_gb}GB';")
        self.con.execute(f"SET threads={threads};")
        self.con.execute(f"SET temp_directory='{temp_dir}';")
        self.con.execute("SET preserve_insertion_order=false;")

    def run_etl(self, events_parquet_glob: str, users_parquet_path: str, output_path: str) -> None:
        # Step 1: Build Lazy Logical Plan in Polars (Predicate & Projection Pushdown)
        lazy_events = (
            pl.scan_parquet(events_parquet_glob)
            .filter(
                (pl.col('event_timestamp') >= pl.lit('2025-01-01').str.to_datetime()) &
                (pl.col('status_code') == 200) &
                (pl.col('revenue_usd').is_not_null())
            )
            .select([
                'user_id',
                'session_id',
                'event_type',
                'revenue_usd',
                'event_timestamp'
            ])
        )

        lazy_users = (
            pl.scan_parquet(users_parquet_path)
            .filter(pl.col('is_active') == True)
            .select(['user_id', 'country_code', 'acquisition_channel'])
        )

        # Step 2: Register Polars LazyFrames in DuckDB via Arrow C Stream Interface (Zero-Copy)
        self.con.register('filtered_events', lazy_events)
        self.con.register('active_users', lazy_users)

        # Step 3: Execute Vectorized Out-of-Core Window Aggregation & Direct Parquet Export
        query = f"""
        COPY (
            WITH user_session_metrics AS (
                SELECT
                    e.user_id,
                    u.country_code,
                    u.acquisition_channel,
                    e.session_id,
                    COUNT(*) AS events_in_session,
                    SUM(e.revenue_usd) AS session_revenue,
                    ROW_NUMBER() OVER (
                        PARTITION BY e.user_id
                        ORDER BY SUM(e.revenue_usd) DESC
                    ) AS session_rank
                FROM filtered_events e
                INNER JOIN active_users u ON e.user_id = u.user_id
                GROUP BY e.user_id, u.country_code, u.acquisition_channel, e.session_id
            )
            SELECT *
            FROM user_session_metrics
            WHERE session_rank <= 3
        ) TO '{output_path}' (FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 131072);
        """
        self.con.execute(query)

if __name__ == '__main__':
    pipeline = ZeroCopyAnalyticalPipeline(max_memory_gb=6, threads=8)
    pipeline.run_etl(
        events_parquet_glob='s3://data-lake/raw/events/*.parquet',
        users_parquet_path='s3://data-lake/dim/users.parquet',
        output_path='/data/curated/top_user_sessions.parquet'
    )

نکته کلیدی مهندسی: در DuckDB، غیرفعال کردن حفظ ترتیب ردیفها با دستور SET preserve_insertion_order=false; هنگام اجرای کوئریهای تجمیعی و نوشتن در فایل Parquet، مصرف رم موتور را تا ۴۰ درصد کاهش میدهد و به تردهای پردازنده اجازه میدهد بلوکهای داده را به صورت کاملاً موازی روی دیسک بنویسند.