La version 1.12 du langage de programmation Julia est disponible, avec la redéfinition des constantes et des structures, de nouvelles fonctionnalités de multithreading et bien plus encore

Julia 1.12, la dernière version du langage de programmation, est officiellement disponible, apportant des améliorations significatives en termes de performances, de multithreading et de productivité des développeurs. Cette version introduit notamment une nouvelle fonctionnalité expérimentale --trim pour des binaires de plus petites tailles et des temps de compilation plus rapides, un multithreading amélioré avec un thread interactif dédié, la redéfinition des structures, des builds optimisés par l'outil BOLT et des capacités étendues de gestion des packages.

Julia est un langage de programmation de haut niveau, performant et dynamique conçu pour le calcul scientifique et les applications de calcul numérique. Sa syntaxe est familière aux utilisateurs d'autres environnements de développement tels que MATLAB, R, Scilab, et Python. Julia est connue pour sa vitesse et ses capacités avancées en termes de calcul parallèle et de gestion de données volumineuses. Julia combine les avantages des langages compilés (tels que C et Fortran) avec la flexibilité des langages dynamiques.

Bien qu'étant un langage généraliste dans sa conception, Julia est majoritairement utilisé dans le monde scientifique. On le retrouve notamment dans des domaines tels que la science des données, la modélisation numérique, la statistique, l'apprentissage automatique ou encore la biologie et la climatologie. Les utilisateurs du langage sont en majorité des ingénieurs, des chercheurs ou des étudiants, qui l'utilisent pour faire de la recherche scientifique ou comme un passe-temps. Certains utilisateurs trouvent dans Julia un langage avec une syntaxe simple comme Python tout en ayant des performances élevées.

Julia 1.12 vient de sortir. Cette version stable apporte des améliorations du langage et des corrections de bogues, mais elle devrait également être entièrement compatible avec le code écrit dans les versions précédentes de Julia. Certaines des fonctionnalités et améliorations introduites dans cette dernière version de Julia sont présentées ci-dessous. La liste complète des modifications est disponible ici.


Nouvelle fonctionnalité --trim

Julia dispose désormais d'une nouvelle fonctionnalité expérimentale --trim. Lorsque vous compilez une image système avec ce mode, Julia supprime statiquement le code inaccessible, ce qui améliore considérablement les temps de compilation et la taille des fichiers binaires. Pour l'utiliser, vous devez également passer le drapeau --experimental lors de la création de l'image système.

Pour pouvoir l'utiliser, tout code accessible depuis les points d'entrée ne doit comporter aucun dispatch dynamique, sinon le trimming ne sera pas sûr et une erreur se produira lors de la compilation.

La manière prévue pour l'utiliser est via le package JuliaC.jl, qui fournit une CLI et une API programmatique.

Voici par exemple un package simple avec une fonction @main :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
module AppProject
 
function @main(ARGS)
    println(Core.stdout, "Hello World!")
    return 0
end
 
end

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
juliac --output-exe app_test_exe --bundle build --trim=safe --experimental ./AppProject

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
./build/bin/app_test_exe
Hello World!
 
ls -lh build/bin/app_test_exe
-rwxr-xr-x@ 1 gabrielbaraldi  staff   1.1M Oct  6 17:22 ./build/bin/app_test_exe*


Redéfinition des constantes (structures)

Les liaisons participent désormais au mécanisme « world age » précédemment utilisé pour les méthodes. Cela permet de redéfinir correctement les constantes et les structures. Par exemple :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Define a struct and a method on that struct:
julia> struct Foo
          a::Int
       end
 
julia> g(f::Foo) = f.a^2
g (generic function with 1 method)
 
julia> g(Foo(2))
4
 
# Redefine the struct (julia pre-1.12 would error here)
julia> struct Foo
          a::Int
          b::Int
       end
 
# Note that functions need to be redefined to work on the new `Foo`
julia> g(Foo(1,2))
ERROR: MethodError: no method matching g(::Foo)
The function `g` exists, but no method is defined for this combination of argument types.
 
Closest candidates are:
  g(::@world(Foo, 39296:39300)) # <- This is syntax for accessing the binding in an older "world"
   @ Main REPL[2]:1
 
julia> g(f::Foo) = f.a^2 + f.b^2
g (generic function with 2 methods)
 
