19  Manipuler des données avec arrow

19.1 Tâches concernées et recommandations

L’utilisateur souhaite manipuler des données structurées sous forme de data.frame par le biais de l’écosystème Arrow (sélectionner des variables, sélectionner des observations, créer des variables, joindre des tables).

Tâches concernées et recommandations
  • Pour des tables de données de taille petite et moyenne (inférieure à 1 Go ou moins d’un million d’observations), il est recommandé d’utiliser les packages tibble, dplyr et tidyr qui sont présentés dans la fiche Manipuler des données avec le tidyverse;

  • Pour des tables de données de grande taille (plus de 1 Go en CSV, plus de 200 Mo en Parquet, ou plus d’un million d’observations), il est possible d’utiliser soit le package data.table qui fait l’objet de la fiche Manipuler des données avec data.table, soit le package arrow qui fait l’objet de la présente fiche, avec éventuellement duckdb en complément. Dans la mesure où le trio Parquet / Arrow/ DuckDB devient de plus en plus central dans l’écosystème du traitement de la donnée et où ces outils présentent l’avantage d’être interopérables, il est recommandé de préférer ces solutions pour les traitements de données volumineuses.

  • Il est essentiel de travailler avec la dernière version d’arrow, de duckdb et de R car les packages arrow et duckdb sont en cours de développement.

  • Si les données traitées sont très volumineuses (plus de 5 Go en CSV, plus de 1 Go en Parquet ou plus de 5 millions d’observations), il est essentiel de manipuler uniquement des objets Arrow Table, plutôt que des tibbles. Cela implique notamment d’utiliser la fonction compute() plutôt que collect() dans les traitements intermédiaires.

  • Pour les personnes qui découvrent arrow, il est recommandé de partir de l’exemple de script de la Section 19.6 pour se familiariser avec l’usage d'arrow.

Note

Apprendre à utiliser arrow n’est pas difficile, car la syntaxe utilisée est quasiment identique à celle du tidyverse. Toutefois, une bonne compréhension du fonctionnement de R et de arrow est nécessaire pour bien utiliser arrow sur des données volumineuses. Voici quelques conseils pour bien démarrer:

  • Il est indispensable de lire les fiches Importer des fichiers Parquet et Manipuler des données avec le tidyverse avant de lire la présente fiche.
  • Il est complètement normal de rencontrer des erreurs difficiles à comprendre lorsqu’on commence à utiliser arrow, il ne faut donc pas se décourager.
  • Il ne faut pas hésiter à demander de l’aide à des collègues, ou à poser des questions sur les salons Tchap adaptés (le salon Langage R par exemple).

19.2 Présentation du package arrow et du projet associé

19.2.1 Qu’est-ce qu’arrow?

Apache Arrow est un projet open-source qui propose deux choses:

  • une représentation standardisée des données tabulaires en mémoire vive appelée Apache Arrow Columnar Format, qui est à la fois efficace (les traitements sont rapides), interopérable (différents langages de programmation peuvent accéder aux mêmes données, sans conversion des données dans un autre format) et indépendante du langage de programmation utilisé.
  • une implémentation de ce standard en C++, qui prend la forme d’une librairie C++ nommée libarrow. Il existe d’autres implémentations dans d’autres langages; l’implémentation en Rust est par exemple utilisée par le projet Polars, une librairie alternative à dplyr (R) ou Pandas (Python) pour le traitement de données.

Un point important à retenir est donc que arrow n’est pas un outil spécifique à R, et il faut bien distinguer le projet arrow (et la librairie C++ libarrow) du package R arrow. Ce package propose simplement une interface qui permet d’utiliser la librairie libarrow avec R, et il existe d’autres interfaces pour se servir de libarrow avec d’autres langages: en Python, en Java, en Javascript, en Julia, etc.

19.2.2 Spécificités de arrow

Le projet arrow présente cinq spécificités:

  • Représentation des données en mémoire : arrow organise les données en colonnes plutôt qu’en lignes (on parle de columnar format). Concrètement, cela veut dire que dans la RAM toutes les valeurs de la première colonne sont stockées de façon contiguë, puis les valeurs de la deuxième colonne, etc. Cette structuration des données rend les traitements très efficaces: si l’on veut par exemple calculer la moyenne d’une variable, il est possible d’accéder directement au bloc de mémoire vive qui contient l’intégralité de cette colonne (indépendamment des autres colonnes de la table de données), d’où un traitement très rapide.
Illustration de ce principe

Illustration de ce principe du stockage orienté colonnes (droite) par rapport au csv
  • Utilisation avec Parquet: arrow est souvent utilisé pour manipuler des données stockées en format Parquet. Parquet est un format de stockage orienté colonne conçu pour être très rapide en lecture (voir la fiche Importer des fichiers Parquet pour plus de détails). arrow est optimisé pour travailler sur des fichiers Parquet, notamment lorsqu’ils contiennent des données très volumineuses.

  • Traitement de données volumineuses: arrow est conçu pour traiter des données sans avoir besoin de les charger complètement dans la mémoire vive. Cela signifie qu’arrow est capable de traiter des données plus volumineuses que la mémoire vive (RAM) dont on dispose. C’est un avantage majeur en comparaison aux autres approches possibles en R (data.table et dplyr par exemple).

  • Interopérabilité: arrow est conçu pour être interopérable entre plusieurs langages de programmation tels que R, Python, Java, C++, etc. Cela signifie que les données peuvent être échangées entre ces langages sans avoir besoin de convertir les données, d’où des gains importants de temps et de performance.

  • Lazy Evaluation: arrow prend en charge la lazy evaluation (évaluation différée) dans certains contextes. Cela signifie que les traitements ne sont effectivement exécutés que lorsqu’ils sont nécessaires, ce qui peut améliorer les performances en évitant le calcul de résultats intermédiaires non utilisés. La Section 19.3.5 présente en détail cette notion.

