الوحدة 1 — معمارية Spark: المحرّك والمنفّذون والأقسام
قبل أيّ سطر من MLlib ينبغي فهم مَن يفعل ماذا داخل Spark. فأخطاء الأداء والذاكرة التي سترافقنا في الوحدات القادمة لا تفسير لها بغير هذه المعمارية، وأخطر الأخطاء هو تشغيل Spark حيث لا لزوم له.
المحرّك والمنفّذون
عند تنفيذ سكربت PySpark، تُبنى عمليّة أولى تُسمّى المحرّك (driver). لا تحسب هذه العمليّة البيانات بنفسها؛ بل تحمل شيفرتك، وتُخطّط لِما ينبغي عمله، وتُوزّع العمل على عمليّات أخرى تُسمّى المنفّذين (executors). كلّ منفّذ عمليّة JVM مستقلّة تدور على عقدة من العنقود، تحوز نصيبها من الذاكرة والأنوية، وتُنفّذ ما يُرسِله المحرّك عليها.
يتراسل الطرفان بمخطَّط بسيط: يُرسل المحرّك مَهامّ (tasks)، ويُعيد المنفّذ نتائجها. وتُجمَع المهامّ في مراحل (stages) لا يمكن أن يحدث خلط بين البيانات داخلها، ثمّ تُوصَل المراحل داخل وظيفة (job) واحدة تولّدها كلّ عملية count() أو write أو نظيراتها. المحرّك أيضًا هو الذي يستضيف الجدول الوصفيّ (SparkContext, SparkSession) الذي تخاطبه من الشيفرة.
نتيجتان عمليّتان تتفرّعان من هذا التقسيم: إذا سقط المحرّك سقط التطبيق كلّه، أمّا سقوط منفّذ فيمكن تعويضه بإعادة تنفيذ مَهامّه على منفّذ آخر. ومن هنا نصيحة قد تبدو مُخالِفة للحدس: أعطِ المحرّك ذاكرة مريحة وأنوية قليلة، وأعطِ المنفّذين الأنوية والذاكرة الأكبر — فالحساب الفعلي يقع عندهم.
الأقسام والمهامّ
يقسِّم Spark كلّ DataFrame إلى أقسام (partitions): كتل من الأسطر، كلّ كتلة تُعالَج مستقلًّا. القسم هو الحبيبة الأصغر للتوازي: قسم واحد يعني مَهمّة واحدة، منفّذًا واحدًا نَشِطًا، وبقية الأنوية عاطلة. ومنها القاعدة الذهبية: عدد الأقسام يجب أن يكون أضعاف عدد الأنوية المتاحة (spark.default.parallelism) لا مساويًا لها فقط.
نُصدِر أوّل سلسلة على الخيط الأحمر لهذه الدورة، تاريخ الرحلات الجوّية:
from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.appName("VolsRetard")
.config("spark.sql.shuffle.partitions", "200")
.getOrCreate()
)
vols = spark.read.parquet("s3a://donnees-vols/annees=2015-2023/")
print("عدد الأقسام:", vols.rdd.getNumPartitions())
print("عدد الأسطر:", vols.count())
على مجموعة تاريخية تضمّ نحو 60 م ليون رحلة، سيقرأ Spark ملفّات Parquet ويُنشئ 200 قسم افتراضيًّا. لن نستدعي .count() بلا داعٍ في الإنتاج — سنرى في الوحدة 3 أنّ التقييم الكسول يحتّم الحذر — لكنّه هنا كافٍ لِرؤية الأقسام تعمل.
الذاكرة والتسرُّب إلى القرص
لكلّ منفّذ ذاكرة موزَّعة بين ذاكرة تنفيذيّة لِلعمليّات الحاليّة (فرز، تجميع، انضمام) و ذاكرة تخزين للتخبئة (cache). حين تفيض الذاكرة يبدأ Spark بالتسرُّب إلى القرص — وهذا هو الجحيم الأدائي الأشيع: عمليّة كان زمنها ثوانٍ تتحوّل إلى عشرات الدقائق دون رسالة خطأ صريحة. ستُقرأ هذه الحادثة في واجهة Spark UI تحت اسم Spill (Memory) و Spill (Disk)، وسنعود إليها في الوحدة 8.
متى Spark مبالغة
Spark مصمَّم لأحجام لا تسع ذاكرة آلة واحدة أو تحتاج توازيًا حقيقيًّا. لكنّ كلفته ثابتة: كلّ استعلام يمرّ بتخطيط ومحسِّن Catalyst وإرسال شبكيّ إلى المنفّذين. لملفّ CSV بحجم مئة ميغابايت على حاسوب محمول، سيبدأ Spark بعد بضع ثوان ويُنهي المهمّة في زمن يبتلعه zpandas في جزء من الثانية.
دون 10 غيغابايت من البيانات على آلة تحوز ذاكرة كافية، لا تستعمل Spark. بين 10 و 100 غيغابايت، Spark محلّي على آلة واحدة قد يفوز على pandas. فوق ذلك، Spark موزَّع على عنقود أو خدمة مُدارة (Databricks, EMR, Dataproc) هي الخيار الطبيعي. الحكم الحاسم لا يعتمد على الحجم وحده بل على تكرار المعالجة أيضًا: مهمّة تُشغَّل مرّة في السنة على 50 غيغابايت لا تُبرِّر تعلُّم Spark إن لم تكن في فريقك.
الخلاصة
- المحرّك يُخطّط ولا يحس ب؛ المنفّذون يحسبون. الذاكرة والأنوية للمنفّذين، الاستقرار للمحرّك.
- الأقسام هي حبيبة التوازي: عددها يجب أن يكون أضعاف عدد الأنوية، لا مساويًا لها.
- الذاكرة عند التنفيذ مقسَّمة بين تنفيذ وتخزين؛ فيضانها يُسبِّب تسرُّبًا صامتًا إلى القرص.
- Spark ليس دائمًا الجواب: تحت 10 غيغابايت وذاكرة كافية على حاسوب واحد، pandas أسرع وأبسط.
الوحدة التالية: التجريدات الثلاث التي تعرضها Spark — RDD و DataFrame و Dataset — ولماذا صار DataFrame هو الافتراضي عمليًّا.