diff --git a/.github/workflows/bookdown-test.yaml b/.github/workflows/bookdown-test.yaml index 940f4228..67c37673 100644 --- a/.github/workflows/bookdown-test.yaml +++ b/.github/workflows/bookdown-test.yaml @@ -30,7 +30,7 @@ jobs: - name: Render Book run: | quarto render - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: name: _public path: _public/ @@ -39,7 +39,7 @@ jobs: if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} uses: actions/setup-node@v2 with: - node-version: '14' + node-version: '18' - name: Deploy to Netlify if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} # NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID added in the repo's secrets diff --git a/.github/workflows/prod.yaml b/.github/workflows/prod.yaml index 6b7bbd90..87fe8470 100644 --- a/.github/workflows/prod.yaml +++ b/.github/workflows/prod.yaml @@ -75,7 +75,7 @@ jobs: if: ${{ github.repository == 'inseefrlab/utilitr' }} uses: actions/setup-node@v2 with: - node-version: '14' + node-version: '18' - name: Deploy to Netlify if: ${{ github.repository == 'inseefrlab/utilitr' }} # NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID added in the repo's secrets diff --git a/03_Fiches_thematiques/Fiche_arrow.qmd b/03_Fiches_thematiques/Fiche_arrow.qmd new file mode 100644 index 00000000..9644bfe4 --- /dev/null +++ b/03_Fiches_thematiques/Fiche_arrow.qmd @@ -0,0 +1,628 @@ +# Manipuler des données avec `arrow` {#arrow} + +## 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). + +::: {.callout-important} +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`](#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 recommandé d'utiliser soit le *package* `data.table` qui fait l'objet de la fiche [Manipuler des données avec `data.table`](#datatable), soit le *package* `arrow` qui fait l'objet de la présente fiche, avec éventuellement `duckdb` en complément. + +- 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. + +::: + +::: {.callout-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](#importparquet) et [Manipuler des données avec le `tidyverse`](#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). +::: + +## Présentation du package `arrow` et du projet associé + +### Qu'est-ce qu'`arrow`? {#sec-presentation} + +[Apache `Arrow`](https://arrow.apache.org/) 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`](https://pola.rs/), 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. + +### 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](https://raw.githubusercontent.com/InseeFrLab/ssphub/news-janvier/infolettre/infolettre_17/parquet.png) +
+ + +- __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](#importparquet) 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 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 @sec-lazy présente en détail cette notion. + +### 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. + +### 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). + +## 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. + +### 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. + +```{r} +#| output: false +library(arrow) +library(dplyr) + +options(arrow.use_threads = TRUE) # <1> +arrow::set_cpu_count(parallel::detectCores() %/% 2) +``` +1. Autoriser `arrow` à utiliser plusieurs processeurs en parallèle +2. Définir le nombre de processeurs qu'arrow peut utiliser +### 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`](@sec-presentation)). 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. + +```{r} +# 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). + +![](../pics/arrow/diff_environnement.png) + +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). + +```{r} +# Affichage d'un tibble +bpe_ens_2018_tbl +``` + +```{r} +# Affichage d'un Arrow Table +bpe_ens_2018_arrow +``` + +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()`. + +::: {.callout-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`.** + + +```{r} +#| eval: false +#| code-fold: true +#| code-summary: 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) +``` +::: + +### 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. + +:::: {.columns} + +::: {.column width="49%"} + +__Manipulation d'un `tibble`__ + +```{r eval=FALSE,message=FALSE} +bpe_ens_2018_tbl |> + group_by(REG) |> + summarise( + NB_EQUIP_TOT = sum(NB_EQUIP) + ) +``` + +::: + +::: {.column width="2%"} + +::: + +::: {.column width="49%"} + +__Manipulation d'un `Arrow Table`__ + +```{r eval=FALSE,message=FALSE} +bpe_ens_2018_arrow |> + group_by(REG) |> + summarise( + NB_EQUIP_TOT = sum(NB_EQUIP) + ) |> + collect() +``` + +::: + +:::: + +### 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](https://arrow.apache.org/docs/dev/r/reference/acero.html). 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`". + + +### L'évaluation différée avec `arrow` (_lazy evaluation_) {#sec-lazy} + +__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. + + +```{r, message=FALSE} +# É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. + +## 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`: + +- Utiliser correctement l'évaluation différée; +- Utiliser `compute()` plutôt que `collect()`; +- Utiliser `open_dataset()` plutôt que `read_parquet()`; +- Surveiller la consommation de RAM de `R`. + + +### Savoir bien utiliser l'évaluation différée + +La @sec-lazy 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](#importparquet) décrit en détail ce qu'est un fichier Parquet partitionné et comment le manipuler. + +```{r, message=FALSE} +# Sauvegarder la BPE 2018 sous la forme d'un dataset Arrow partitionné +write_dataset( + bpe_ens_2018_arrow, + "bpe2018/", + partitioning = "REG", + hive_style = TRUE +) +``` + +#### 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. + +```{r, message=FALSE} +# É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é. + + ```{r, message=FALSE} + # Imprimer la première requête + show_exec_plan(ds_bpe2018) + ``` + + + 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`). + + ```{r, message=FALSE} + # Imprimer la deuxième requête, qui contient la première + show_exec_plan(eq_dep) + ``` + + + 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()`. + + ```{r, message=FALSE} + # Imprimer la troisième requête, qui contient les deux premières + show_exec_plan(resultats) + ``` + +#### 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: + +```{r, eval=FALSE} +# 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. + +#### 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. + +### 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. + +### 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: + +:::: {.columns} + +::: {.column width="49%"} + +__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. + +```{r eval=FALSE,message=FALSE} +# 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") +``` + +::: + +::: {.column width="2%"} + +::: + +::: {.column width="49%"} + +__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. + + +```{r eval=FALSE,message=FALSE} +# 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") +``` + +::: + +:::: + +::: {.callout-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"`. +::: + +### 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__; la fiche [Superviser sa session `R`]{#superviser-ressources}. +- 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. + + + +## Notions avancées + +### Connaître les limites d'`arrow` {#sec-limites-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: + + ```{r eval=FALSE, message=FALSE} + # 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). + + ```{r eval=FALSE, message=FALSE} + # Comment empiler de multiples Arrow Tables + resultats <- table1 |> + union(table2) |> + union(table3) |> + union(table4) |> + compute() + ``` + + +### 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. + +::: {.callout-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()`. +::: + +#### 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`](https://arrow.apache.org/docs/dev/r/reference/acero.html) 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`. + +```{r eval=FALSE,message=FALSE} +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`: + +```{r eval=FALSE,message=FALSE} +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() +``` + +#### 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 @sec-limites-arrow), contrairement à `duckdb`. Voici comment faire avec `duckdb`: + +```{r eval=FALSE, message=FALSE} +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. + + +#### 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: + +- [un post de blog qui décrit en détail les liens entre `libarrow` et `R`](https://blog.djnavarro.net/posts/2022-01-18_binding-arrow-to-r/) (en anglais); +- la partie du [`Apache Arrow R Cookbook`](https://arrow.apache.org/cookbook/r/manipulating-data---tables.html#use-arrow-functions-in-dplyr-verbs-in-arrow) qui porte sur les `Arrow functions`. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +## Pour en savoir plus {#RessourcesArrow} + +- la documentation officielle du _package_ [`arrow`](https://arrow.apache.org/docs/dev/r/index.html) (en anglais); +- [un post de blog qui décrit en détail les liens entre `libarrow` et `R`](https://blog.djnavarro.net/posts/2022-01-18_binding-arrow-to-r/) (en anglais); +- la [liste](https://arrow.apache.org/docs/dev/r/reference/acero.html) des fonctions du _tidyverse_ supportées par `acero`. + diff --git a/03_Fiches_thematiques/Fiche_datatable.qmd b/03_Fiches_thematiques/Fiche_datatable.qmd index a1243776..8e52ddd7 100644 --- a/03_Fiches_thematiques/Fiche_datatable.qmd +++ b/03_Fiches_thematiques/Fiche_datatable.qmd @@ -7,8 +7,8 @@ L'utilisateur souhaite manipuler des données structurées sous forme de `data.f ::: {.callout-important} ## Tâche concernée et recommandation -* 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 le package `dplyr` ; -* Pour des tables de données de grande taille (plus de 1 Go ou plus d'un million d'observations), il est recommandé d'utiliser le package `data.table` qui fait l'objet de la présente fiche. +* 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`](#tidyverse); +* Pour des tables de données de grande taille (plus de 1 Go ou plus d'un million d'observations), il est recommandé d'utiliser soit le _package_ `data.table` qui fait l'objet de la présente fiche, soit les _packages_ `arrow` et `duckdb` présentés dans les fiches [Manipuler des données avec `arrow`](#arrow) et [Manipuler des données avec `duckdb`](#duckdb). ::: ::: {.callout-note} diff --git a/03_Fiches_thematiques/Fiche_import_fichiers_parquet.qmd b/03_Fiches_thematiques/Fiche_import_fichiers_parquet.qmd index 8a2726bf..d55b6d7e 100644 --- a/03_Fiches_thematiques/Fiche_import_fichiers_parquet.qmd +++ b/03_Fiches_thematiques/Fiche_import_fichiers_parquet.qmd @@ -130,7 +130,7 @@ Partitionner un fichier revient à le "découper" selon une clé de partitionnem ::: {.callout-tip} - **Il est important de bien choisir les variables de partitionnement** d'un fichier **Parquet**. Il faut choisir des variables faciles à comprendre et qui soient cohérentes avec l'usage des données (année, département, secteur...). En effet, un partitionnement bien construit induit par la suite des gains d'efficacité sur les traitements et facilite la maintenance du fichier sur le long terme. - **Il est inutile de partitionner des données de petite taille**. Si les données dépassent quelques millions d'observations et/ou si leur taille en CSV dépasse quelques giga-octets, il est utile de partitionner. -- **Il ne faut pas partitionner les données en trop de fichiers**. En pratique, il est rare d'avoir besoin de plus qu'une ou deux variables de partitionnement. +- **Il ne faut pas partitionner les données en trop de fichiers**. En pratique, il est rare d'avoir besoin de plus d'une ou deux variables de partitionnement. ::: Pour créer des fichiers **Parquet** partitionnés, il faut utiliser la fonction [`write_dataset()`](https://arrow.apache.org/docs/r/reference/write_dataset.html) du _package_ `arrow`. Voici ce que ça donne sur le fichier de la BPE : @@ -183,7 +183,7 @@ donnees <- arrow::read_parquet( ) ``` -Dans les trois cas, le résultat obtenu est un objet directement utilisable dans R. +Dans les trois cas, le résultat obtenu est un objet directement utilisable dans `R`. ### Cas des données volumineuses: utiliser des requêtes `dplyr` diff --git a/03_Fiches_thematiques/Fiche_tidyverse.qmd b/03_Fiches_thematiques/Fiche_tidyverse.qmd index 1d66c294..b4945f50 100644 --- a/03_Fiches_thematiques/Fiche_tidyverse.qmd +++ b/03_Fiches_thematiques/Fiche_tidyverse.qmd @@ -9,7 +9,7 @@ L'utilisateur souhaite manipuler des données structurées sous forme de `data.f ## Tâche concernée et recommandation * 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 font l'objet de la présente fiche ; -* Pour des tables de données de grande taille (plus de 1 Go ou plus d'un million d'observations), il est recommandé d'utiliser le _package_ `data.table` qui fait l'objet de la fiche [Manipuler des données avec `data.table`](#datatable). +* Pour des tables de données de grande taille (plus de 1 Go ou plus d'un million d'observations), il est recommandé d'utiliser soit le _package_ `data.table` présenté dans la fiche [Manipuler des données avec `data.table`](#datatable), soit les _packages_ `arrow` et `duckdb` présentés dans les fiches [Manipuler des données avec `arrow`](#arrow) et [Manipuler des données avec `duckdb`](#duckdb). ::: ::: {.callout-note} diff --git a/_quarto.yml b/_quarto.yml index fb665590..644c1c19 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -87,6 +87,7 @@ book: chapters: - 03_Fiches_thematiques/Fiche_tidyverse.qmd - 03_Fiches_thematiques/Fiche_datatable.qmd + - 03_Fiches_thematiques/Fiche_arrow.qmd - part: "Manipuler des données avec R" chapters: - 03_Fiches_thematiques/Fiche_joindre_donnees.qmd diff --git a/pics/arrow/diff_environnement.png b/pics/arrow/diff_environnement.png new file mode 100644 index 00000000..cebc31f3 Binary files /dev/null and b/pics/arrow/diff_environnement.png differ