19.2.3 A quoi sert le package arrow?

Du point de vue d’un statisticien utilisant R, le package arrow permet de faire trois choses:

  • Importer des données (exemples: fichiers CSV, fichiers Parquet, stockage S3) et les organiser en mémoire vive dans un objet Arrow Table;
  • Manipuler des données organisées dans un Arrow Table avec la syntaxe dplyr, ou avec le langage SQL (grâce à duckdb);
  • Écrire des données en format Parquet.

19.2.4 Quels sont les avantages d’arrow?

En pratique, le package arrow présente trois avantages:

  • Performances élevées: arrow est très efficace et très rapide pour la manipulation de données tabulaires (nettement plus performant que dplyr par exemple);
  • Usage réduit des ressources: arrow est conçu pour ne charger en mémoire que le minimum de données. Cela permet de réduire considérablement les besoins en mémoire, même lorsque les données sont volumineuses;
  • Facilité d’apprentissage grâce aux approches dplyr et SQL: arrow peut être utilisé avec les verbes de dplyr (select, mutate, etc.) et/ou avec le langage SQL grâce à duckdb. Par conséquent, il n’est pas nécessaire d’apprendre une nouvelle syntaxe pour utiliser arrow, on peut s’appuyer sur la ou les approches que l’on maîtrise déjà. En revanche, il est à noter que le package data.table n’est pas directement compatible avec arrow (il faut convertir les objets Arrow Table en data.table, opération longue lorsque les données sont volumineuses).

19.3 Que faut-il savoir pour utiliser arrow?

Le package arrow présente quatre caractéristiques importantes:

  • une structure de données spécifique: le Arrow Table;
  • une utilisation via la syntaxe dplyr;
  • un moteur d’exécution spécifique: acero;
  • un mode de fonctionnement particulier: l’évaluation différée.

19.3.1 Charger et paramétrer le package arrow

Pour utiliser arrow, il faut commencer par charger le package. Comme arrow s’utilise presque toujours avec dplyr en pratique, il est préférable de prendre l’habitude de charger les deux packages ensemble. Par ailleurs, il est utile de définir systématiquement deux réglages qui sont importants pour les performances d’arrow: autoriser arrow à utiliser plusieurs processeurs en parallèle, et définir le nombre de processeurs qu’arrow peut utiliser.

library(arrow)
library(dplyr)

# Autoriser arrow à utiliser plusieurs processeurs en parallèle
options(arrow.use_threads = TRUE)
# Définir le nombre de processeurs qu'arrow peut utiliser
arrow::set_cpu_count(parallel::detectCores() %/% 4)

19.3.2 Le data.frame version arrow: le Arrow Table

Le package arrow structure les données non pas dans un data.frame classique, mais dans un objet spécifique à arrow: le Arrow Table. Dans un objet Arrow Table, les données sont organisées en colonnes plutôt qu’en lignes, conformément aux spécifications d’arrow (voir la présentation d’arrow). Pour convertir un data.frame ou un tibble en Arrow Table, il suffit d’utiliser la fonction as_arrow_table().

Par rapport à un data.frame standard ou à un tibble, le Arrow Table se distingue immédiatement sur trois points. Pour illustrer ces différences, on utilise la base permanente des équipements 2018 (table bpe_ens_2018), transformée en tibble d’une part, et en Arrow Table d’autre part.

# Charger les données et les convertir en tibble
bpe_ens_2018_tbl   <- doremifasolData::bpe_ens_2018 |> as_tibble()

# Charger les données et les convertir en Arrow Table
bpe_ens_2018_arrow <- doremifasolData::bpe_ens_2018 |> as_arrow_table()

Première différence: alors que les data.frames et les tibbles apparaissent dans la rubrique Data de l’environnement RStudio (cadre rouge dans la capture d’écran), les objets Arrow Table apparaissent dans la rubrique Values (cadre blanc).

Deuxième différence: alors que l’affichage dans la console d’un data.frame ou tibble permet de visualiser les premières lignes, la même opération sur un Arrow Table affiche uniquement des métadonnées (nombre de lignes et de colonnes, nom et type des colonnes).

# Affichage d'un tibble
bpe_ens_2018_tbl
# A tibble: 1,035,564 × 7
   REG   DEP   DEPCOM DCIRIS    AN TYPEQU NB_EQUIP
   <chr> <chr> <chr>  <chr>  <dbl> <chr>     <dbl>
 1 84    01    01001  01001   2018 A401          2
 2 84    01    01001  01001   2018 A404          4
 3 84    01    01001  01001   2018 A504          1
 4 84    01    01001  01001   2018 A507          1
 5 84    01    01001  01001   2018 B203          1
 6 84    01    01001  01001   2018 C104          1
 7 84    01    01001  01001   2018 D233          1
 8 84    01    01001  01001   2018 F102          1
 9 84    01    01001  01001   2018 F111          1
