ND-Recursion
Exemples d'utilisation de la ND-récursion (@@, @>, @[]).
La ND-récursion permet d'exprimer des computations récursives qui peuvent être exécutées en parallèle sans syntax
async/await. Le runtime choisit le mode d'exécution.
@@ - ND-Recursion
Forme combinateur
La forme combinateur applique la lambda avec une seed initiale:
# Countdown récursif
@@(5, (v, recur) => {
if v > 0 {
recur(v - 1)
} else {
v
}
})
# → 0
Explication: La lambda reçoit (v, recur) où:
v= valeur couranterecur= fonction pour continuer la récursion
Factorielle
@@(5, (n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
})
# → 120
Forme déclaration
Créer une fonction ND-récursive réutilisable:
countdown = @@ (n, recur) => {
if n > 0 { recur(n - 1) }
else { "done" }
}
countdown(10)
# → "done"
À ce stade, la lambda wrappée s'exécute comme une fonction normale. Une fois appelée avec une seed, elle déclenche la récursion.
@> - ND-Map
Forme lift
Lifter une fonction dans le contexte ND:
f = @> abs
f(-5)
# → 5
Forme applicative
Appliquer une fonction en contexte ND:
@>(list(-1, -2, -3), abs)
# → [1, 2, 3]
Broadcast ND-map
Mapper une fonction sur chaque élément:
list(-1, -2, 3).[@> abs]
# → [1, 2, 3]
Avec lambda:
list(1, 2, 3).[@> (x) => { x * 2 }]
# → [2, 4, 6]
@[] - Empty Topos
Littéral représentant le topos vide (élément neutre):
empty = @[]
Propriétés:
# Falsy en contexte booléen
if @[] { 1 } else { 2 }
# → 2
# Égalité
@[] == @[]
# → True
# Longueur
len(@[])
# → 0
Le topos vide sert d'élément identité pour les opérations ND. Il marque la terminaison dans les graphes de calcul.
Broadcast ND
Les opérateurs ND fonctionnent avec le broadcast sur collections.
data.[@> f] - Broadcast ND-map
Applique une fonction à chaque élément en contexte ND:
# Map abs sur liste
list(-1, -2, 3).[@> abs]
# → [1, 2, 3]
# Map lambda inline
list(1, 2, 3).[@> (x) => { x * 2 }]
# → [2, 4, 6]
# Préserve le type tuple
tuple(-1, -2, 3).[@> abs]
# → (1, 2, 3)
data.[@@ lambda] - Broadcast ND-recursion
Applique ND-recursion à chaque élément:
# Factorielle sur liste
list(3, 5).[@@ (n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
}]
# → [6, 120]
# Countdown sur chaque élément
list(3, 5, 2).[@@ (v, recur) => {
if v > 0 { recur(v - 1) }
else { v }
}]
# → [0, 0, 0]
# Préserve le type tuple
tuple(3, 2, 4).[@@ (n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
}]
# → (6, 2, 24)
Note: Le broadcast ND préserve automatiquement le type de la collection (list, tuple, set).
Chaînage
Les opérateurs ND se chaînent naturellement avec le broadcasting standard:
data = list(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
result = data.[if > 3].[* 2].[if < 15].[+ 1]
# > 3: [4,5,6,7,8,9,10]
# * 2: [8,10,12,14,16,18,20]
# < 15: [8,10,12,14]
# + 1: [9,11,13,15]
Mode d'exécution
Par défaut, exécution séquentielle via NDScheduler.execute_sync().
Pragmas ND
Les pragmas permettent de contrôler le mode d'exécution :
# Configurer le nombre de workers (0 = auto-detect)
pragma("nd_workers", "8")
# Mode d'exécution : "sequential" (défaut) ou "parallel"
pragma("nd_mode", "parallel")
# Cette computation utilisera 8 workers en parallèle
@@(huge_dataset, (data, recur) => { ... })
Modes disponibles :
"sequential"(défaut) : exécution séquentielle, pas de overhead de threads"parallel": exécution sur N workers via ThreadPoolExecutor
Nombre de workers :
"0": détection automatique (nombre de cœurs CPU)"N": nombre explicite de workers
Note sur le parallélisme : Le mode parallèle utilise
ThreadPoolExecutor(threads Python) par défaut. Le GIL limite les gains pour du code CPU-bound pur, mais permet de profiter du parallélisme pour les I/O ou les opérations natives.Pour du vrai parallélisme CPU-bound, utiliser
ProcessPoolExecutorviapragma("nd", "process"). Les lambdas Catnip sont picklables (sérializables), donc les workers peuvent les exécuter dans leurs propres processus. Chaque worker initialise son registry Catnip automatiquement.
Memoization
Le pragma nd_memoize active un cache automatique des résultats :
# Activer la memoization
pragma("nd_memoize", "on")
# Fibonacci avec memoization - 11x speedup sur fib(25)
@@(25, (n, recur) => {
if n <= 1 { n }
else { recur(n - 1) + recur(n - 2) }
})
# → 75025
# Désactiver la memoization (défaut)
pragma("nd_memoize", "off")
Principe : Le scheduler cache les résultats par valeur seed. Si recur(n) est rappelé avec la même valeur n, le
résultat en cache est retourné au lieu de recalculer.
Cas d'usage optimal :
- Algorithmes avec redondance (Fibonacci, DP)
- Calculs coûteux sur mêmes valeurs
- Broadcast sur collections avec valeurs dupliquées
Performance :
- Fibonacci sans memoization : O(2^n) appels
- Fibonacci avec memoization : O(n) appels
- Speedup mesuré : 11x sur fib(25), plus élevé pour n plus grand
Batching (Phase 6)
Le pragma nd_batch_size contrôle la granularité du parallélisme en regroupant plusieurs items avant de les soumettre à
ThreadPoolExecutor :
# Configuration explicite
pragma("nd_mode", "parallel")
pragma("nd_workers", "8")
pragma("nd_batch_size", "10") # 10 items par batch
# Large collection - batching réduit l'overhead
range(1, 101).[@@ (n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
}]
# Auto-calcul (défaut avec 0)
pragma("nd_batch_size", "0") # batch_size = ceil(len / (workers * 4))
pragma("batch_size", "0") # Shorthand
Principe : Au lieu de soumettre chaque item individuellement à l'executor, on groupe batch_size items ensemble.
Cela réduit le nombre de submits et l'overhead de synchronisation.
Auto-calcul : Si batch_size = 0 (défaut), le scheduler calcule automatiquement pour obtenir ~4 batches par worker.
Détection intelligente : Pour les petites collections (< workers*2 items), le batching est automatiquement désactivé pour éviter l'overhead.
Cas d'usage optimal :
- Collections grandes (100+ items)
- Mode parallel avec plusieurs workers
- Items avec temps de calcul variable
Combinaison avec memoization :
pragma("nd_mode", "parallel")
pragma("nd_memoize", "on")
pragma("nd_batch_size", "5")
# Broadcast sur collection avec doublons
# Batching: réduit overhead ThreadPoolExecutor
# Memoization: évite recalculs des valeurs déjà vues
list(10, 12, 10, 15, 12, 20).[@@ (n, recur) => {
if n <= 1 { n }
else { recur(n - 1) + recur(n - 2) }
}]
Principe : La sémantique du code reste identique, seul le mode d'exécution change. Le déterminisme est préservé - résultat identique en séquentiel et parallèle.
Les optimisations (memoization, batching) réduisent le temps d'exécution sans changer le résultat.