Skip to content

Commit

Permalink
Évolutions sur la fiche duckdb (#524)
Browse files Browse the repository at this point in the history
* supprime check_from qui est déprécié

* quelques précisions sur comment ouvrir des fichiers parquet

* une fonction par ligne

* limiter le nb de coeurs

* complements sur les fichiers intermédiaires

* ajoute de quotes manquants

* corrections sur les propositions des optimisations

* ajout d'une section sur les paramètres de configuration

* quelques ajouts sur SQL

* Update 03_Fiches_thematiques/Fiche_duckdb.qmd

* Update 03_Fiches_thematiques/Fiche_duckdb.qmd

* Update 03_Fiches_thematiques/Fiche_duckdb.qmd

* Update 03_Fiches_thematiques/Fiche_duckdb.qmd

* Update 03_Fiches_thematiques/Fiche_duckdb.qmd

---------

Co-authored-by: Olivier Meslin <44379737+oliviermeslin@users.noreply.github.com>
  • Loading branch information
nbc and oliviermeslin authored May 16, 2024
1 parent 261b5f0 commit ce8b0aa
Show file tree
Hide file tree
Showing 2 changed files with 189 additions and 32 deletions.
221 changes: 189 additions & 32 deletions 03_Fiches_thematiques/Fiche_duckdb.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,17 @@ Concrètement, cette commande crée une nouvelle base de données `duckdb` dans
DBI::dbDisconnect(conn_ddb, shutdown = TRUE)
```

Pour la suite, on supposera que la connexion est ouverte.
Par défaut, `duckdb` utilisera tous les cœurs disponibles. Sur un serveur mutualisé, afin de ne pas consommer toutes les ressources, il est conseillé de limiter le nombre de cœurs utilisés par `duckdb` (pour plus d'information, cf [Configurer `duckdb`](#sec-configuration))

```{r}
conn_ddb <- dbConnect(duckdb())
conn_ddb <- DBI::dbConnect(duckdb::duckdb(
config = list(threads = "6")
))
```

Pour la suite, on supposera que la connexion à une base de données duckdb est ouverte.

### 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:
Expand Down Expand Up @@ -155,31 +160,44 @@ Pour l'exemple, on sauvegarde les données `bpe_ens_2018` au format Parquet.
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.
**Pour utiliser un fichier Parquet dans `duckdb` sans le charger en mémoire, on propose deux méthodes:** utiliser la fonction `dplyr::tbl` qui va directement lire les fichiers avec `duckdb` ou passer par `arrow::open_dataset` et convertir l'objet avec `arrow::to_duckdb`. Si la deuxième méthode est plus simple, surtout quand vous connaissez déjà `arrow`, la première est systématiquement plus efficace et peut générer des gains de consommation mémoire et de temps de traitement conséquents, il est donc conseillé de ne pas lire vos fichiers avec `arrow::open_dataset` si vos traitements sont lourds.

En passant par `arrow`. Cette méthode utilise un objet intermédiaire de type Arrow Table (voir la fiche [Manipuler des données avec `arrow`](#arrow)) :

```{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)
La première approche repose uniquement `duckdb`. Vous devez utilisez la fonction `dplyr::tbl` :

```{r messages=FALSE}
conn_ddb %>% tbl("read_parquet('bpe_ens_2018_dataset/**/*.parquet')")
```

En passant directement par `duckdb`, il faut travailler un peu plus pour construire les noms de fichiers :
Quelques explications de cette ligne :

* La fonction [`read_parquet`](https://duckdb.org/docs/data/parquet/overview.html#read_parquet-function) est une fonction interne à `duckdb`, elle ne doit pas être confondue avec la fonction `read_parquet()` du _package_ `arrow`.
* `**/*.parquet` est un motif qui indique que vous souhaitez lire, dans tous les sous-répertoires quelque soit le niveau (`**`), l'ensemble des fichiers parquets (`*.parquet`) qui s'y trouvent. C'est utile pour lire des fichiers Parquet partitionnés.
Quand vous n'avez pas besoin de passer d'arguments à `read_parquet`, vous pouvez l'omettre :

```{r messages=FALSE}
conn_ddb %>% tbl('bpe_ens_2018_dataset/*.parquet', check_from = FALSE)
conn_ddb %>% tbl('bpe_ens_2018_dataset/**/*.parquet')
```

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`.
A noter que `duckdb` propose aussi [des fonctions pour lire d'autres formats](https://duckdb.org/docs/data/csv/overview.html) comme csv, json...

La seconde approche consiste à passer par `arrow`, puis à transmettre les données à `duckdb`. Cette méthode utilise un objet intermédiaire de type Arrow Table (voir la fiche [Manipuler des données avec `arrow`](#arrow)) :
```{r}
bpe_ens_2018_dataset <- conn_ddb %>%
tbl('bpe_ens_2018_dataset/*.parquet', check_from = FALSE)
# 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)
```

Ces deux approches ont un point commun important: __elles établissent une connexion aux données contenues dans le dataset Parquet, mais elles ne chargent pas les données en mémoire__ (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('bpe_ens_2018_dataset/*.parquet')
```

### Manipulation des données avec la syntaxe `dplyr`

Expand Down Expand Up @@ -260,7 +278,6 @@ 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**
Expand Down Expand Up @@ -289,6 +306,66 @@ bpe_ens_2018_dataset %>%

## Notions avancées / bien utiliser `duckdb`

### Configurer `duckdb` {#sec-configuration}

`duckdb` propose [de nombreux paramètres](https://duckdb.org/docs/configuration/overview) mais nous n'allons voir que les principaux.

Nous allons d'abord voir comment configurer `duckdb` à l'initialisation du pilote. La signature de `duckdb` est :

```{r}
drv <- duckdb(
dbdir = "fichier.db",
config = list(
threads = "4",
memory_limit = "40GB",
temp_directory = "tmp_path/",
preserve_insertion_order = "true")
)
```

**`dbdir` : utiliser une base persistante**

Par défaut, `duckdb` créé une base en mémoire. Si vous fixez ce paramètre, `duckdb` créera une base de données sur disque que vous pourrez réouvrir à votre prochaine session.

Si vous utilisez principalement `dplyr`, les bases de données en mémoire sont certainement suffisantes pour vous mais si vous utilisez du SQL, que vous créez des vues ou que vous utilisez `dplyr::compute`, ce paramètre peut être intéressant.

#### Les principaux paramètres de `config`

**`threads` : limiter le nombre de threads** {#config-thread}

Par défaut, `duckdb` fixe la limite de thread au nombre de cœurs disponibles, ce qui n'est pas forcément souhaitable pour plusieurs raisons :

- sur un serveur partagé, vos collègues seront gênés ;
- il est [conseillé de disposer de 5 à 10Go](https://duckdb.org/docs/guides/performance/environment.html) de mémoire par thread (5 pour des aggrégations, 10 pour des jointures) donc beaucoup de threads implique beaucoup de mémoire ;
- avoir trop de thread peut être contre-productif.

Au final, il n'y a pas de règle exacte mais 4 à 8 threads, en respectant le ratio threads/mémoire ci-dessus, sont des valeurs raisonnables. Au delà, les performances augmentent généralement peu pour une consommation mémoire plus importante.

**`memory_limit` : Limiter la mémoire**

Par défaut, `duckdb` limite la mémoire à 80% de la mémoire disponible sur le serveur.

Si vous avez une quantité limitée de mémoire, essayez plutôt de limiter le nombre de threads en respectant la règle de 5 à 10Go par thread.

**`temp_directory` : ou comment utiliser plus de mémoire que ce dont on dispose**

`duckdb` sait ["déborder" sur disque](https://duckdb.org/docs/guides/performance/how_to_tune_workloads.html#larger-than-memory-workloads-out-of-core-processing) pour une grande partie de ces opérations, à savoir, quand il a arrive à la limite mémoire fixée, il va écrire dans des fichiers temporaires les données qu'il ne peut conserver en mémoire.

Il est généralement beaucoup plus efficace de diminuer le nombre de threads (ou de faire vos traitements par bloc) que de déborder sur disque mais dans le cas où vous avez besoin de "juste un peu plus" de mémoire cela peut se révéler utile.

A noter, ce paramètre est automatiquement fixé si vous avez décidé d'utiliser une base persistante.

**`preserve_insertion_order` : préserver l'ordre de lecture/écriture ou non**

`duckdb` peut consommer beaucoup de mémoire pour conserver l'ordre de lecture et d'écriture. Ce dernier paramètre permet d'autoriser `duckdb` à ne pas préserver l'ordre des données à la lecture et à l'écriture des fichiers dans le cas où il n'y a pas de clause `ORDER BY` / `arrange`.

#### Fixer les paramètres après l'initialisation

Vous pouvez également changer les paramètres d'une base après son initialisation en utilisant la commande `dbExecute`. Par exemple, pour fixer le nombre de thread à 4 :

```{r}
dbExecute(conn_ddb, "SET threads = '4';")
```

### L'évaluation différée avec `duckdb` (_lazy evaluation_) {#sec-lazy}

Expand Down Expand Up @@ -427,15 +504,16 @@ conn_ddb %>% tbl("dates_duckdb") %>%
```


### Exécuter du code SQL directement
### Manipulation des données avec SQL

`DuckDB` étant un serveur SQL à part entière, on peut interagir avec `DuckDB` directement avec des requêtes SQL. Par exemple,
`DuckDB` étant un moteur SQL à part entière, on peut interagir avec `DuckDB` directement avec des requêtes SQL. Par exemple, en reprenant une table enregistrée plus haut avec la fonction `duckdb::duckdb_register` :

```{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`.
Vous pouvez 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
Expand All @@ -456,6 +534,50 @@ conn_ddb %>% tbl("bpe_ens_2018_view")

Il est déconseillé de faire `CREATE TABLE` car cela copie les données. Les fonctions `read_parquet()` en SQL et `duckdb_register` du _package_ utilisent `CREATE VIEW` implicitement.

Vous pouvez bien sûr lire des fichiers `Parquet`, `CSV` ou autres en utilisant les [fonctions de duckdb](https://duckdb.org/docs/data/overview) :

```{r}
DBI::dbGetQuery(conn_ddb, "SELECT * FROM read_parquet('bpe_ens_2018_dataset/**/*.parquet') LIMIT 5")
```

::: {.callout-tip}
Le SQL de `duckdb` est très proche de celui de PostgreSQL avec [quelques évolutions très pertinentes](https://duckdb.org/docs/guides/sql_features/friendly_sql).
:::


#### Séparer vos traitements SQL en blocs

Si vos requêtes deviennent trop complexes et/ou longues, vous pouvez les découper en créant des vues intermédiaires que vous réutiliserez plus tard :

```{r eval=FALSE}
dbExecute(conn_ddb, "CREATE OR REPLACE VIEW data1_nettoye AS SELECT ... FROM read_parquet('data1.parquet')")
dbExecute(conn_ddb, "CREATE OR REPLACE VIEW data2_nettoye AS SELECT ... FROM read_parquet('data2.parquet')")
dbGetQuery(conn_ddb, "SELECT * FROM data1_nettoye LEFT JOIN data2_nettoye ON data1.id = data2.id")
```

Et vous pouvez bien sûr créer des tables intermédiaires (temporaires ou non) à la place des vues pour éviter de les recalculer à chaque fois.

#### Écrire des fichiers

Vous pouvez exporter des données vers des fichiers en utilisant [`COPY ... TO ...`](https://duckdb.org/docs/sql/statements/copy.html#copy--to) :

```{r, message=F}
dbExecute(conn_ddb, "COPY (SELECT * FROM read_parquet('bpe_ens_2018_dataset/**/*.parquet'))
TO 'un_dataset_parquet' (FORMAT PARQUET, PARTITION_BY (REG), OVERWRITE_OR_IGNORE 1)")
```

Si vous préférez utiliser les fonctions de `arrow`, vous pouvez créez une vue et utiliser `dbplr::tbl` avec `arrow::write_dataset` :

```{r, eval=FALSE}
dbExecute(conn_ddb, "CREATE OR REPLACE VIEW output AS SELECT ...")
tbl(conn_ddb, "output") |>
arrow::to_arrow() |>
write_dataset("mon_dataset")
```

(https://duckdb.org/docs/guides/sql_features/friendly_sql)

### Optimisations

Expand All @@ -479,29 +601,63 @@ 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.
- 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(), dbdir = "my-db.duckdb", read_only = FALSE)
- diminuer le nombre de threads utilisés par `duckdb`, donc moins de besoins de mémoire, mais aussi moins de parallélisme.
conn_ddb <- dbConnect(duckdb(),
config=list("threads"="1")))
```
ou
```{r eval=FALSE}
conn_ddb <- dbConnect(duckdb(),
config=list("memory_limit"="10GB", "threads"="1")))
dbExecute(conn_ddb, "SET threads = '1';")
```
- 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.
- découper le calcul et sauvegarder une base intermédiaire (cf ci-dessous).
- 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.
```{r eval=FALSE}
conn_ddb <- dbConnect(duckdb(), dbdir = "my-db.duckdb")
```

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**.
**Sauvegarder des résultats intermédiaires**.

Dans plusieurs cas, vous pouvez vouloir passer par des résultats intermédiaires :

- Votre traitement est long et vous ne souhaitez pas le recalculer entièrement à chaque fois ;
- Certaines requêtes sont trop compliquées pour le moteur SQL et/ou pour la traduction automatique, vous devez le découper.

Vous avez plusieurs méthodes possibles :

- 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 :
- `arrow::write_dataset()` sait faire les calculs par morceaux automatiquement, et libère la mémoire au fur et à mesure.
```{r eval=FALSE}
conn_ddb %>% calcul1() %>%
arrow::to_arrow() %>% arrow::write_dataset("base_intermediaire")
arrow::open_dataset("base_intermediaire") %>% arrow::to_duckdb(conn_ddb) %>%
conn_ddb %>% calcul1() %>%
arrow::to_arrow() %>%
arrow::write_dataset("base_intermediaire")
arrow::open_dataset("base_intermediaire") %>%
arrow::to_duckdb(conn_ddb) %>%
calcul2()
```
- Vous pouvez utiliser `dbplyr::compute` pour créer une table `duckdb` stockée sur le disque (si vous avez créée une base sur disque bien sûr) que vous pourrez directement utiliser par la suite dans une autre session :
```{r eval=FALSE}
conn_ddb %>%
calcul1() %>%
compute(name = "matable", temporary = FALSE)
tbl(conn_dbb, "matable") %>%
calcul2()
```

La première méthode avec `arrow` est généralement la plus rapide et la seconde avec `dbplyr::compute` sur une table nommée est la plus efficace (de loin) en terme d'occupation mémoire.

A noter que vous pouvez également utiliser `dbplyr::compute` pour créer une table temporaire `duckdb` stockée en mémoire qui disparaitra à la fin de votre session :
```{r eval=FALSE}
table_temporaire <- conn_ddb %>%
calcul1() %>%
compute()
table_temporaire %>%
calcul2()
```

Expand Down Expand Up @@ -542,9 +698,10 @@ purrr::walk(f, groups) # on peut aussi utiliser sapply

`arrow` et `duckdb` partagent de nombreux concepts. Voici quelques différences :

- `duckdb` comprend parfaitement SQL. Si vous utilisez `PROC SQL`, vous ne serez pas dépaysés.
- 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é de `duckdb` que d'`arrow`. 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 __conversions de type__: `duckdb` est plus permissif que `arrow` et fera plus facilement des [conversions automatiques](https://duckdb.org/docs/sql/data_types/typecasting.html) 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`.
Expand Down
Binary file modified resources/rmarkdown/chunk07.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit ce8b0aa

Please sign in to comment.