10 84    01    01001  01001   2018 F113          1
# ℹ 1,035,554 more rows
# Affichage d'un Arrow Table
bpe_ens_2018_arrow
Table
1035564 rows x 7 columns
$REG <string>
$DEP <string>
$DEPCOM <string>
$DCIRIS <string>
$AN <double>
$TYPEQU <string>
$NB_EQUIP <double>

See $metadata for additional Schema metadata

Troisième différence: alors qu’il est possible d’afficher le contenu d’un data.frame ou d’un tibble en cliquant sur son nom dans la rubrique Data de l’environnement ou en utilisant la fonction View(), il n’est pas possible d’afficher directement le contenu d’un Arrow Table. Pour afficher le contenu d’un Arrow Table, il faut d’abord convertir le Arrow Table en tibble avec la fonction collect().

Tip

Il arrive fréquemment que l’on souhaite jeter un coup d’oeil au contenu d’un Arrow Table. Toutefois, convertir directement un Arrow Table très volumineux en tibble peut poser de sérieux problèmes: temps de conversion, consommation importante de RAM, voire plantage de R si le Arrow Table est vraiment très gros. Il est donc fortement conseillé de prendre un petit extrait du Arrow Table concerné et de convertir uniquement cet extrait en tibble.

Exemple de code qui visualise un échantillon d’une table Arrow
# Extraire les 1000 premières lignes du Arrow Table et les convertir en tibble
extrait_bpe <- bpe_ens_2018_arrow |> slice_head(n = 1000) |> collect()
View(extrait_bpe)

19.3.3 Manipuler des Arrow Table avec la syntaxe dplyr

Le package R arrow a été écrit de façon à ce qu’un Arrow Table puisse être manipulé avec les fonctions de dplyr (select, filter, mutate, left_join, etc.), comme si cette table était un data.frame ou un tibble standard.

Il est également possible d’utiliser sur un Arrow Table un certain nombre de fonctions des packages du tidyverse (comme stringr et lubridate). Cela s’avère très commode en pratique, car lorsqu’on sait utiliser dplyr et le tidyverse, on peut commencer à utiliser arrow sans avoir à apprendre une nouvelle syntaxe de manipulation de données. Il y a néanmoins des subtilités à connaître, détaillées dans la suite de cette fiche.

Dans l’exemple suivant, on calcule le nombre d’équipements par région, à partir d’un tibble et à partir d’un Arrow table. La seule différence apparente entre les deux traitement est la présence de la fonction collect() à la fin des instructions; cette fonction indique que l’on souhaite que le résultat du traitement soit stocké sous la forme d’un tibble. La raison d’être de ce collect() est expliquée plus loin, dans le paragraphe sur l’évaluation différée.

Manipulation d’un tibble

bpe_ens_2018_tbl |>
  group_by(REG) |>
  summarise(
    NB_EQUIP_TOT = sum(NB_EQUIP)
  )

Manipulation d’un Arrow Table

bpe_ens_2018_arrow |>
  group_by(REG) |>
  summarise(
    NB_EQUIP_TOT = sum(NB_EQUIP)
  ) |>
  collect()

19.3.4 Le moteur d’exécution d’arrow: acero

Il y a une différence fondamentale entre manipuler un data.frame ou un tibble et manipuler un Arrow Table. Pour bien la comprendre, il faut d’abord comprendre la distinction entre syntaxe de manipulation des données et moteur d’exécution:

  • La syntaxe de manipulation des données sert à décrire les manipulations de données qu’on veut faire (calculer des moyennes, faire des jointures…), indépendamment de la façon dont ces calculs sont effectivement réalisés;
  • le moteur d’exécution fait référence à la façon dont les opérations sur les données sont effectivement réalisées en mémoire, indépendamment de la façon dont elles ont été décrites par l’utilisateur.

La grande différence entre manipuler un tibble et manipuler un Arrow Table tient au moteur d’exécution: si on manipule un tibble avec la syntaxe de dplyr, alors c’est le moteur d’exécution de dplyr qui fait les calculs; si on manipule un Arrow Table avec la syntaxe de dplyr, alors c’est le moteur d’exécution d’arrow (nommé acero) qui fait les calculs. C’est justement parce que le moteur d’exécution d’arrow est beaucoup plus efficace que celui de dplyr qu’arrow est beaucoup plus rapide.

Cette différence de moteurs d’exécution a une conséquence technique importante: une fois que l’utilisateur a défini des instructions avec la syntaxe dplyr, il est nécessaire que celles-ci soient converties pour que le moteur acero (écrit en C++ et non en R) puisse les exécuter. De façon générale, arrow fait cette conversion de façon automatique et invisible, car le package arrow contient la traduction C++ de plusieurs centaines de fonctions du tidyverse. Par exemple, le package arrow contient la traduction C++ de la fonction filter() de dplyr, ce qui fait que les instructions filter() écrites en syntaxe tidyverse sont converties de façon automatique et invisible en des instructions C++ équivalentes. La liste des fonctions du tidyverse supportées par acero est disponible sur cette page. Il arrive toutefois qu’on veuille utiliser une fonction non supportée par acero. Cette situation est décrite dans le paragraphe “Comment utiliser une fonction non supportée par acero”.

19.3.5 L’évaluation différée avec arrow (lazy evaluation)

