Aller au contenu principal

Module 1 — Architecture Spark : pilote, exécuteurs, partitions

Avant d'écrire une seule ligne de PySpark, il faut voir ce qui tourne où. Un travail Spark n'est pas un script Python qu'on lance plus fort : c'est une architecture à deux étages, avec un chef d'orchestre et une flotte d'ouvriers, et les malentendus les plus coûteux viennent presque tous d'une confusion entre les deux.

Deux composants, deux mémoires

Le pilote (driver) est le processus qui exécute votre programme Python. Il tient la SparkSession, construit le plan d'exécution, décide quelles tâches envoyer, et récupère les résultats agrégés. Il ne traite jamais lui-même les gros volumes : sa mémoire correspond à celle du portable ou du nœud maître, elle est petite face au jeu de données.

Les exécuteurs (executors) sont les processus JVM qui font le travail réel. Chacun tient une partie des données en mémoire, exécute les tâches qu'on lui envoie, et rend son résultat. Ils tournent sur les nœuds de calcul de la grappe (YARN, Kubernetes, Databricks) et, en mode local avec local[4], ce sont quatre fils d'un même processus.

La différence pratique tombe dès la première erreur. Un collect() qui ramène cinq millions de lignes vers le pilote fait exploser la mémoire du pilote, pas celle des exécuteurs. Un filter mal indexé fait au contraire travailler les exécuteurs pendant des minutes sans jamais toucher au pilote. Savoir où tourne quoi guide déjà la moitié du débogage.

Étapes, tâches, partitions

Un travail Spark se décompose en étapes (stages), séparées par un brassage de données (shuffle, voir module 3). À l'intérieur d'une étape, Spark découpe le travail en tâches (tasks), une par partition. Une partition est un morceau du DataFrame, typiquement 128 Mo à 256 Mo, que Spark manipule comme une unité.

C'est ce grain-là qui gouverne le parallélisme. Avec 200 partitions et 20 cœurs d'exécuteurs disponibles, Spark exécute vingt tâches en parallèle, puis les vingt suivantes, jusqu'à épuiser les 200. Trop peu de partitions et vous laissez des cœurs oisifs ; trop de partitions et l'ordonnancement coûte plus que le calcul. Le paramètre par défaut spark.sql.shuffle.partitions vaut 200 — il est presque toujours à retoucher, voir module 8.

from pyspark.sql import SparkSession

spark = (
SparkSession.builder
.appName("vols-retards")
.master("local[4]")
.config("spark.sql.shuffle.partitions", "16")
.getOrCreate()
)

vols = spark.read.parquet("data/vols.parquet")
print(vols.rdd.getNumPartitions()) # nombre de partitions à la lecture

Quand Spark est en fait disproportionné

Le réflexe de sortir Spark dès qu'on entend « big data » est le premier piège. Spark paie un coût fixe non négligeable : démarrage de la JVM, sérialisation Java-Python (pour PySpark), planification, brassages. Sur un jeu qui tient en mémoire, pandas ou polars sont plus rapides d'un ordre de grandeur, tout en offrant une API plus directe.

Une règle de terrain : en dessous de quelques gigaoctets et si un seul serveur suffit, préférer un outil monomachine. Spark devient légitime quand le jeu ne tient plus sur un nœud, quand la lecture doit paralléliser sur du stockage colonne partitionné, ou quand la chaîne s'inscrit déjà dans un environnement Spark (Databricks, EMR, table Delta). Notre jeu de vols de plusieurs dizaines de millions de lignes coche ces cases ; un extrait d'un an sur une seule compagnie ne les cocherait pas.

Le coût invisible du mode local

En local[*], Spark tourne dans un seul processus JVM, sur votre machine. C'est parfait pour développer et tester, mais les mesures de performance n'y valent presque rien : pas de réseau, pas de vraie sérialisation inter-nœuds, un ramasse-miettes qui n'a rien à voir. Ne concluez pas d'une mesure locale qu'un travail « ira vite en production ».

Le petit tour d'une exécution

Quand vous appelez vols.groupBy("compagnie").count().show(), il ne se passe rien tant que show() n'est pas atteint. Le pilote construit alors un plan logique, l'optimise par Catalyst (module 2), le découpe en étapes et en tâches, puis expédie ces tâches aux exécuteurs. Chaque exécuteur lit sa partition de Parquet, applique le groupBy local, renvoie ses agrégats partiels. Un brassage regroupe ensuite les compagnies sur les mêmes exécuteurs pour l'agrégat final, et le pilote reçoit le petit résultat à afficher.

Cette séquence — plan, découpe, envoi, brassage, retour — se répète pour chaque action. Elle est visible dans l'interface Spark UI, à l'adresse http://localhost:4040 en mode local. Prendre l'habitude de l'ouvrir dès le premier travail évite des heures de conjecture ; on y verra le module 8.

En résumé

  • Le pilote exécute le programme, planifie et récupère les résultats agrégés ; les exécuteurs manipulent les données par partitions.
  • Une partition est l'unité de parallélisme ; chaque tâche traite une partition, et le nombre par défaut de 200 est presque toujours à ajuster.
  • Sortir Spark pour un jeu qui tient en mémoire coûte plus qu'il ne rapporte ; pandas ou polars restent plus directs sous quelques gigaoctets.
  • Le pilote est petit, les exécuteurs sont gros : un collect() ramène tout au pilote et fait exploser sa mémoire, jamais celle des exécuteurs.

Module suivant : les trois abstractions historiques de Spark (RDD, DataFrame, Dataset), et pourquoi le DataFrame est aujourd'hui le défaut.