Fondamentaux de l’IA

Que se passe-t-il réellement lorsqu’on exécute une tâche Spark ?

mm
Ajouter Unite.AI à vos sources préférées sur Google
Apache Spark Architecture & Execution: Complete Internal Guide

Introduction

À mesure que les jeux de données s’agrandissent, leur traitement efficace sur une seule machine peut devenir difficile en raison des limites de la capacité du processeur, de la mémoire et du débit de stockage.

Apache Spark répond à ces défis en fournissant un moteur de traitement distribué des données capable de répartir les charges de travail en partitions et d’exécuter les tâches en parallèle sur plusieurs cœurs de processeur et machines.

Cependant, Spark ne consiste pas simplement à ajouter des machines.

Il est essentiel de comprendre comment ses composants fonctionnent ensemble — les pilotes, les exécuteurs, les RDD, les DataFrames, les partitions, les tâches, les étapes et les sous-tâches — pour concevoir des pipelines de données efficaces et fiables.

Dans cet article, je vais présenter l’architecture de Spark, ses mécanismes d’exécution et les principaux facteurs à prendre en compte pour optimiser ses performances, à l’aide d’un exemple pratique d’agrégation des ventes d’un commerce en ligne.

1. Pourquoi Apache Spark : le traitement parallèle des données

Apache Spark est un moteur de calcul distribué conçu pour le traitement de données à grande échelle et l’ingénierie des données.

Spark traite les mégadonnées en s’appuyant sur le traitement parallèle des données :

Figure A. Architecture générale d’un cluster distribué Apache Spark (cluster, pilote et nœuds de travail), comparant une exécution sur un seul nœud (créée par Kuriko IWAI)

Figure A. Architecture générale d’un cluster distribué Apache Spark (cluster, pilote et nœuds de travail), comparant une exécution sur un seul nœud (créée par Kuriko IWAI)

Contrairement à un traitement sur un seul nœud (à droite, Figure A), Spark répartit le travail en tâches à l’aide de plusieurs nœuds et les exécute sur les ressources disponibles, le cas échéant, sur plusieurs machines.

Ici, le terme « distribué » ne signifie pas que plusieurs machines sont indispensables. Spark peut fonctionner en mode local sur une seule machine. Même dans ce cas, les données peuvent être partitionnées et traitées en parallèle.

2. Architecture de base d’Apache Spark

Spark se compose de plusieurs éléments clés :

  • Pilote Spark (pilote) : processus central de coordination d’une application Spark.
  • Gestionnaire de cluster : service externe chargé d’allouer les ressources matérielles, comme les cœurs de processeur et la mémoire.
  • Nœuds de travail (nœud de travail) : machine physique ou virtuelle, ou nœud de calcul, au sein du cluster.
  • Exécuteurs : processus de travail distribués lancés sur les nœuds de travail.
  • Java Virtual Machine (JVM) : environnement d’exécution nécessaire pour exécuter le bytecode de Spark (puisque Spark est écrit en Scala/Java).

Tout d’abord, le pilote coordonne une application Spark et la transforme en un graphe orienté acyclique (DAG) de tâches d’exécution physiques, puis les planifie sur les exécuteurs.

Ensuite, le gestionnaire de cluster alloue les ressources en fonction du système de gestion des ressources de l’environnement de déploiement.

Les nœuds de travail fournissent des ressources de calcul aux exécuteurs, qui exécutent les tâches et conservent les données pendant leur exécution.

Certains environnements gérés, comme Databricks, utilisent un exécuteur par nœud de travail, mais ce n’est pas une règle générale de Spark. Selon la configuration du cluster, un nœud de travail peut héberger plusieurs exécuteurs.

2-1. Cas d’usage dans le commerce en ligne : structure des données d’entrée

Imaginons une petite entreprise de commerce en ligne.

Elle possède 10 000 enregistrements de ventes, qui comportent chacun une référence de stock (SKU) et une quantité vendue. Supposons qu’il existe trois SKU possibles : A, B, et C.

SKU Enregistrements Quantité Somme attendue
A 4,000 2 8,000
B 3,500 3 10,500
C 2,300 4 9,200
Invalides / retours 200 -1 -200
Total général 10 000

Tableau 1. Exemple de répartition des enregistrements de ventes du commerce en ligne et des agrégations attendues.

Notre objectif est de calculer le nombre total d’unités valides vendues par SKU :

  1. Écarter les enregistrements dont qty <= 0, puis
  2. Regrouper les échantillons par SKU.