Une caractéristique importante d’arrow est qu’il pratique l’évaluation différée (lazy evaluation): les calculs ne sont effectivement réalisés que lorsqu’ils sont nécessaires. En pratique, cela signifie qu’arrow se contente de mémoriser les instructions, sans faire aucun calcul tant que l’utilisateur ne le demande pas explicitement. Il existe deux fonctions pour déclencher l’évaluation d’un traitement arrow: collect() et compute(). Il n’y a qu’une seule différence entre collect() et compute(), mais elle est importante: collect() renvoie le résultat du traitement sous la forme d’un tibble, tandis que compute() le renvoie sous la forme d’un Arrow Table.

L’évaluation différée permet d’améliorer les performances en évitant le calcul de résultats intermédiaires inutiles, et en optimisant les requêtes pour utiliser le minimum de données et le minimum de ressources. L’exemple suivant illustre l’intérêt de l’évaluation différée dans un cas simple.

# Étape 1: compter les équipements
eq_dep <- bpe_ens_2018_arrow |>
  group_by(DEP) |>
  summarise(
    NB_EQUIP_TOT = sum(NB_EQUIP)
  )

# Étape 2: filtrer sur le département
resultats <- eq_dep |> 
  filter(DEP == "59") |> 
  collect()

Dans cet exemple, on procède à un traitement en deux temps: on compte les équipements par département, puis on filtre sur le département. Il est important de souligner que la première étape ne réalise aucun calcul par elle-même, car elle ne comprend ni collect() ni compute(). L’objet equipements_par_departement n’est pas une table et ne contient pas de données, il contient simplement une requête (query) décrivant les opérations à mener sur la table bpe_ens_2018_arrow.

On pourrait penser que, lorsqu’on exécute l’ensemble de ce traitement, arrow se contente d’exécuter les instructions les unes après les autres: compter les équipements par département, puis conserver uniquement le département 59. Mais en réalité arrow fait beaucoup mieux que cela: arrow analyse la requête avant de l’exécuter, et optimise le traitement pour minimiser le travail. Dans le cas présent, arrow repère que la requête ne porte en fait que sur le département 59, et commence donc par filtrer les données sur le département avant de compter les équipements, de façon à ne conserver que le minimum de données nécessaires et à ne réaliser que le minimum de calculs. Ce type d’optimisation s’avère très utile quand les données à traiter sont très volumineuses.

19.4 Comment bien utiliser arrow?

Au premier abord, on peut avoir l’impression qu’arrow s’utilise exactement comme dplyr (c’est d’ailleurs fait exprès!). Il y a toutefois quelques différences qui peuvent avoir un impact considérable sur les performances des traitements. Cette partie détaille quatre recommandations à suivre pour bien utiliser arrow:

19.4.1 Savoir bien utiliser l’évaluation différée

La Section 19.3.5 a présenté la notion d’évaluation différée et son intérêt pour optimiser les performances. Toutefois, l’évaluation différée n’est pas toujours facile à utiliser, et présente des limites qu’il faut bien comprendre. Cette section décrit plus en détail le fonctionnement de l’évaluation différée et ses limites. Pour illustrer ce fonctionnement, on commence par exporter la base permanente des équipements sous la forme d’un dataset Arrow partitionné. La fiche Importer des fichiers Parquet décrit en détail ce qu’est un fichier Parquet partitionné et comment le manipuler.

# Sauvegarder la BPE 2018 sous la forme d'un dataset Arrow partitionné
write_dataset(
  bpe_ens_2018_arrow,
  "bpe2018/",
  partitioning = "REG",
  hive_style = TRUE
)

19.4.1.1 Comment fonctionne l’évaluation différée?

Ce paragraphe s’adresse aux lecteurs qui souhaitent comprendre plus en détail le fonctionnement de l’évaluation différée. Les lecteurs pressés peuvent passer directement au paragraphe suivant, sur les limites de l’évaluation différée.

Le traitement suivant est un exemple simple d’utilisation de l’évaluation différée. Ce traitement comprend trois étapes: se connecter aux données avec open_dataset(), puis calculer le nombre d’équipements par département, et enfin sélectionner le département 59.

# Étape 1: se connecter au fichier Paruet Partitionné
ds_bpe2018 <- open_dataset(
  "bpe2018/",
  partitioning = schema("REG" = utf8()),
  hive_style = TRUE
)

# Étape 2: compter les équipements
eq_dep <- ds_bpe2018 |>
  group_by(DEP) |>
  summarise(
    NB_EQUIP_TOT = sum(NB_EQUIP)
  )

# Étape 3: filtrer sur le département
resultats <- eq_dep |> 
  filter(DEP == "59")

