From c970ed2a6a66f3d02b07daec6f871df2aae77dbf Mon Sep 17 00:00:00 2001 From: slithiaote <41158851+slithiaote@users.noreply.github.com> Date: Thu, 22 Feb 2024 17:59:01 +0100 Subject: [PATCH] Proposition de fiche duckdb (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create Fiche_duckdb.qmd * Update Fiche_duckdb.qmd * Fiche duckdb : fin * Update 03_Fiches_thematiques/Fiche_duckdb.qmd Co-authored-by: Olivier Meslin <44379737+oliviermeslin@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Olivier Meslin <44379737+oliviermeslin@users.noreply.github.com> * Update Fiche_duckdb.qmd * Apply suggestions from code review Co-authored-by: Pierre Lamarche * Restructuration + commentaires de P Lamarche Aussi : ajout à la liste des contributeurs (suggestion d'Olivier Meslin) * Nuancer sur le volume de données * Compléter les conseils * Précision * Mettre un nom plus signifiant pour la connexion duckdb * Les backticks c'est la vie * L'italique aussi c'est la vie * Amélioration de la présentation du chargement des données * Reformulations * Les majuscules c'est la vie * Update Fiche_duckdb.qmd --------- Co-authored-by: Olivier Meslin <44379737+oliviermeslin@users.noreply.github.com> Co-authored-by: Pierre Lamarche Co-authored-by: Olivier Meslin --- 03_Fiches_thematiques/Fiche_duckdb.qmd | 574 +++++++++++++++++++++++++ DESCRIPTION | 1 + _quarto.yml | 1 + 3 files changed, 576 insertions(+) create mode 100644 03_Fiches_thematiques/Fiche_duckdb.qmd diff --git a/03_Fiches_thematiques/Fiche_duckdb.qmd b/03_Fiches_thematiques/Fiche_duckdb.qmd new file mode 100644 index 00000000..6ed16f8a --- /dev/null +++ b/03_Fiches_thematiques/Fiche_duckdb.qmd @@ -0,0 +1,574 @@ +# Manipuler des données avec `duckdb` {#duckdb} + +## 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 `duckdb` (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) lorsque les données tiennent en mémoire vive, soit le *package* `arrow` [Manipuler des données avec `arrow`](#arrow). Nous présentons ici les fonctionnalités du _package_ `duckdb` en complément ou en remplacement de `arrow`. + +- 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 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 recommandé de manipuler les données avec `duckdb` (et avec `arrow`) plutôt qu'avec le `tidyverse`. Il peut arriver que le volume de données soit tellement important qu'il ne soit pas possible de les traiter avec `duckdb` et `arrow`; il faut s'orienter vers d'autres infrastructures spécialisées Big Data (Spark par exemple). +::: + + +::: {.callout-note} +Apprendre à utiliser `duckdb` 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 `duckdb` est nécessaire pour bien utiliser `duckdb` sur des données volumineuses. Voici quelques conseils pour bien démarrer: + +- Il est indispensable de lire la fiche [Manipuler des données avec le `tidyverse`](#tidyverse) avant de lire la présente fiche. +- Il est recommandé de lire les fiches [Se connecter à une base de données](#bdd) et [Manipuler des données avec `arrow`](#arrow) avant de lire la présente fiche. +- Il est complètement normal de rencontrer des erreurs difficiles à comprendre lorsqu'on commence à utiliser `duckdb`, 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_ `duckdb` et du projet associé + +### Qu'est-ce que `duckdb`? {#sec-presentation} + +[`DuckDB`](https://duckdb.org/) est un projet *open-source* (license MIT) qui propose un moteur SQL optimisé pour les requêtes *online analytical processing* (OLAP): + +- un moteur SQL rapide, capable d'utiliser des données au format `parquet` sans les charger en mémoire, +- un dialecte SQL enrichi avec des fonctions qui facilitent l'analyse de données, +- une installation et un déploiement facile, +- un moteur portable, utilisable sous Windows, MacOS, Linux, et interfacé avec de nombreux langages de programmation (R, Python, Javascript, etc.). + +Un point important à comprendre est que `DuckDB` est un système de gestion de base de données (SGBD), similaire par exemple à un serveur PostgreSQL: il dispose donc de sa propre mémoire, et on peut lui envoyer directement des requêtes SQL, sans passer par `R`. +Un point important à retenir est donc que **`DuckDB` n'est pas un outil spécifique à `R`**, et il faut bien distinguer le projet `DuckDB` du *package* `R` `duckdb`. Ce *package* propose simplement une interface avec `R` parmi les autres interfaces existantes : Python, Java, Javascript, Julia, etc. + +Toutefois, `DuckDB` est très facile à utiliser avec `R`, ce qui permet de bénéficier des optimisations inhérentes au langage SQL, à la fois en terme d'utilisation de la mémoire et de rapidité de calcul. C'est de plus un bon intermédiaire avant de passer à des infrastructures avancées telles que spark ou oracle. + + +### À quoi sert le *package* `duckdb`? + +Du point de vue d'un statisticien utilisant `R`, le *package* `duckdb` permet de faire trois choses: + +- Importer des données (exemples: fichiers CSV, fichiers Parquet); +- Manipuler des données avec la syntaxe `dplyr`, ou avec le langage SQL; +- Écrire des données en format Parquet. + + +### Quels sont les avantages de `duckdb`? + +- **Disponibilité immédiate**: on peut pré-visualiser les données ou le résultat d'un calcul sans l'exécuter totalement, sans attendre le chargement des données; +- **Performances élevées**: `duckdb` est très rapide pour la manipulation de données tabulaires (nettement plus performant que `dplyr` par exemple); +- **Ne pas nécessairement charger les données en mémoire**: `duckdb` permet également de travailler directement sur des fichiers du disque dur; +- **Optimisations automatiques**: `duckdb` sélectionne automatiquement les colonnes nécessaires, et ne lit que les lignes nécessaires. Cela permet d'accélérer les calculs et 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: `duckdb` peut être utilisé avec les verbes de `dplyr` (`select`, `mutate`, etc.) et/ou avec le langage SQL. Par conséquent, il n'est pas nécessaire d'apprendre une nouvelle syntaxe pour utiliser `duckdb`, on peut s'appuyer sur la ou les approches que l'on maîtrise déjà. + + +### Quels sont les points d'attention à l'usage ? + +- __Représentation des données en mémoire__ : `duckdb` est un moteur SQL. Les lignes n'ont pas d'ordre pré-défini, et le résultat d'un traitement peut être dans un ordre aléatoire. +- __Traitement de données volumineuses__: `duckdb` peut traiter de gros volumes de données, qu'elles soient en mémoire vive ou sur le disque dur. Lorsque les données sont en mémoire vive, les _packages_ `duckdb` et `arrow` peuvent être utilisés conjointement de façon très efficace: cela veut dire concrètement que `duckdb` peut manipuler directement des données stockées dans un `Arrow Table`, sans aucun coût de conversion. Avec des données stockées sur le disque dur, `duckdb` est capable de faire les traitements sur des données plus volumineuses que la mémoire vive (RAM). C'est un avantage majeur en comparaison aux autres approches possibles en `R` (`data.table` et `dplyr` par exemple). Toutefois, il faut dans ce cas ajouter le temps de lecture des données au temps nécessaire pour le calcul. +- __*Évaluation différée*__: `duckdb` construit des requêtes SQL, qui sont exécutées uniquement quand nécessaire, après optimisation des étapes intermédiaires, et peuvent être exécutées partiellement. La @sec-lazy présente en détail cette notion. +- __*Traduction en SQL*__: `duckdb` traduit automatiquement les instructions `dplyr` en requêtes SQL (de la même façon qu'`arrow` traduit ces instructions en code C++). Il arrive toutefois que certaines fonctions de `dplyr` n'aient pas d'équivalent direct en `duckdb` et ne puissent être traduites automatiquement. Dans ce cas (qui est heureusement moins fréquent qu'avec `arrow`), il faut parfois utiliser une fonction SQL directement ou trouver une solution pour contourner le problème. La @sec-sql donne quelques trucs et astuces dans ce cas. +- __Interopérabilité__: `duckdb` 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. + +## Installation de `duckdb` + +Il suffit d'installer le _package_ `duckdb`. + +```{r eval=FALSE} +install.packages("duckdb", repos="https://cloud.r-project.org") +``` + +## Utilisation de `duckdb` + +Dans cette section, on présente l'utilisation basique de `duckdb`. C'est très facile: il n'est pas nécessaire de connaître le langage de SQL car il est possible d'utiliser `duckdb` avec la syntaxe `tidyverse`. + +### Charger le *package* `duckdb` + +Pour utiliser `duckdb`, il faut commencer par charger le *package*. Il est utile de charger le *package* `dplyr` pour utiliser la syntaxe `tidyverse`. + +```{r} +#| output: false +library(duckdb) +library(dplyr) +``` + +Le moteur `duckdb` fonctionnant "en dehors" de `R`, il détecte le nombre de processeurs et effectue les opérations en parallèle si possible. + + +### Connexion à une base de données + +**`duckdb` est une base de données distante et s'utilise comme telle: il faut ouvrir une connexion, puis "charger" les données dans la base de données pour les manipuler.** + +Comme beaucoup d'autres bases de données (distantes ou locales), on ouvre une connexion au moteur `duckdb` avec une base de données en mémoire vive de la façon suivante : + +```{r} +conn_ddb <- DBI::dbConnect(duckdb::duckdb()) +``` + +Concrètement, cette commande crée une nouvelle base de données `duckdb` dans la mémoire vive. Cette base de données ne contient aucune donnée lorsqu'elle est créée. L'objet `conn_ddb` apparaît dans l'onglet `Data` de l'environnement `RStudio`, mais la liste des tables n'y est pas directement accessible. Pour plus d'informations, se reporter à la documentation du _package_ `DBI`. + +À la fin du traitement ou du programme, on ferme la connexion avec le code ci-dessous. L'option `shutdown` est importante : elle permet de fermer complètement la session `duckdb` et de libérer la mémoire utilisée. Si on n'utilise pas cette option, il arrive souvent d'avoir des connexions à moitié ouvertes, et de devoir relancer la session `R`. + +```{r} +DBI::dbDisconnect(conn_ddb, shutdown = TRUE) +``` + +Pour la suite, on supposera que la connexion est ouverte. + +```{r} +conn_ddb <- DBI::dbConnect(duckdb::duckdb()) +``` + +### Chargement des données + +Une fois qu'on s'est connecté à une base de données duckDB, il faut charger des données dans cette base de données. Il y a deux façons de le faire: + +- En établissant un lien entre la base de données duckDB et les objets de la session `R`; +- En indiquant à `duckdb` l'emplacement des données sur le disque dur. + +#### Déclaration de données provenant de `R` + +__On charge des données dans `duckdb` au travers de la connexion `conn_ddb` avec la fonction `duckdb_register()`.__ Cette méthode a l'avantage de ne pas _recopier_ les données: elle se contente d'établir un lien entre la base de données `duckdb` et un objet de la session `R`. Il existe d'autres moyens de charger les données dans `duckdb` (notamment en passant par `arrow`). Voici un exemple avec la Base permanente des équipements. + +```{r} +# Charger la Base permanente des équipements 2018 dans la session R +bpe_ens_2018 <- doremifasolData::bpe_ens_2018 |> as_tibble() + +# Etablir le lien entre la base de données duckdb et la table de données +conn_ddb %>% duckdb::duckdb_register( + name = "bpe_ens_2018_duckdb", + df = bpe_ens_2018) +``` + +Le code ci-dessous permet de vérifier que le chargement des données a bien fonctionné. La fonction `tbl` permet d'accéder à un objet de la base de données par le nom (de la table), ou par du code SQL (utilisation un peu plus avancée). Par défaut, `duckdb` affiche les 10 premières lignes du résultat, sans effectuer tout le calcul. C'est très pratique et très rapide ! + +```{r} +conn_ddb %>% tbl("bpe_ens_2018_duckdb") +``` + + +#### Déclaration de données stockées sur le disque dur + +Pour l'exemple, on sauvegarde les données `bpe_ens_2018` au format Parquet. + +```{r} +bpe_ens_2018 |> arrow::write_dataset("bpe_ens_2018_dataset") +``` + +**Pour utiliser un fichier Parquet dans `duckdb` sans le charger en mémoire, on propose deux méthodes:** utiliser `arrow`, ou passer directement par `duckdb`. Il est recommandé de privilégier la première méthode qui est plus simple. + +En passant par `arrow`. Cette méthode est généralisable aux autres formats de données lisibles par `arrow`, notamment CSV, mais utilise des objets intermédiaires : + +```{r} +# Créer une connexion au dataset Parquet +bpe_ens_2018_dataset <- arrow::open_dataset("bpe_ens_2018_dataset") + +# Etablir le lien entre la base de données duckdb et le dataset Parquet +bpe_ens_2018_dataset %>% arrow::to_duckdb(conn_ddb) +``` + +En passant directement par `duckdb`, il faut travailler un peu plus pour construire les noms de fichiers : + +```{r messages=FALSE} +conn_ddb %>% tbl("read_parquet('bpe_ens_2018_dataset/*.parquet')", check_from = FALSE) +``` + +Ces deux méthodes utilisent les données directement depuis le dataset Parquet. Les données ne sont chargées ni dans la mémoire de `R`, ni dans celle de `DuckDB`. Pour plus de commodité, on sauvegarde l'instruction précédente dans la variable `bpe_ens_2018_dataset`. + +```{r} +bpe_ens_2018_dataset <- conn_ddb %>% + tbl("read_parquet('bpe_ens_2018_dataset/*.parquet')", check_from = FALSE) +``` + + +### Manipulation des données avec la syntaxe `dplyr` + +Le _package_ `R` `duckdb` (en fait `dbplyr`) a été écrit de façon à pouvoir manipuler les données avec la syntaxe de `dplyr` (`select`, `filter`, `mutate`, `left_join`, etc.). `duckdb` traduit le code `R`, y compris certaines fonctions de `stringr` et `lubridate` en requête SQL. Cela s'avère très commode en pratique, car lorsqu'on sait utiliser `dplyr` et le `tidyverse`, on peut commencer à utiliser `duckdb` sans avoir à apprendre une nouvelle syntaxe de manipulation de données. + + +Dans l'exemple suivant, on calcule le nombre d'équipements par région, à partir d'un `tibble` et à partir d'une table `duckdb`. 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. Les résultats sont identiques, à l'exception de l'ordre des lignes. En effet, un moteur SQL ne respecte pas l'ordre par défaut, il faut le demander explicitement avec `arrange`. + +:::: {.columns} + +::: {.column width="49%"} + +__Manipulation d'un `tibble`__ + +```{r message=FALSE} +doremifasolData::bpe_ens_2018 |> + group_by(REG) |> + summarise( + NB_EQUIP_TOT = sum(NB_EQUIP) + ) +``` + +::: + +::: {.column width="2%"} + +::: + +::: {.column width="49%"} + +__Manipulation d'une table `duckdb`__ + +```{r} +bpe_ens_2018_dataset |> + group_by(REG) |> + summarise( + NB_EQUIP_TOT = sum(NB_EQUIP) + ) |> + collect() +``` + +::: + +:::: + + +On peut examiner la requête SQL construite par `duckdb` avec la fonction `show_query()`. + +```{r} +bpe_ens_2018_dataset |> + group_by(REG) |> + summarise( + NB_EQUIP_TOT = sum(NB_EQUIP) + ) |> + show_query() +``` + +Cette requête est envoyée au serveur SQL et exécutée de façon différente en fonction de la dernière instruction du traitement: + +- si le traitement se termine par `collect()`: le calcul est exécuté en entier et le résultat est retourné sous la forme d'un `tibble`, +- si le traitement se termine par `print(n=nb_lignes)`: le calcul est exécuté partiellement, et seules les `nb_lignes` demandées sont affichées. Cela permet de minimiser les ressources et la mémoire utilisées. + +__Ce point est important: en utilisant `print()`, on peut prévisualiser le résultat d'une requête `duckdb` de façon très rapide, sans exécuter tout le traitement.__ Il ne faut pas hésiter à s'en servir pour explorer les données et pour construire le traitement étape par étape, en ajustant en fonction des résultats. + + + + +### Écriture au format Parquet + +**Pour écrire une table (ou n'importe quelle requête) sur le disque au format Parquet, on suggère d'utiliser la librairie `arrow`.** + +```{r} +bpe_ens_2018_dataset %>% + arrow::to_arrow() %>% arrow::write_dataset("temp_dataset") +list.files("temp_dataset") # liste des fichiers du répertoire temp_dataset/ +``` + +Pour un usage basique en syntaxe `dplyr`, passer par `arrow` (au lieu de SQL) est plus facile à manipuler, notamment quand on souhaite ajouter des options telle que le partitionnement. + + + +### Erreurs courantes + +**On a éliminé des colonnes nécessaires** + +```{r error=TRUE} +bpe_ens_2018_dataset |> select(DEP) |> + mutate(NB_EQUIP_TOTAL_DEP = sum(NB_EQUIP)) +``` + +**Convertir les types** + +Dans cet exemple, on veut multiplier un nombre par une indicatrice. +```{r error=TRUE} +bpe_ens_2018_dataset %>% + summarise(nb_boulangeries = sum(NB_EQUIP * (TYPEQU == "B203")), .by = DEP) +``` + +Avec `duckdb`, il faut transformer explicitement un booléen en nombre (entier ou flottant). + +```{r} +bpe_ens_2018_dataset %>% + summarise(nb_boulangeries = sum(NB_EQUIP * as.integer(TYPEQU == "B203")), .by = DEP) +``` + + + +## Notions avancées / bien utiliser `duckdb` + + +### L'évaluation différée avec `duckdb` (_lazy evaluation_) {#sec-lazy} + +Quand on manipule des objets `duckdb`, on construit des requêtes SQL. Le _package_ `duckdb` se contente de traduire le code `R` en `SQL` sans l'exécuter. On rappelle qu'il faut utiliser `show_query()` pour visualiser la requête. La fonction `print()` permet de pré-visualiser le résultat. +```{r} +# Étape 1: compter les équipements +req_dep <- + bpe_ens_2018_dataset |> + group_by(DEP) |> + summarise( + NB_EQUIP_TOT = sum(NB_EQUIP) + ) +req_dep |> show_query() + +# Étape 2: filtrer sur le département +req_dep_filter <- req_dep |> + filter(DEP == "59") +req_dep_filter |> show_query() +``` + +La fonction `collect()` fait exécuter le calcul, et transmet les résultats à `R`. Avant `collect()`, c'est le moteur SQL de `duckdb` qui fait / fera les calculs. Après `collect()`, c'est `R` qui fait le calcul, et l'on manipule un objet `R` (`tibble`) standard, avec toutes les fonctions / _packages_ disponibles. +```{r} +req_dep_filter |> collect() +``` + +Pour bénéficier de la rapidité du moteur SQL, il faut faire le plus de calculs possibles avant `collect()` ! + +On pourrait penser que, lorsqu'on exécute l'ensemble de ce traitement, `duckdb` 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é `duckdb` fait beaucoup mieux que cela: __`duckdb` analyse la requête avant de l'exécuter, et optimise le traitement pour minimiser le travail__. Dans le cas présent, `duckdb` 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. + + +:::: {.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} +# Etape 1 +res_etape1 <- + bpe_ens_2018_dataset |> + 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 +arrow::write_parquet(res_final, "resultats.parquet") +``` + +::: + +::: {.column width="2%"} + +::: + +::: {.column width="49%"} + +__Usage recommandé__ + +La première étape construit une requête SQL, sans effectuer de calcul. La deuxième étape complète la requête sans effectuer de calcul. Ici, pas de fonction `print()`, donc pas de calcul partiel. Le calcul n'est exécuté qu'au moment de la sauvegarde des résultats par `DuckDB`, ce qui assure de bonnes performances notamment sur données volumineuses. Les données ne passent pas par la mémoire de `R`. + + +```{r} +# Etape 1 +res_etape1 <- bpe_ens_2018_dataset |> + group_by(DEP) |> + summarise( + NB_EQUIP_TOT = sum(NB_EQUIP) + ) + +# Etape 2 +res_final <- res_etape1 |> + filter(DEP == "59") + +# Sauvegarder les résultats +res_final |> arrow::to_arrow() |> + arrow::write_parquet("resultats.parquet") +``` + +::: + +:::: + +::: {.callout-tip} +Si vous ne savez plus si une table de données est une requête SQL ou un `tibble`, il suffit d'exécuter `print()`. +::: + + +### Fonctions non traduites et/ou comment passer des paramètres ? {#sec-sql} + +Que faire quand la traduction automatique ne fonctionne pas ? Pour les fonctions non traduites ou bien pour spécifier un paramètre à une fonction, il faut mettre les mains dans le mécanisme de traduction vers SQL. + +**Facile : les fonctions non connues par le _package_ `duckdb` sont écrites directement dans le code SQL**, y compris lorsqu'elles n'existent pas. +```{r} +req <- bpe_ens_2018_dataset |> + mutate(test = fonction_inexistante(DEP)) |> + show_query() +``` + +On peut se servir de cela pour utiliser directement des fonctions du moteur `DuckDB`, ou préciser des paramètres. Un exemple très utile : préciser le format des dates. +```{r} +dates <- tibble(date_naissance = c("02/07/1980", "29/02/2004"), + date_deces = c("05/06/2001", "12/07/2023"),) +conn_ddb %>% duckdb::duckdb_register(name = "dates_duckdb", df = dates, overwrite = TRUE) +``` + +```{r error=TRUE} +conn_ddb %>% tbl("dates_duckdb") %>% + mutate(date_naissance = as.Date(date_naissance, "%d/%m/%Y")) # erreur +``` + +On peut utiliser la fonction [`strptime`](https://duckdb.org/docs/sql/functions/dateformat.html) du moteur SQL `DuckDB` en indiquant le paramètre adéquat : +```{r} +conn_ddb %>% tbl("dates_duckdb") %>% + mutate(date_naissance = strptime(date_naissance,"%d/%m/%Y")) +``` + +On a ainsi, très facilement, accès à l'ensemble des fonctions optimisées de `DuckDB` disponibles ([documentation ici](https://duckdb.org/docs/sql/functions/overview)). + +Note : `duckdb` ne propose pas de convertir automatiquement les objets en `tibble` pour faire passer les calculs. + +**Avancé :** utilisation sur plusieurs variables avec `mutate_at`. + +```{r} +liste_variables <- c("date_naissance","date_deces") +conn_ddb %>% tbl("dates_duckdb") %>% + mutate_at(liste_variables, ~ strptime(.,"%d/%m/%Y")) +``` + + +### Exécuter du code SQL directement + +`DuckDB` étant un serveur SQL à part entière, on peut interagir avec `DuckDB` directement avec des requêtes SQL. Par exemple, + +```{r} +DBI::dbGetQuery(conn_ddb, "SELECT * FROM bpe_ens_2018_duckdb") |> head() +``` + +Par exemple, on peut créer des vues ou des tables explicitement. La fonction `dbExecute` retourne le nombre de lignes modifiées, tandis que la fonction `dbGetQuery` retourne le résultat sous la forme d'un `tibble`. +```{r} +DBI::dbExecute(conn_ddb, "CREATE TABLE bpe_ens_2018_table AS SELECT REG, SUM(NB_EQUIP) AS NB_EQUIP_TOT FROM bpe_ens_2018_duckdb GROUP BY REG") # Éviter +DBI::dbExecute(conn_ddb, "CREATE VIEW bpe_ens_2018_view AS SELECT REG, SUM(NB_EQUIP) AS NB_EQUIP_TOT FROM bpe_ens_2018_duckdb GROUP BY REG") # n'utilise pas de mémoire +``` + +Et interroger les objets créés dans la base SQL via `dplyr` : +```{r} +conn_ddb %>% tbl("bpe_ens_2018_view") +``` + +Il est déconseillé de faire `CREATE TABLE` car cela charge les données dans la mémoire. Les fonctions `read_parquet()` en SQL et `duckdb_register` du _package_ utilisent `CREATE VIEW` implicitement. + + +### Optimisations + +Les opérations difficiles en SQL, longues, nécessitant beaucoup de mémoire, sont les fonctions "fenêtre" : jointures, `GROUP BY` avec beaucoup de petits groupes, dédoublonnage, etc. On propose ici quelques techniques pour faire passer ces calculs difficiles. + + +**Utilisation de la mémoire vive** + +Comme expliqué plus haut, les objets manipulés dans cette fiche sont des requêtes SQL, et ne nécessitent pas de mémoire vive. Les données déclarées par `read_parquet` sont stockées sur le disque dur, lues à la demande, et "oubliées" à la fin du calcul. On retourne le _résultat_ du calcul. + +Pour les opérations compliquées, il peut être nécessaire de charger les données en mémoire pour effectuer le calcul. En cas de débordement, `duckdb` renvoit un message du type : +``` +Error: rapi_execute: Failed to run query +Error: Out of Memory Error: could not allocate block of size 262KB (99.7MB/100.0MB used) +Database is launched in in-memory mode and no temporary directory is specified. +Unused blocks cannot be offloaded to disk. + +Launch the database with a persistent storage back-end +Or set PRAGMA temp_directory='/path/to/tmp.tmp' +``` + +Pour contourner le manque de mémoire vive, on propose les quatre techniques suivantes : + +- exécuter et sauvegarder les résultats au fur et à mesure. La commande `arrow::write_dataset` et la commande SQL `COPY request TO filename.parquet` savent le faire automatiquement, sans faire déborder la mémoire, pour certains calculs. + ```{r eval=FALSE} + conn_ddb <- dbConnect(duckdb(), dbdir = "my-db.duckdb", read_only = FALSE) + ``` +- adosser un fichier sur le disque dur à la base de données en mémoire au moment de la création de la connexion. Cela ralentit considérablement les calculs, et ne permet pas toujours d'obtenir un résultat. +- diminuer le nombre de threads utilisés par `duckdb`, donc moins de besoins de mémoire, mais aussi moins de parallélisme. + ```{r eval=FALSE} + conn_ddb <- dbConnect(duckdb(), + config=list("memory_limit"="10GB", "threads"="1"))) + ``` +- découper le calcul et sauvegarder une base intermédiaire (cf ci-dessous). + +L'interaction entre les différentes options de `duckdb` est complexe et rendent difficile l'élaboration de recommandations claires. Nous mettrons à jour cette fiche quand des benchmarks plus poussés seront disponibles. + + +**Sauvegarder des résulats intermédiaires**. + +- Avec l'évaluation différée, l'ensemble de la requête est exécutée à chaque appel à `collect()`. Il peut être utile de conserver des résultats pour ne pas refaire les mêmes calculs. +- Certaines requêtes sont trop compliquées pour le moteur SQL et/ou pour la traduction automatique. Par contre, `arrow::write_dataset()` sait faire les calculs par morceaux automatiquement, et libère la mémoire au fur et à mesure. Lorsqu'un calcul ne passe pas, on peut tenter de le découper en passant par une base intermédiaire : +```{r eval=FALSE} +conn_ddb %>% calcul1() %>% + arrow::to_arrow() %>% arrow::write_dataset("base_intermediaire") +arrow::open_dataset("base_intermediaire") %>% arrow::to_duckdb(conn_ddb) %>% + calcul2() +``` + + +**Partitionner les données**. + +- Pour exécuter une fonction fenêtre, il faut pouvoir localiser les données en mémoire avec un _index_. +- Les fichiers `parquet` ont un index `min-max` : les fichiers sont structurés en blocs, et on indique le minimum et maximum des valeurs du bloc dans les métadonnées. Ceci permet de sauter la lecture d'un bloc si l'on s'intéresse à des valeurs en dehors de la plage `min-max`, parce que l'on filtre les données par exemple. +- En SQL, on peut créer un index, mais il faut que les données soient en mémoire, ce qui peut s'avérer être incompatible avec de très grosses volumétries. +- Par contre, on peut partitionner les données, et le moteur SQL sait utiliser le partitionnement comme un index. +```{r} +bpe_ens_2018_dataset %>% + arrow::to_arrow() %>% arrow::write_dataset("bpe_ens_2018_dataset_parts", partitioning = "REG" ) +list.files("bpe_ens_2018_dataset_parts") # on obtient un sous-répertoire par région +``` + + +**Exécuter les traitements par groupe _explicitement_**. +S'il faut absolument charger des données en mémoire, on peut découper le calcul pour ne charger qu'une partie des données. Par exemple, faire une jointure région par région au lieu de faire la jointure sur toute la base d'un coup. On peut utiliser le partitionnement pour sauvegarder les résultats partiels, et les ré-assembler ensuite. +```{r eval=FALSE} +groups <- bpe_ens_2018_dataset %>% + distinct(REG) %>% pull() # liste des modalités de la variable REG +f <- function(x) { + bpe_ens_2018_dataset %>% + filter(REG == x) %>% + calcul_long() %>% + arrow::to_arrow() %>% arrow::write_dataset("resultat", partitioning = "REG") +} +purrr::walk(f, groups) # on peut aussi utiliser sapply +``` + + +**Bien entendu, on peut combiner ces techniques.** + + + +## Comparaison avec `arrow` + +`arrow` et `duckdb` partagent de nombreux concepts. Voici quelques différences : + +- Le projet `duckdb` est très récent. Il y a régulièrement des évolutions qui sont souvent des extensions ou des optimisations, et parfois la résolution de bugs. `arrow` est un projet plus ancien et plus mature. +- Certaines fonctions standards de `R` ne sont pas traduites, mais la situation est meilleure du côté `duckdb`. Hormis `write_dataset()`, la plupart des traitements peuvent être effectués en utilisant uniquement `duckdb`, sans passer par `arrow`. +- Les __conversions de type__: `duckdb` est plus permissif que `arrow` et fera plus facilement des conversions automatiques sans danger. +- 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__ : `pivot_wider` et `pivot_longer` existent nativement dans `duckdb` mais pas dans `arrow`. +- 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. Le code fonctionne en `duckdb`. + +```{r} +# arrow ne peut pas exécuter ceci +bpe_ens_2018_dataset |> + group_by(DEP) |> + mutate(NB_EQUIP_TOTAL_DEP = sum(NB_EQUIP)) |> + select(DEP, NB_EQUIP, NB_EQUIP_TOTAL_DEP) +``` + +- 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`. On propose la syntaxe suivante en `duckdb`. Par ailleurs, les deux tables 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} + # Comment empiler de multiples tables + bpe_ens_2018_dataset %>% count() + bpe_ens_2018_dataset %>% + union_all( bpe_ens_2018_dataset) %>% + union_all( bpe_ens_2018_dataset) %>% count() + ``` + + +## Pour en savoir plus {#Ressourcesduckdb} + +- la documentation officielle du _moteur_ [`DuckDB`](https://duckdb.org/docs/) (en anglais); +- la documentation du _package_ [`DBI`](https://dbi.r-dbi.org/) décrit les mécanismes de traduction `dplyr` vers SQL utilisés dans toutes les bases de données interfacées avec `R`. + +```{r} +#| echo: false +DBI::dbDisconnect(conn_ddb, shutdown = TRUE) +``` + + + diff --git a/DESCRIPTION b/DESCRIPTION index e91a38e9..634b865e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -30,6 +30,7 @@ Authors@R: c( person("Frédérique" , "Cornuau" , role = "ctb", email = "frederique.cornuau@justice.gouv.fr"), person("Clément" , "Rousset" , role = "ctb", email = "clement.rousset@insee.fr"), person("Aurélien" , "D'Isanto" , role = "ctb", email = "aurelien.disanto@insee.fr"), + person("Sébastien" , "Li-Thiao-Té", role = "ctb", email = "sebastien.li-thiao-te@travail.gouv.fr"), person(given = NULL, family = "Institut National de la Statistique et des Études Économiques", role = "cph") ) License: Licence Ouverte 2.0 diff --git a/_quarto.yml b/_quarto.yml index e8c1c442..482eab9a 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -86,6 +86,7 @@ book: - 03_Fiches_thematiques/Fiche_tidyverse.qmd - 03_Fiches_thematiques/Fiche_datatable.qmd - 03_Fiches_thematiques/Fiche_arrow.qmd + - 03_Fiches_thematiques/Fiche_duckdb.qmd - part: "Manipuler des données avec R" chapters: - 03_Fiches_thematiques/Fiche_joindre_donnees.qmd