Dans cet exemple de jeu de données complet, supposons que 9 800 des 10 000 enregistrements subsistent après le filtrage. Voici les totaux finaux attendus de Spark :

[("A", 8000), ("B", 10500), ("C", 9200)]

2-2. Cluster Spark

Je vais définir un cluster simplifié pour notre cas d’usage.

Nœuds de travail

Je vais configurer 3 nœuds de travail, chacun doté d’une instance de 8 vCPU et de 32 GiB de mémoire vive :

Figure B. Répartition des ressources entre les nœuds de travail (créée par Kuriko IWAI)

Figure B. Répartition des ressources entre les nœuds de travail (créée par Kuriko IWAI)

Au total, 24 vCPU sont disponibles dans le système.

Exécuteurs Spark et allocation du processeur et de la mémoire

Supposons que je réserve :

  • 7 exécuteurs.
  • 2 cœurs vCPU par exécuteur.
  • 3 GiB de mémoire de tas JVM par exécuteur.

L’application Spark demande au gestionnaire de cluster d’allouer des ressources à ces 7 exécuteurs.

Le gestionnaire de cluster décide ensuite de l’allocation des processeurs et de la mémoire en fonction de l’utilisation des ressources par chaque nœud de travail.

Par exemple, il décide de créer 14 emplacements de tâche : 2 cœurs vCPU par exécuteur × 7 exécuteurs :

Figure C-1. Cœurs vCPU, mémoire et allocation des exécuteurs entre les nœuds de travail, avec un déséquilibre des ressources sur le nœud de travail A (créée par Kuriko IWAI)

Figure C-1. Cœurs vCPU, mémoire et allocation des exécuteurs entre les nœuds de travail, avec un déséquilibre des ressources sur le nœud de travail A

Notez que les 3 GiB de mémoire alloués à l’exécuteur ne correspondent pas à sa mémoire totale, car il doit réserver une surcharge mémoire pour les nœuds de travail et les autres exécutions.

En supposant qu’une tâche nécessite 1 vCPU (spark.task.cpus = 1), chaque nœud de travail alloue ses vCPU :

Figure C-2. Allocation parallèle des vCPU entre les nœuds de travail (créée par Kuriko IWAI)

Figure C-2. Allocation parallèle des vCPU entre les nœuds de travail

Cela met en évidence deux points clés :

  1. Chaque nœud de travail a son propre taux d’utilisation du processeur (unique) (dans la figure C-2, le taux d’utilisation du nœud de travail A est de 75 %, tandis que ceux des nœuds de travail B et C restent à 50 %).
  2. Même si le système dispose de 24 cœurs vCPU (8 vCPU par nœud de travail × 3 nœuds de travail), il ne peut en utiliser que 14 ; les 10 autres restent inutilisés pour ces tâches.

2-3. La machine virtuelle Java (JVM) au cœur de Spark

Apache Spark repose sur la machine virtuelle Java (JVM).

La JVM est un environnement d’exécution nécessaire pour exécuter le bytecode de Spark.

Comme Spark est écrit en Scala/Java :

  • Lorsqu’on utilise Scala ou Java dans l’application : le code s’exécute nativement dans la JVM.
  • Lorsqu’on utilise PySpark (Python) : un processus Python exécute le code, mais communique avec un pilote basé sur la JVM par l’intermédiaire d’un pont tel que Py4J. Les exécuteurs Spark sur les nœuds de travail s’exécutent toujours dans une JVM, qui gère ensuite les processus de travail Python chargés d’exécuter le code Python et les fonctions définies par l’utilisateur en Python (UDF Python).

Dans les deux cas, la JVM gère les threads d’exécution, l’allocation de mémoire et le ramasse-miettes du pilote et des exécuteurs.

JVM du pilote et des exécuteurs dans une application PySpark

Dans une application PySpark, le pilote Python communique avec le pilote Spark exécuté dans la JVM ;

  • Pilote Python : emplacement où le code PySpark de l’application est écrit et exécuté.
  • JVM du pilote : coordonne l’exécution et la planification de Spark.
  • JVM de l’exécuteur : exécute les tâches Spark et gère les ressources d’exécution. Des processus de travail Python interviennent lorsque les tâches nécessitent l’exécution de code Python.
  • JVM du client (processus client ou machine de soumission) : JVM exécutée sur la machine locale ou sur un nœud périphérique/de passerelle.

Comme l’illustre la figure, la JVM du client se connecte d’abord à la JVM du pilote, qui coordonne ensuite les JVM des exécuteurs :

