ND Concurrency

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_ir dans 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 matcher P{x} -- et pas seulement recevoir un P qu'il ne saurait pas nommer. Un nom que le programme a réassigné (struct P {x} puis P = (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 worker traite 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'un str(...) 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