julia> g(Foo(2,3))
13


Des travaux sont également en cours dans Revise.jl pour redéfinir automatiquement les fonctions sur les liaisons remplacées. Cela devrait réduire considérablement le nombre de redémarrages de Julia nécessaires lors de l'itération sur certains morceaux de code.

Nouveaux indicateurs de suivi et macros pour inspecter ce que Julia compile

--trace-compile-timing est un nouvel indicateur de ligne de commande qui complète --trace-compile en affichant la durée (en millisecondes) de chaque méthode compilée avant la ligne precompile(...) correspondante. Cela permet de repérer plus facilement les compilations coûteuses.

De plus, deux macros permettant un suivi ad hoc sans redémarrer Julia ont été ajoutées :

  • @trace_compile expr exécute expr avec --trace-compile=stderr --trace-compile-timing activé, en émettant des entrées precompile(...) chronométrées uniquement pour cet appel.
  • @trace_dispatch expr exécute expr avec --trace-dispatch=stderr activé, en signalant les méthodes qui sont dispatchées dynamiquement.

Voici un exemple :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
julia> @trace_compile @eval rand(2,2) * rand(2,2)
#=   79.9 ms =# precompile(Tuple{typeof(Base.rand), Int64, Int64})
#=    4.4 ms =# precompile(Tuple{typeof(Base.:(*)), Array{Float64, 2}, Array{Float64, 2}})
2×2 Matrix{Float64}:
 0.302276  0.14341
 0.738941  0.396414
 
julia> f(x) = x
 
julia> @trace_dispatch map(f, Any[1,2,3])
precompile(Tuple{Type{Array{Int64, 1}}, UndefInitializer, Tuple{Int64}})
precompile(Tuple{typeof(Base.collect_to_with_first!), Array{Int64, 1}, Int64, Base.Generator{Array{Any, 1}, typeof(Main.f)}, Int64})
3-element Vector{Int64}:
 1
 2
 3


Nouvelles fonctionnalités multithreading

Un thread interactif par défaut

Julia démarre désormais avec un thread interactif par défaut (en plus du default thread). Cela signifie que, par défaut, Julia s'exécute avec une configuration multithreading comprenant 1 thread par défaut et 1 thread interactif.

Le pool de threads interactifs est l'endroit où s'exécutent le REPL et les autres opérations interactives. En les séparant du pool de threads par défaut (où @spawn et @threads planifient le travail lorsqu'aucun pool de threads n'est spécifié), le REPL peut effectuer des opérations telles que les requêtes d'autocomplétion en parallèle avec l'exécution du code utilisateur, ce qui se traduit par une expérience interactive plus réactive.

Comportements clés :

  • Par défaut : Julia démarre avec -t1,1 (1 thread par défaut + 1 thread interactif)
  • -t1 explicite : si vous demandez explicitement 1 thread avec -t1, Julia vous donnera exactement cela — aucun thread interactif supplémentaire ne sera ajouté (ce qui donne -t1,0)
  • Threads multiples : -t2 ou -tauto vous donnera les threads par défaut demandés plus 1 thread interactif
  • Contrôle manuel : vous pouvez toujours spécifier explicitement les deux pools, par exemple -t4,2 pour 4 threads par défaut et 2 threads interactifs

Cette modification améliore l'expérience prête à l'emploi tout en conservant la rétrocompatibilité pour les utilisateurs qui demandent explicitement une exécution à thread unique.

Les paramètres des threads respectent l'affinité CPU

Julia respecte désormais les paramètres d'affinité du CPU, tels que ceux définis via cpuset/taskset/cgroups, etc. Il en va de même pour le nombre par défaut de threads BLAS, qui suit désormais la même logique. Cela peut également être observé lors de l'exécution de Julia dans Docker. Actuellement, vous disposez de :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
$ docker run --cpus=4 --rm -ti julia:1.11 julia --threads=auto -e '@show Threads.nthreads(); using LinearAlgebra; @show BLAS.get_num_threads()'
Threads.nthreads() = 22
BLAS.get_num_threads() = 11