Voici quelques commentaires pour comprendre ce traitement:

  • Le code ci-dessus n’effectue aucun calcul, car il ne comprend ni collect() ni compute(). Il faut exécuter resultats |> collect() ou resultats |> compute() pour que les calculs soient effectivement réalisés.
  • Les objets ds_bpe2018, eq_dep et resultats ne sont pas des tables R standards contenant des données: ce sont des requêtes (de classe arrow_dplyr_query), qui décrivent des opérations à mener sur des données. C’est justement en utilisant collect() ou compute() qu’on demande à arrow d’exécuter ces requêtes avec le moteur acero.
  • Il est possible d’afficher le contenu des requêtes avec la fonction show_exec_plan().
    • La première requête est très courte: elle ne contient que la description des données contenues dans le fichier Parquet partitionné.

      # Imprimer la première requête
      show_exec_plan(ds_bpe2018)
      ExecPlan with 3 nodes:
      2:SinkNode{}
        1:ProjectNode{projection=[DEP, DEPCOM, DCIRIS, AN, TYPEQU, NB_EQUIP, REG]}
          0:SourceNode{}
    • La deuxième requête est un peu plus longue, et si on regarde en détail, on constate deux choses. Premièrement, elle contient la première requête, mais elle n’a conservé que les variables utilisées dans le traitement (NB_EQUIP et DEP). C’est un exemple d’optimisation faite par arrow: le moteur acero a compris automatiquement qu’il suffisait de charger seulement deux variables pour réaliser le traitement. Deuxièmement, on retrouve tous les éléments du traitement (notamment le group_by et la somme), mais le traitement décrit en syntaxe tidyverse a été traduit automatiquement en fonctions internes d’arrow (la fonction sum est par exemple remplacée par hash_sum).

      # Imprimer la deuxième requête, qui contient la première
      show_exec_plan(eq_dep)
      ExecPlan with 4 nodes:
      3:SinkNode{}
        2:GroupByNode{keys=["DEP"], aggregates=[
          hash_sum(NB_EQUIP_TOT, {skip_nulls=false, min_count=0}),
        ]}
          1:ProjectNode{projection=["NB_EQUIP_TOT": NB_EQUIP, DEP]}
            0:SourceNode{}
    • Enfin, la troisième requête est encore plus longue, et contient les deux premières. Autrement dit, elle contient l’intégralité du traitement, donc on réalise l’intégralité du traitement lorsqu’on exécute cette requête avec resultats |> collect().

      # Imprimer la troisième requête, qui contient les deux premières
      show_exec_plan(resultats)
      ExecPlan with 5 nodes:
      4:SinkNode{}
        3:FilterNode{filter=(DEP == "59")}
          2:GroupByNode{keys=["DEP"], aggregates=[
              hash_sum(NB_EQUIP_TOT, {skip_nulls=false, min_count=0}),
          ]}
            1:ProjectNode{projection=["NB_EQUIP_TOT": NB_EQUIP, DEP]}
              0:SourceNode{}

19.4.1.2 Quelles sont les limites de l’évaluation différée?

L’évaluation différée optimise les performances en minimisant la quantité de données chargées en RAM et la quantité de calculs effectivement réalisés. Avec cette vision en tête, on pourrait penser que la meilleure façon d’utiliser arrow est d’écrire un traitement entier en mode lazy (autrement dit, sans aucun compute() ni aucun collect() dans les étapes intermédiaires), et faire un unique compute() ou collect() tout à la fin du traitement, pour que toutes les opérations soient optimisées en une seule étape. Un traitement idéal ressemblerait alors à ceci:

# Se connecter aux données
data1 <- open_dataset("data1.parquet")
data2 <- open_dataset("data2.parquet")

# Une première étape de traitement
table_intermediaire1 <- data1 |>
  select(...) |>
  filter(...) |>
  mutate(...)

# Une deuxième étape de traitement
table_intermediaire2 <- data2 |>
  select(...) |>
  filter(...) |>
  mutate(...)

# Et encore beaucoup d'autres étapes de traitement
# avec beaucoup d'instructions...

# La dernière étape du traitement
resultats <- table_intermediaire8 |>
  left_join(
    table_intermediaire9, 
    by = "identifiant"
  ) |>
  compute()
  
write_parquet(resultats, "resultats.parquet")

La réalité n’est malheureusement pas si simple, car l’évaluation différée a des limites. En effet, au moment de produire le résultat final de l’exemple précédent, la fonction compute() donne l’instruction au moteur acero d’analyser puis d’exécuter l’intégralité du traitement en une seule fois (le paragraphe précédent donne un exemple détaillé). Or, le moteur acero est certes puissant, mais il a ses limites et ne peut pas exécuter en une seule fois des traitements vraiment trop complexes. Par exemple, acero rencontre des difficultés lorsqu’on enchaîne de multiples jointures de tables volumineuses.

Ces limites de l’évaluation différée peuvent provoquer des bugs violents. Lorsque le moteur acero échoue à exécuter une requête trop complexe, les conséquences sont brutales: R n’imprime aucun message d’erreur, la session R plante et il faut simplement redémarrer R et tout recommencer. Il est donc nécessaire de bien structurer le traitement pour profiter des avantages de l’évaluation différée sans en toucher les limites.

19.4.1.3 Décomposer le traitement en étapes cohérentes, puis le tester

La solution évidente pour ne pas toucher les limites de l’évaluation différée consiste à décomposer le traitement en étapes, et à exécuter chaque étape séparément, en mettant un compute(). De cette façon, acero va réaliser séquentiellement plusieurs traitements un peu complexes, plutôt qu’échouer à réaliser un seul traitement très complexe en une seule fois.

La vraie difficulté consiste à savoir quelle est la bonne longueur de ces étapes intermédiaires: s’il faut éviter de faire de très longues étapes (sinon l’évaluation différée plante), il faut également éviter d’exécuter une à une les étapes du traitement (sinon on perd les avantages de l’évaluation différée). Il n’y a pas de solution miracle, et seule la pratique permet de déterminer ce qui est raisonnable. Voici toutefois quelques conseils de bon sens:

  • Un point de départ raisonnable peut consister à définir des étapes de traitement qui ne dépassent pas 30 ou 40 lignes de code. Une étape de traitement de 200 lignes aura toutes les chances de poser des problèmes, d’autant qu’elle ne sera probablement pas très lisible.
  • Il est préférable que le séquencement des étapes soit cohérent avec l’objet du traitement. Par exemple, si l’ensemble du traitement consiste à retraiter séparément deux tables, puis à les joindre, on peut imaginer trois étapes qui s’achèvent chacune par un compute(): le retraitement de la première table, le retraitement de la seconde table, et la jointure.
  • Plus les données sont volumineuses, plus il faut être prudent avant de définir de longues étapes de traitement.
  • Plus les opérations unitaires sont complexes, plus les étapes doivent être courtes. Par exemple, si les opérations sont des filter() et des select(), il est possible d’en enchaîner un certain nombre en une seule étape de traitement sans aucun problème, car ces opérations sont simples. Inversement, une étape de traitement ne doit pas comprendre plus de trois ou quatre jointures (car les jointures sont des opérations complexes), en particulier si les tables sont volumineuses.