Figure D. Relation générale entre les JVM du client, du pilote et des exécuteurs

Figure D. Relation générale entre les JVM du client, du pilote et des exécuteurs

À partir de là, la JVM du pilote héberge le pilote Spark, qui gère l’application, tandis que les JVM des exécuteurs hébergent les exécuteurs individuels chargés d’exécuter ces tâches.

Chaque JVM du pilote dispose d’un tas JVM pour traiter efficacement les données, gérer la mise en cache et stocker les objets utilisateur.

2-4. Analyse approfondie des jeux de données distribués résilients (RDD)

Un jeu de données distribué résilient (RDD) est une collection immuable et partitionnée d’éléments que Spark peut traiter en parallèle.

Les RDD suivent les dépendances entre les transformations, ce qui permet à Spark de recalculer les partitions perdues dans de nombreux scénarios de défaillance.

Le nom recouvre trois propriétés :

  • Résilient : Spark enregistre le lignage — en indiquant de quel RDD dépend chaque RDD précédent — et peut souvent reconstruire les partitions manquantes au lieu de conserver des répliques de chaque résultat intermédiaire.
  • Distribué : le jeu de données est représenté sous forme de partitions que Spark peut traiter sur différents exécuteurs. Plusieurs machines physiques ne sont pas nécessaires ; le mode local prend également en charge les RDD.
  • Jeu de données : un RDD peut contenir des paires clé-valeur, des entiers, des chaînes de caractères, des tuples et d’autres éléments pris en charge — pas uniquement des lignes relationnelles. Les API RDD incluent des opérations telles que map, filter, reduceByKey, join et aggregateByKey.

Une RDD est une abstraction logique de collection et de calcul, et non une déclaration selon laquelle des partitions particulières sont attribuées de façon permanente à des travailleurs particuliers.

Partitions

Une partition est un bloc logique de données et correspond normalement à une tâche au sein d’une étape.

Supposons que nous créions 100 partitions (ces 100 partitions initiales constituent un choix pédagogique explicite).

Avec 10 000 éléments source, on obtient environ 100 éléments par partition si les données d’entrée sont réparties de manière à peu près uniforme :

Figure E. Répartition parallèle des partitions entre 100 divisions initiales des données

Figure E. Répartition parallèle des partitions entre 100 divisions initiales des données

 

3. API RDD vs API DataFrame : optimisation interne de Catalyst

L’exemple RDD d’origine aide à comprendre la lignée, les partitions et le brassage des données.

Dans les processus ETL structurés courants, toutefois, l’API DataFrame constitue le point de départ privilégié, car elle fournit des colonnes, des informations de schéma, des expressions de type SQL et des opérations visibles par l’optimiseur.

Le schéma ci-dessous illustre cette relation :

Figure L. Relations entre les concepts de graphe de Spark : lignée des RDD, DataFrame et DAG d’exécution de Spark (créé par Kuriko IWAI)

Figure F. Relations entre les concepts de graphe de Spark : lignée des RDD, DataFrame et DAG d’exécution de Spark

 

Tout comme la lignée d’une RDD définit les transformations, le plan de requête d’un DataFrame représente sa requête structurée.

Spark construit le travail d’exécution à partir des dépendances et des opérateurs physiques.

L’exécution d’un DataFrame peut s’appuyer sur des mécanismes fondés sur les RDD en interne, mais cela ne signifie pas que les utilisateurs doivent construire manuellement la lignée des RDD lorsqu’ils écrivent du code DataFrame.

3-1. Plan de requête d’un DataFrame

Un plan de requête de DataFrame est la séquence d’étapes logiques et physiques générée par un moteur d’exécution (encadré violet de la figure L).

Une requête DataFrame passe par plusieurs représentations :

  1. Plan logique : description des opérations demandées (analyse, filtrage, regroupement, agrégation).
  2. Plan logique analysé et optimisé : Spark résout les références et applique les optimisations logiques pertinentes, comme l’élagage des colonnes ou le refoulement des prédicats.
  3. Plan physique : Spark choisit les opérateurs exécutables, par exemple les analyses, les agrégations partielles et finales, les échanges, les jointures et les tris.
  4. Exécution : le plan physique est exécuté à l’aide des mécanismes d’exécution de Spark, ce qui implique finalement des tâches sur les exécuteurs.

Dans notre cas d’utilisation, le plan physique peut comprendre les étapes suivantes :

  • Agrégation partielle par hachage,
  • Échange par SKU, et
  • Agrégation finale par hachage.