Lorsque vous démarrez Julia avec --threads=auto, Threads.nthreads() est égal au nombre total de processeurs du système au lieu des 4 processeurs réservés par Docker. De même, le nombre de threads BLAS, qui peut être obtenu avec BLAS.get_num_threads() et qui, sur les systèmes x86-64, correspond par défaut à la moitié du nombre de cœurs disponibles, est de 11 au lieu de 2. Avec Julia v1.12, ce problème est résolu, et le nombre de threads Julia et BLAS respectera le nombre de processeurs réservés par Docker :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
% docker run --cpus=4 --rm -ti julia:1.12 julia --threads=auto -e '@show Threads.nthreads(); using LinearAlgebra; @show BLAS.get_num_threads()'
Threads.nthreads() = 4
BLAS.get_num_threads() = 2


Ce nouveau comportement est également important pour éviter la sursouscription dès le départ lors de l'exécution de Julia sur des systèmes HPC où les planificateurs définissent l'affinité du CPU lors de l'utilisation de ressources partagées.

OncePerX

Certains modèles d'initialisation ne doivent s'exécuter qu'une seule fois, en fonction de leur portée : par processus, par thread ou par tâche. Pour faciliter et sécuriser cette opération, Julia propose désormais trois types intégrés :

  • OncePerProcess{T}: runs an initializer exactly once per process, returning the same value for all future calls.
  • OncePerThread{T}: runs an initializer once for each thread ID. Subsequent calls on the same thread return the same value.
  • OncePerTask{T}: runs an initializer once per task, reusing the same value within that task.

Ils remplacent les solutions courantes développées manuellement, telles que l'utilisation directe de __init__, nthreads() ou task_local_storage().

Exemple simple d'utilisation de OncePerProcess :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
julia> const global_state = Base.OncePerProcess{Vector{UInt32}}() do
           println("Making lazy global value...done.")
           return [Libc.rand()]
       end;
 
julia> a = global_state();
Making lazy global value...done.
 
julia> a === global_state()
true


Cas d'utilisation :

  • OncePerProcess : caches, constantes globales ou initialisation qui doivent avoir lieu une seule fois par processus Julia (même lors de la précompilation).
  • OncePerThread : état par thread nécessaire à l'interopérabilité avec les bibliothèques C ou les modèles de threading spécialisés.
  • OncePerTask : état léger de la tâche locale sans gestion manuelle de task_local_storage.

Ces types offrent un moyen plus sûr et modulable d'exprimer la sémantique « initialiser une seule fois » dans le code Julia concurrent.

Compilation de Julia et LLVM à l'aide de l'outil Binary Optimization and Layout (BOLT)

BOLT est un optimiseur post-liaison de LLVM qui améliore les performances d'exécution en réorganisant les fonctions et les blocs de base, en séparant le code chaud et le code froid, et en regroupant les fonctions identiques. Julia prend désormais en charge la compilation de versions optimisées par BOLT de libLLVM, libjulia-internal et libjulia-codegen.

Ces optimisations réduisent le temps de compilation et d'exécution dans les charges de travail courantes. Par exemple, les benchmarks d'inférence globale s'améliorent d'environ 10 %, une charge de travail LLVM intensive affiche un gain similaire d'environ 10 %, et la compilation de corecompiler.ji s'améliore de 13 à 16 % avec BOLT. En combinaison avec PGO et LTO, des améliorations totales pouvant atteindre environ 23 % ont été observées.

Pour créer une version optimisée de Julia pour BOLT, exécutez les commandes suivantes à partir de contrib/bolt/ :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
make stage1
make copy_originals
make bolt_instrument
make finish_stage1
make merge_data
make bolt


Les binaires optimisés seront disponibles dans le répertoire optimized.build. Un workflow analogue existe dans contrib/pgo-lto-bolt/ pour combiner BOLT avec PGO+LTO.

BOLT ne fonctionne actuellement que sur Linux x86_64 et aarch64, et les fichiers .so résultants ne doivent pas être dépouillés. Certains avertissements readelf peuvent apparaître pendant les tests, mais ils sont considérés comme inoffensifs.

La famille de macros @atomic prend désormais en charge la syntaxe d'affectation de référence

La famille de macros @atomic prend désormais en charge l'indexation (par exemple m[i], m[i,j]) en plus de l'accès aux champs. Cela permet d'effectuer des opérations atomiques de récupération, de définition, de modification, d'échange, de comparaison et d'échange, et de définition unique directement sur des références de type tableau. Les macros se développent en nouvelles API : getindex_atomic, setindex_atomic!, modifyindex_atomic!, swapindex_atomic!, replaceindex_atomic! et setindexonce_atomic!. L'indexation Vararg et CartesianIndex est prise en charge.