19.4.2 Lire des fichiers Parquet avec open_dataset() plutôt que read_parquet()

Il est recommandé d’utiliser systématiquement la fonction open_dataset() plutôt que la fonction read_parquet() pour accéder à des données stockées en format Parquet. En effet, la fonction open_dataset() présente deux avantages:

  • Consommation mémoire inférieure: la fonction open_dataset() crée une connexion au fichier Parquet, mais elle n’importe pas les données contenues dans le fichier tant que l’utilisateur ne le demande pas avec compute() ou collect(), et elle est optimisée pour importer uniquement les données nécessaires au traitement. Inversement, la fonction read_parquet() importe immédiatement dans R toutes les données du fichier Parquet, y compris des données qui ne servent pas à la suite du traitement.
  • Usage plus général: la fonction open_dataset() peut se connecter à un fichier Parquet unique, mais aussi à des fichiers Parquet partitionnés, tandis que read_parquet() ne peut pas lire ces derniers.

19.4.3 Utiliser des objets Arrow Table plutôt que des data.frames

Lorsqu’on manipule des données volumineuses, il est essentiel de manipuler uniquement des objets Arrow Table, plutôt que des data.frames (ou des tibbles). Cela implique deux recommandations:

  • Importer les données directement dans des Arrow Table, ou à défaut convertir en Arrow Table avec la fonction as_arrow_table(). Par exemple, lorsqu’on importe un fichier Parquet avec la fonction read_parquet() ou un fichier csv avec la fonction read_csv_arrow(), il est recommandé d’utiliser l’option as_data_frame = FALSE pour que les données soient importées sous forme de Arrow Table.

  • Utiliser systématiquement compute() plutôt que collect() dans les étapes de calcul intermédiaires. Cette recommandation est particulièrement importante.

    L’exemple suivant explique pourquoi il est préférable d’utiliser compute() dans les étapes intermédiaires:

Situation à éviter

La première étape de traitement est déclenchée par collect(), la table intermédiaire res_etape1 est donc un tibble. C’est le moteur d’exécution de dplyr qui est utilisé pour manipuler res_etape1 lors de la seconde étape, ce qui dégrade fortement les performances sur données volumineuses.

# Etape 1
res_etape1 <- bpe_ens_2018_tbl |>
  group_by(DEP) |>
  summarise(
    NB_EQUIP_TOT = sum(NB_EQUIP)
  ) |>
  collect()

# Etape 2
res_final <- res_etape1 |> 
  filter(DEP == "59") |> 
  collect()

# Sauvegarder les résultats
write_parquet(res_final, "resultats.parquet")

Usage recommandé

La première étape de traitement est déclenchée par compute(), la table intermédiaire res_etape1 est donc un Arrow Table. C’est le moteur d’exécution acero qui est utilisé pour manipuler res_etape1 lors de la seconde étape, ce qui assure de bonnes performances notamment sur données volumineuses.

# Etape 1
res_etape1 <- bpe_ens_2018_tbl |>
  group_by(DEP) |>
  summarise(
    NB_EQUIP_TOT = sum(NB_EQUIP)
  ) |>
  compute()

# Etape 2
res_final <- res_etape1 |> 
  filter(DEP == "59") |> 
  compute()

# Sauvegarder les résultats
write_parquet(res_final, "resultats.parquet")
Tip

Si vous ne savez plus si une table de données est un Arrow Table ou un tibble, il suffit d’exécuter class(le_nom_de_ma_table). Si la table est un Arrow Table, vous obtiendrez ceci: "Table" "ArrowTabular" "ArrowObject" "R6". Si elle est un tibble, vous obtiendrez "tbl_df" "tbl" "data.frame".

19.4.4 Surveiller la consommation de RAM de R

Comme expliqué plus haut, les Arrow Table ne sont pas des objets R standards, mais des objets C++ qui peuvent être manipulés avec R via arrow. En pratique, cela signifie que R n’a qu’un contrôle partiel sur la RAM occupé par arrow, et ne parvient pas toujours à libérer la RAM qu’arrow a utilisée temporairement pour réaliser un traitement. En particulier, la fonction gc() ne permet pas de libérer la RAM qu’arrow a utilisée temporairement. Cette imperfection de la gestion de la RAM implique deux choses:

  • Si on travaille sur des données volumineuses, il est important de surveiller fréquemment sa consommation de RAM pour s’assurer qu’elle n’est pas excessive (cf. fiche Superviser sa session R) ;
  • Si la consommation de RAM devient très élevée, la seule solution semble être de redémarrer la session R. En pratique, redémarrer la session R ne fait pas perdre plus de quelques minutes, car grâce à arrow et Parquet le chargement des données est très rapide.