3-2. Comparaison des actions : API RDD vs API DataFrame

Comparons les API RDD et DataFrame en prenant l’agrégation par SKU comme exemple.

API RDD

Les éléments sont des (sku, qty) paires. La logique d’agrégation Python est explicite, et Spark suit les dépendances des RDD :

result_rdd = (
    raw_rdd
    .filter(lambda row: row[1] > 0)
    .reduceByKey(lambda a, b: a + b, numPartitions=4)
)

rdd_results = result_rdd.collect()

# Example:
# [('A', 8000), ('B', 10500), ('C', 9200)]

API DataFrame

Supposons que sales_df possède les colonnes sku (chaîne de caractères) et qty (numérique) :

from pyspark.sql import functions as F

result_df = (
    sales_df
    .filter(F.col("qty") > 0)
    .groupBy("sku")
    .agg(F.sum("qty").alias("total_qty"))
)

df_results = result_df.collect()

# Example:
# [Row(sku='A', total_qty=8000),
#  Row(sku='B', total_qty=10500),
#  Row(sku='C', total_qty=9200)]

Ici, les deux exemples répondent au même problème métier :

Filtrer les enregistrements de vente invalides et agréger les quantités valides par SKU.

Et dans les deux cas, collect() déclenche l’exécution de Spark et renvoie tous les enregistrements obtenus au pilote.

La principale différence réside ici dans la représentation des résultats :

  • API RDD : renvoie des éléments Python, tels que (sku, total_qty) des tuples.
  • API DataFrame : renvoie des objets structurés Row avec des champs nommés, tels que sku et total_qty.

Voici une comparaison générale des API RDD et DataFrame :

API RDD API DataFrame / SQL
Représentation des données Éléments généraux, y compris des objets arbitraires pris en charge Lignes avec des colonnes nommées et un schéma
Style de programmation Transformations de bas niveau Transformations relationnelles déclaratives
Agrégations Oui : reduceByKey, aggregateByKey, etc. Oui : groupBy, agg, SQL
Optimisation des requêtes Moins d’informations sémantiques relationnelles exposées Optimisation Catalyst et de l’exécution physique
Usage typique Traitement spécialisé de bas niveau / apprentissage des mécanismes d’exécution ETL structuré, analytique, ingénierie des caractéristiques
Exécution Jobs, étapes et tâches Spark Jobs, étapes et tâches Spark

Tableau 2. Comparaison des fonctionnalités et des niveaux d’abstraction des API RDD et DataFrame : (créé par Kuriko IWAI)

4. Pour conclure

L’objectif du réglage de Spark n’est pas de maximiser indépendamment le nombre de partitions ou l’utilisation du processeur.

Il s’agit de réduire le coût et la latence de l’exécution de bout en bout sans introduire d’instabilité.

Dans cet article, nous avons découvert les relations fondamentales au sein de son système :

  • Ressources : Les workers hébergent les exécuteurs ; les exécuteurs exécutent les tâches ; le processeur et la mémoire configurés limitent le nombre d’opérations exécutées simultanément.
  • Programmation avec RDD : Les transformations produisent des définitions de RDD immuables et leur lignée ; les actions déclenchent le calcul.
  • Programmation avec DataFrame : Les transformations produisent des opérations de requête logiques ; l’optimisation choisit les opérateurs physiques exécutables ; les actions déclenchent le calcul.
  • Exécution : Spark organise le travail en jobs, étapes et tâches, et les tâches traitent les partitions sur les exécuteurs. Les échanges de données et d’autres dépendances créent des frontières de coordination entre les étapes.

Comprendre comment Spark exécute des charges de travail distribuées n’est que le point de départ.

Vous pouvez découvrir un processus de calcul en sept étapes avec l’API RDD.

Kuriko IWAI est ingénieure senior en apprentissage automatique chez Kernel Labs, un centre de recherche et d’ingénierie spécialisé dans la transformation de travaux de recherche en apprentissage automatique en pipelines automatisés prêts pour la production.

Elle est spécialisée dans la conception de systèmes d’apprentissage automatique, notamment dans l’architecture de l’IA générative, la traçabilité de l’apprentissage automatique et le traitement automatique du langage naturel avancé.
Forte d’une vaste expérience en gestion de produits dans toute l’Asie du Sud-Est, Kuriko excelle à faire converger l’expérimentation technique et la valeur commerciale.

Elle travaille actuellement avec une équipe d’Indeed à la création de pipelines d’automatisation.