ND Concurrency
Sommaire
- Les trois modes
- Le GIL en 30 secondes
- Quand utiliser quoi
- sequential - le défaut
- thread - I/O et mémoïsation partagée
- process - vrai parallélisme CPU
- Compromis résumés
- Le scope capturé est en lecture seule
- import() n'est pas disponible en parallèle
- Sérialisation et processus
- Patterns courants
- Fibonacci avec cache partagé (thread)
- Broadcast CPU-bound (process)
- Fallback automatique
- Configuration CLI
- Références
Guide pratique pour choisir le bon mode d'exécution ND (sequential, thread, process) et comprendre l'impact du
GIL.
Les trois modes
pragma("nd_mode", ND.sequential) # Défaut. Un seul thread.
pragma("nd_mode", ND.thread) # ThreadPoolExecutor. Mémoire partagée.
pragma("nd_mode", ND.process) # Workers Rust natifs. Processus séparés.
Le mode s'applique à toutes les opérations ~~ et ~> qui suivent.
Le GIL en 30 secondes
CPython exécute le bytecode Python sous un verrou global (GIL). Conséquence : plusieurs threads Python ne peuvent pas exécuter du bytecode en même temps. Ils alternent.
Cela ne veut pas dire que les threads sont inutiles. Le GIL est relâché pendant :
- les appels système (I/O fichier, réseau, sleep)
- certaines opérations natives (numpy, compression, hashing)
- les attentes (
time.sleep,socket.recv, requêtes HTTP)
Donc : threads = utile pour I/O, inutile pour du calcul pur Python.
Les processus n'ont pas ce problème. Chaque processus a son propre interpréteur, son propre GIL.
Le GIL est un détail d'implémentation de CPython, pas une propriété du langage Python. Mais comme Catnip tourne sur CPython, c'est notre réalité.
Quand utiliser quoi
sequential - le défaut
pragma("nd_mode", ND.sequential)
~~(10, (n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
})
Pas d'overhead. Pas de surprise. Le plus rapide pour les petits calculs.
Utiliser quand :
- Le calcul est court (< 1s)
- On debug
- On ne sait pas quel mode choisir
thread - I/O et mémoïsation partagée
pragma("nd_mode", ND.thread)
pragma("nd_workers", 8)
pragma("nd_memoize", True)
~~(30, (n, recur) => {
if n <= 1 { n }
else { recur(n - 1) + recur(n - 2) }
})
Les threads partagent la mémoire du processus. Le cache de mémoïsation est commun à tous les workers. Un résultat calculé par un thread est immédiatement disponible pour les autres.
Utiliser quand :
- La lambda fait de l'I/O (lecture fichier, requêtes réseau, base de données)
- La mémoïsation est activée et les valeurs se recoupent (Fibonacci, DP)
- On veut le cache partagé sans payer le coût de la sérialisation
Ne pas utiliser quand :
- La lambda est du calcul pur (arithmétique, logique, manipulation de listes)
- Le GIL empêchera le parallélisme réel et l'overhead des threads sera du bruit
process - vrai parallélisme CPU
pragma("nd_mode", ND.process)
pragma("nd_workers", 8)
list(5, 10, 15, 20).[~~(n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
}]
Chaque worker est un processus séparé avec son propre interpréteur Python. Le GIL n'est plus un facteur limitant.
Utiliser quand :
- Le calcul est CPU-bound (arithmétique lourde, récursion profonde)
- Les items sont indépendants (broadcast sur une collection)
- Le temps de calcul par item justifie l'overhead de sérialisation
Ne pas utiliser quand :
- Les items sont petits ou le calcul est rapide (overhead > gain)
- La mémoïsation croisée est critique (chaque processus a son propre cache)
- Les lambdas capturent des objets non sérialisables
Compromis résumés
| Critère | sequential |
thread |
process |
|---|---|---|---|
| Overhead | Aucun | Faible | Faible (IPC postcard) [^1] |
| Parallélisme CPU | Non | Non (GIL) | Oui |
| Parallélisme I/O | Non | Oui | Oui |
| Mémoïsation partagée | N/A | Oui | Non |
| Sérialisation | Aucune | Aucune | Freeze postcard [^1] |
| Debug | Trivial | Correct | Difficile |
[^1]: Quand la lambda et ses captures/seeds sont freezables (int, float, bool, string, list, tuple, dict, set, et
structs plats -- frontière v1), le mode process utilise un pool persistant de workers Rust (catnip worker) avec IPC
postcard : parallélisme CPU réel, pas de pickle, pas de startup Python par worker. Sinon (callback Python, struct
extends/implements/abstract, grand entier en champ, global non freezable référencé, builtin redéfini et référencé
par la lambda), l'exécution retombe automatiquement sur le mode thread.
Le scope capturé est en lecture seule
Une ligne du tableau ci-dessus n'y figure pas, parce qu'elle ne varie pas : depuis un callback ~~/~>, écrire dans le
scope englobant — réassigner (count = count + 1) ou muter en place (bag.append(x)) — donne un résultat qui dépend du
mode. En sequential il est ordonné, en thread l'ordre change d'une exécution à l'autre, en process l'écriture ne
sort jamais du worker. Le linter la rejette donc (E205) dans les trois modes : un nd_mode choisit où un callback
tourne, jamais ce qu'il répond.
L'écriture d'attribut sur une struct capturée est de plus refusée à l'exécution, dans les trois exécuteurs et dans les
trois modes : b.v = x sur une struct qui préexiste au callback lève
RuntimeError: cannot mutate 'B' inside a parallel ND callback: the captured scope is read-only. Les structs passées en
élément ou en seed, et celles créées dans le callback, restent mutables — ce sont des arguments, pas des captures.
Le refus ne dépend pas du chemin par lequel l'écriture arrive : passer la struct à une fonction, y compris par argument
nommé, ou repasser par Python (sorted(xs, key=f)) mène au même message. Ce qui compte est que l'instance préexiste au
callback — celle qu'il construit lui-même reste mutable, jusque dans un broadcast imbriqué.
Une déclaration de type échappe à cette règle, et c'est voulu : struct P { x } écrit dans un callback reste
joignable après le broadcast, comme dans une lambda ordinaire (voir
portée des déclarations de type). Elle remplace donc un homonyme que l'appelant
aurait déclaré. La lecture seule porte sur l'état que le callback trouve en place, pas sur ce qu'il définit.
Pour agréger, replier par une réduction — reduce(data, sum) — plutôt que d'accumuler dans une cellule externe.
import() n'est pas disponible en parallèle
Le chargeur d'imports est lié au thread principal. Un import() atteint depuis un callback exécuté en thread — ou en
process retombé sur thread — est refusé, quel que soit le chemin : écrit dans le corps, atteint à travers une
fonction appelée, ou confié à une fonction Python qui l'appelle.
RuntimeError: import() is not supported inside a thread-parallel ND callback: the import loader is bound to the main
thread. Use pragma('nd_mode', 'sequential'), or hoist the import out of the callback.
Sortir l'import du callback suffit presque toujours : le module reste visible par capture, et seule sa création est liée au thread.
m = import('math')
data = list(1, 4, 9)
data.[~> (n) => { m.sqrt(n) }]
# ⇒ [1.0, 2.0, 3.0]
Trois modes, trois compteurs, trois totaux. Le programme n'a pas changé d'avis ; il a changé d'endroit.
Sérialisation et processus
En mode process, deux chemins sont possibles :
Chemin natif (Rust workers) -- utilisé quand la lambda, ses captures et ses seeds sont freezables (types primitifs, listes, dicts, tuples, strings, sets, et structs plats -- frontière v1) :
- La lambda est compilée avec son IR source encodé (
encoded_irdans le CodeObject) - Les captures, seeds et définitions de type struct sont converties en
FrozenValue/FrozenStructType(postcard, pas pickle) ; le worker reconstruit les types struct par forme, pas par nom -- un nom peut porter plusieurs types vivants à la fois (une redéfinition dont l'ancienne version a encore des instances), et chacun doit pouvoir se reconstruire - Le worker lie le nom du type qui est courant chez le parent, de sorte qu'un callback peut construire
P(x=n)ou matcherP{x}-- et pas seulement recevoir unPqu'il ne saurait pas nommer. Un nom que le programme a réassigné (struct P {x}puisP = (n) => {...}) garde son sens : c'est la valeur qui est liée, pas le type - Un callback récursif reste entier dans le worker : celui-ci se passe le corps qu'il vient de compiler comme handle
recur, et la récursion se déroule sur sa propre pile de frames - Un pool persistant de workers
catnip workertraite les tâches via IPC stdin/stdout - Pas de startup Python par worker, pas de pickle, pas de GIL sur l'orchestration
Chemin thread (fallback) -- utilisé quand quelque chose que la lambda touche ne peut pas voyager jusqu'au worker :
- captures ou seeds non freezables (callback Python, struct hors frontière v1 --
extends/implements/abstract, grand entier en champ). Un type hors frontière n'écarte que ce qui a besoin de lui : les autres types du programme partent quand même, et un callback qui n'en nomme aucun reste sur le chemin natif ; - un global du parent référencé par la lambda et non freezable (fonction helper, struct hors frontière) : le worker
vierge ne le reconstruit pas et lèverait
NameError; - un builtin redéfini et référencé par la lambda (
str = (x) => {...}suivi d'unstr(...)dans le corps) : le worker résoudrait son builtin pré-installé, pas la redéfinition -- détecté en amont, sinon la divergence serait silencieuse (aucune erreur à intercepter). Une redéfinition que la lambda ne nomme jamais ne coûte rien : elle ne peut pas diverger, donc elle n'écarte pas du chemin natif.
Dans tous ces cas, l'exécution retombe sur le mode thread (rayon, in-process) : correct pour tout, mais le GIL
sérialise le calcul CPU. Pas de sérialisation, pas de pickle.
Chemin pickle (exécuteur AST) -- sous -x ast, process passe par un ProcessPoolExecutor : la lambda et le seed
voyagent par pickle. Une closure résout ses globals en liaison tardive contre le scope qui l'a définie, et un processus
neuf n'a pas ce scope : les globals picklables du parent sont donc envoyés avec la tâche et installés avant l'appel. Une
instance de struct en fait partie (elle se reconstruit détachée, en gardant sa signature de forme, donc son identité de
type). Un global qui ne pickle pas -- un module, un objet Python opaque -- reste absent : le worker lève sur son nom et
la tâche rejoue en thread.
Dans tous les cas, le cache de mémoïsation n'est pas partagé entre workers.
Patterns courants
Fibonacci avec cache partagé (thread)
pragma("nd_mode", ND.thread)
pragma("nd_memoize", True)
fib = ~~(n, recur) => {
if n <= 1 { n }
else { recur(n - 1) + recur(n - 2) }
}
fib(30)
# Cache partagé : O(n) appels au lieu de O(2^n)
Le mode thread est le bon choix ici : la mémoïsation partagée transforme un algorithme exponentiel en linéaire. Le GIL
n'est pas un problème car le gain vient du cache, pas du parallélisme.
Broadcast CPU-bound (process)
pragma("nd_mode", ND.process)
pragma("nd_workers", 4)
list(20, 25, 30, 35).[~~(n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
}]
# 4 factorielles calculées en parallèle sur 4 processus
Chaque item est indépendant et coûteux. Le mode process distribue le travail sans contention GIL.
Fallback automatique
pragma("nd_mode", ND.process)
# Si le fork échoue (sandbox, WASM, etc.), le scheduler
# bascule automatiquement en sequential. Pas d'erreur.
~~(5, (n, recur) => { if n <= 1 { 1 } else { n * recur(n - 1) } })
Le mode d'exécution est un choix d'infrastructure, pas de sémantique. Le résultat est identique dans les trois modes. Seul le temps change.
Configuration CLI
catnip -o nd_mode:thread -o nd_workers:8 script.cat
catnip -o nd_mode:process -o nd_workers:4 script.cat
Les options CLI ont priorité sur les pragmas du fichier.
Références
- PRAGMAS - spec complète des pragmas ND
- nd_recursion - exemples d'usage
- scheduler.rs - implémentation du scheduler