Par exemple :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
mem = AtomicMemory{Int}(undef, 2)
 
@atomic mem[1] = 2                 # atomic set
x = @atomic mem[1]                 # atomic fetch
@atomic :monotonic mem[1] += 1     # atomic modify with order
old = @atomicswap mem[1] = 4       # atomic swap (returns old)
res = @atomicreplace mem[1] 4 => 10  # (old=4, success=true)
ok  = @atomiconce mem[2] = 7         # set once (Bool)


Nouvelles fonctionnalités de paquets

Espace de travail

Un espace de travail est un ensemble de fichiers de projet qui partagent tous le même manifeste. Chaque projet d'un espace de travail peut inclure ses propres dépendances, ses informations de compatibilité et même fonctionner comme un package complet.

Lorsque le gestionnaire de packages résout les dépendances, il prend en compte les exigences et la compatibilité de tous les projets de l'espace de travail. Les versions compatibles identifiées au cours de ce processus sont enregistrées dans un seul fichier manifeste.

Un espace de travail est défini dans le projet de base en fournissant une liste des projets qu'il contient :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
[workspace]
projects = ["test", "docs", "benchmarks", "PrivatePackage"]


Cette structure est particulièrement avantageuse pour les développeurs qui utilisent une approche monorepo, où un grand nombre de paquets non enregistrés peuvent être impliqués. Elle est également utile pour ajouter de la documentation ou des benchmarks à un paquet en incluant des dépendances supplémentaires au-delà de celles du paquet lui-même. Il est désormais recommandé de spécifier les dépendances spécifiques aux tests à l'aide de l'approche par espace de travail (un fichier de projet dans le répertoire de test qui fait partie de l'espace de travail défini par le fichier de projet du paquet).

Les espaces de travail peuvent également être imbriqués : un projet qui définit lui-même un espace de travail peut également faire partie d'un autre espace de travail. Dans ce cas, les espaces de travail sont « fusionnés », un seul manifeste étant stocké à côté du « projet racine » (le projet qui n'est pas inclus dans un autre espace de travail).

Applications

Une application est un paquet Julia qui peut être exécuté directement depuis le terminal, à l'instar d'un programme autonome. Chaque application fournit un point d'entrée via @main et peut définir ses propres indicateurs Julia par défaut et son nom d'exécutable.

Lorsqu'une application est installée, elle est placée dans .julia/bin. En l'ajoutant à votre PATH, vous pouvez la lancer par son nom avec des arguments ou des options.

Une application Julia est définie dans le fichier Project.toml à l'aide d'une section [apps] :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
[apps]
reverse = {} # empty dictionary is for additional metadata


avec un point d'entrée correspondant dans le module du paquet :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
# src/MyReverseApp.jl
module MyReverseApp
 
function (@main)(ARGS)
    for arg in ARGS
        print(stdout, reverse(arg), " ")
    end
end
 
end # module


Après l'installation, l'application peut être exécutée directement dans le terminal :

Code julia : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
$ reverse some input string
emos tupni gnirts


Cela rend les applications utiles pour créer des outils CLI ou regrouper les fonctionnalités Julia sous forme d'exécutables destinés aux utilisateurs. Plusieurs applications peuvent être définies par paquet à l'aide de sous-modules, et chaque application peut spécifier des indicateurs Julia par défaut (par exemple --threads=4) pour améliorer les performances ou faciliter le débogage.

Pour en apprendre davantage sur ce langage de programmation, vous pouvez consulter cette excellente présentation de Julia.

Source : Julia

Et vous ?

Quel est votre avis sur le sujet ?
Trouvez-vous les améliorations apportées par cette version de Julia pertinentes et utiles ?

Voir aussi :

La version 1.11 du langage de programmation Julia est disponible, apportant un nouveau type "Memory" et de nouvelles fonctionnalités à l'inférence

Annonce de Julia v1.10, la nouvelle version du langage de programmation apporte des améliorations en termes de rapidité et de confort pour les développeurs

La version 1.9 de Julia, un langage de programmation de haut niveau, est disponible, la nouvelle version est livrée avec de nouvelles fonctionnalités qui vont améliorer les performances du langage