19.5 Notions avancées

19.5.1 Connaître les limites d’arrow

Le projet arrow est relativement récent et en développement actif. Il n’est donc pas surprenant qu’il y ait parfois des bugs, et que certaines fonctions standards de R ne soient pas encore disponibles en arrow. Il est important de connaître les quelques limites d’arrow pour savoir comment les contourner. Voici quatre limites d’arrow à la date de rédaction de cette fiche (janvier 2024):

  • les jointures de tables volumineuses: arrow ne parvient pas à joindre des tables de données très volumineuses; il est préférable d’utiliser duckdb pour ce type d’opération;

  • les réorganisations de données (wide-to-long et long-to-wide): il n’existe pas à ce jour dans arrow de fonctions pour réorganiser une table de données (comme pivot_wider et pivot_longer du package tidyr).

  • les fonctions fenêtre (window functions): arrow ne permet pas d’ajouter directement à une table des informations issues d’une agrégation par groupe de la même table. Par exemple, arrow ne peut pas ajouter directement à la base permanente des équipements une colonne égale au nombre total d’équipements du département:

    # Arrow ne peut pas exécuter ceci
    data <- bpe_ens_2018_arrow |>
      group_by(DEP) |>
      mutate(
        NB_EQUIP_TOTAL_DEP  = sum(NB_EQUIP)
      ) |>
      compute()
  • les empilements de tables: il est facile d’empiler plusieurs tibbles avec dplyr grâce à la fonction bind_rows(): bind_rows(table1, table2, table3, table4). En revanche, il n’existe pas à ce jour de fonction similaire dans arrow. Les fonctions union et union_all permettent d’empiler seulement deux Arrow Table, donc pour empiler plusieurs Arrow Tables il faut appeler plusieurs fois ces fonctions. Par ailleurs, les deux Arrow Table doivent être parfaitement compatibles pour être empilés (il faut le même nombre de colonnes avec le même nom et le même type, ce qui n’est pas toujours le cas en pratique).

    # Comment empiler de multiples Arrow Tables
    resultats <- table1 |>
      union(table2) |>
      union(table3) |>
      union(table4) |>
      compute()

19.5.2 Surmonter le problème des fonctions non supportées par acero

Lorsqu’on manipule des données avec arrow, il arrive fréquemment qu’on écrive un traitement que le moteur d’exécution acero n’arrive pas à exécuter. En ce cas, R renonce à manipuler les données sous forme de Arrow Table avec le moteur acero, convertit les données en tibble et poursuit le traitement avec le moteur d’exécution de dplyr (comme un traitement dplyr standard). R signale systématiquement le recours à cette solution de repli par un message d’erreur qui se termine par pulling data into R.

Le recours à cette solution de repli a pour conséquence de dégrader fortement les performances (car le moteur de dplyr est moins efficace qu’acero). Il est donc préférable d’essayer de réécrire la partie du traitement qui pose problème avec des fonctions supportées par acero. Cela est particulièrement recommandé si les données manipulées sont volumineuses ou si le traitement concerné doit être exécuté fréquemment.

Tip

Il arrive qu’il soit impossible de trouver une solution entièrement supportée par acero, ou que la solution soit vraiment trop complexe à écrire. Ce n’est pas une catastrophe: en dernier recours, on peut tout à fait convertir temporairement les données en tibble (avec collect()) et exécuter le traitement qui pose problème avec dplyr. Le traitement sera simplement plus lent (voire beaucoup plus lent). En revanche, il est important de reconvertir ensuite les données en Arrow Table le plus vite possible, en utilisant la fonction as_arrow_table().

19.5.2.1 Une solution simple existe-t-elle?

Dans la plupart des cas, il est possible de trouver une solution simple pour écrire un traitement que le moteur acero peut exécuter. Voici quelques pistes:

  • Vérifier qu’on utilise la dernière version d’arrow et mettre à jour le package si ce n’est pas le cas;
  • Étudier en détail le message d’erreur renvoyé par R pour bien comprendre d’où vient le problème;
  • Regarder la liste des fonctions du tidyverse supportées par acero pour voir s’il est possible d’utiliser une fonction supportée par acero;
  • Faire des tests pour pour voir si une réécriture mineure du traitement peut régler le problème.

L’exemple qui suit montre que la solution peut être très simple, même lorsque l’erreur semble complexe. Dans cet exemple, on veut calculer le nombre de boulangeries (TYPEQU == "B203") et de poissonneries (TYPEQU == "B206") dans chaque département, en stockant les résultats dans un Arrow Table (avec compute()). Malheureusement, acero ne parvient pas à réaliser ce traitement, et R est contraint de convertir les données en tibble.

resultats <- bpe_ens_2018_arrow |>
  group_by(DEP) |>
  summarise(
    nb_boulangeries  = sum(NB_EQUIP * (TYPEQU == "B203")),
    nb_poissonneries = sum(NB_EQUIP * (TYPEQU == "B206"))
  ) |>
  compute()

Le message d’erreur renvoyé par R est la suivante: ! NotImplemented: Function 'multiply_checked' has no kernel matching input types (double, bool); pulling data into R. En lisant attentivement le message d’erreur et en le rapprochant du traitement, on finit par comprendre que l’erreur vient de l’opération sum(NB_EQUIP * (TYPEQU == "B203")): arrow ne parvient pas à faire la multiplication entre NB_EQUIP (un nombre réel) et (TYPEQU == "B203") (un booléen). La solution est très simple: il suffit de convertir (TYPEQU == "B203") en nombre entier avec la fonction as.integer() qui est supportée par acero. Le code suivant peut alors être entièrement exécuté par acero:

resultats <- bpe_ens_2018_arrow |>
  group_by(DEP) |>
  summarise(
    nb_boulangeries  = sum(NB_EQUIP * as.integer(TYPEQU == "B203")),
    nb_poissonneries = sum(NB_EQUIP * as.integer(TYPEQU == "B206"))
  ) |>
  compute()

19.5.2.2 Passer par duckdb

Il arrive qu’il ne soit pas possible de résoudre le problème en réécrivant légèrement le traitement. Une autre solution peut consister à passer par duckdb, qui permet de manipuler directement des objets Arrow Table de façon simple et transparente. Dans l’exemple suivant, on veut ajouter à la base permanente des équipements une colonne égale au nombre total d’équipements du département. Cette opération ne peut pas être exécutée par arrow (voir le paragraphe Section 19.5.1), contrairement à duckdb. Voici comment faire avec duckdb:

library(duckdb)

data <- bpe_ens_2018_arrow |>
  to_duckdb() |>
  group_by(DEP) |>
  mutate(
    NB_EQUIP_TOTAL_DEP  = sum(NB_EQUIP)
  ) |>
  to_arrow() |>
  compute()

Cet exemple appelle trois commentaires:

  • La fonction to_duckdb() sert à ce que duckdb puisse accéder à l’objet Arrow Table;
  • Symétriquement, la fonction to_arrow() sert à remettre les données dans un objet Arrow Table;
  • Les instructions figurant entre ces deux étapes (le group_by() puis le mutate()) sont exécutées par le moteur d’exécution de duckdb, de façon complètement transparente pour l’utilisateur.

19.5.2.3 Définir soi-même des fonctions arrow (utilisation avancée)

Si les pistes mentionnées précédemment ne fournissent pas de solution simple, il est possible d’aller plus loin et d’écrire ses propres fonctions arrow. Cette approche permet de faire beaucoup plus de choses mais elle nécessite de bien comprendre le fonctionnement d’arrow et les fonctions internes de la librairie libarrow. Il s’agit d’une utilisation avancée d’arrow qui dépasse le cadre de la documentation utilitR. Les lecteurs intéressés pourront consulter les deux ressources suivantes:

19.6 Un exemple de traitement de données avec arrow

Le code ci-dessous propose un exemple de traitement de données avec arrow qui suit les recommandations et conseils de la présente fiche. Vous pouvez le copier-coller et vous en inspirer pour construire vos propres traitements!

library(arrow)
library(dplyr)

# Autoriser arrow à utiliser plusieurs processeurs en parallèle
options(arrow.use_threads = TRUE)
# Définir le nombre de processeurs qu'arrow peut utiliser - ici on prend la partie entière du nombre de processeurs disponibles divisé par 4
arrow::set_cpu_count(parallel::detectCores() %/% 4)

##################
### Se connecter aux données
### Conseil: utiliser open_dataset() plutôt que read_parquet()
##################

# Cas 1: se connecter à un unique fichier Parquet
dataset1 <- open_dataset("mon/beau/dossier/dataset1.parquet")

# Cas 2: se connecter à un fichier Parquet partitionné par la variable DEP
dataset2 <- open_dataset(
  "mon/beau/dossier/dataset2/",
  partitioning = schema(
    DEP = utf8()
  )
)

##################
### Faire les traitements
### Conseils: 
### - Faire des étapes de traitement de 30-40 lignes, suivies d'un compute()
### - Ne pas utiliser collect() dans les calculs intermédiaires sur des données volumineuses
### - Faire attention à suivre la consommation de RAM
##################

# Une première étape de traitement portant sur le dataset1
table_intermediaire1 <- dataset1 |>
  select(...) |>
  filter(...) |>
  mutate(...) |>
  compute()

# Une première étape de traitement portant sur le dataset2
table_intermediaire2 <- dataset2 |>
  select(...) |>
  filter(...) |>
  mutate(...) |>
  compute()

# Et encore beaucoup d'autres étapes de traitement
# avec beaucoup d'instructions...

# La dernière étape du traitement
resultat_final <- table_intermediaire8 |>
  left_join(
    table_intermediaire9, 
    by = "identifiant"
  ) |>
  compute()


##################
### Visualiser un extrait d'une table intermédiaire
### Vous pouvez utiliser collect() sur un extrait des données
##################

extrait_table2 <- table_intermediaire2 |> slice_head(n = 1000) |> collect()
View(extrait_table2)

##################
### Visualiser les résultats finaux sous forme de tibble
### Vous pouvez utiliser collect() sur de petites données
##################

resultat_final_tbl <- resultat_final |> collect()


##################
### Exporter les résultats
### Conseil: partitionner les fichiers Parquet si les données sont volumineuses
##################

# Cas 1: exporter les résultats sous la forme d'un unique fichier Parquet
write_parquet(resultat_final, "mon/dossier/de/sortie/resultat_final.parquet")

# Cas 2: exporter les résultats sous la forme d'un fichier Parquet par les variables DEP et annee
write_dataset(
  resultat_final, 
  "mon/dossier/de/sortie/resultat_final/",
  format= "parquet",
  hive_style = TRUE,
  partitioning = c("DEP", "annee")
)

19.7 Pour en savoir plus