2 points par GN⁺ 2025-10-04 | 1 commentaires | Partager sur WhatsApp
  • En Python, il est d’usage de déclarer tous les imports au niveau du module
  • Mais à l’exécution, même des modules de dépendances inutiles sont chargés immédiatement, ce qui pose des problèmes de vitesse de démarrage et d’utilisation mémoire
  • Jusqu’ici, on utilisait souvent des imports différés manuels, par exemple à l’intérieur des fonctions, mais cela avait le désavantage de compliquer la maintenance et la gestion des dépendances
  • Ce PEP 810 introduit une syntaxe d’import différé explicite avec le nouveau mot-clé lazy, local, explicite, contrôlé et granulaire
  • Cette fonctionnalité permet de charger les modules uniquement au moment où ils sont réellement nécessaires, tout en améliorant la latence au démarrage, le gaspillage mémoire et la transparence de la structure du code

Situation actuelle des imports Python et problèmes associés

  • En Python, il est généralement d’usage d’écrire les instructions import en haut du module
  • Cette approche réduit les duplications, permet de visualiser d’un coup d’œil la structure des dépendances d’import, et minimise l’overhead d’exécution en n’important qu’une seule fois
  • Cependant, lorsque le programme démarre et que le premier module (main) est chargé, il est fréquent qu’une chaîne d’imports charge immédiatement de nombreux modules de dépendance qui ne seront en réalité jamais utilisés
  • C’est particulièrement visible dans les outils CLI : même lorsqu’on n’appelle que l’aide globale, des dizaines de modules peuvent être préchargés, ce qui ajoute un overhead inutile à chaque sous-commande

Solutions existantes et leurs limites

  • On retarde souvent manuellement le moment de l’import, par exemple en déplaçant les imports à l’intérieur des fonctions
  • Mais cette méthode nuit à la cohérence et à la maintenabilité, et rend plus difficile la compréhension de l’ensemble des dépendances
  • D’après l’analyse de la bibliothèque standard, dans le code sensible aux performances, environ 17 % de tous les imports sont déjà utilisés à des fins de différé à l’intérieur de fonctions ou de méthodes
  • Il existe des outils liés au chargement différé, comme importlib.util.LazyLoader ou le package tiers lazy_loader, mais ils ne couvrent pas tous les cas ou souffrent de l’absence d’un standard unique

PEP 810 : introduction des imports différés explicites

  • Introduction du nouveau mot-clé souple lazy (qui n’a un sens que dans certains contextes et peut aussi servir de nom de variable, etc.)

  • lazy ne peut être utilisé que devant une instruction import ; il ne peut pas être utilisé dans les portées de fonctions/classes/with/try, ni avec les star imports

  • Chaque instruction d’import est distinguée explicitement pour différer le chargement du module jusqu’au moment de son utilisation

    lazy import nom_du_module
    lazy from nom_du_module import nom
    

Mode d’implémentation des imports différés explicites et règles syntaxiques

  • Cas d’erreur de syntaxe :

    • Interdits dans une fonction, dans une classe, dans try/with, ainsi qu’avec les star imports (*)
  • Exemple d’utilisation :

    import sys
    lazy import json
    print('json' in sys.modules)  # False (pas encore chargé)
    result = json.dumps({"hello": "world"})  # chargé au premier usage
    print('json' in sys.modules)  # True (chargement différé du module terminé)
    
  • Au niveau du module, il est aussi possible de déclarer les cibles lazy via l’attribut __lazy_modules__ sous forme de liste de chaînes

    __lazy_modules__ = ["json"]
    import json  # traité comme lazy
    

Contrôle du comportement via drapeaux globaux et filtres

  • Il est possible de contrôler l’activation du mode lazy au niveau d’un module ou de l’ensemble de l’application via un drapeau global ou une fonction de filtrage

  • Une fonction de filtrage permet aussi d’appliquer des exceptions en eager import à certains modules spécifiques

    def my_filter(importer, name, fromlist):
        if name in {'problematic_module'}:
            return False  # eager import
        return True  # lazy import
    sys.set_lazy_imports_filter(my_filter)
    

Comportement à l’exécution et gestion des erreurs

  • Avec un lazy import, l’import réel ne se produit pas au moment de l’instruction, mais au premier accès au nom

  • Si l’import échoue, le système montre clairement à la fois l’emplacement de définition et celui où l’erreur s’est produite grâce au chaînage d’exceptions (traceback chaining)

    lazy from json import dumsp  # faute de frappe
    result = dumsp({"key": "value"})  # ImportError au moment du premier accès réel
    

Bénéfices mémoire et performances

  • Les modules différés n’apparaissent que dans l’ensemble sys.lazy_modules et ne sont pas enregistrés dans sys.modules avant leur utilisation réelle
  • Après utilisation, ils sont remplacés par un objet module normal, sans pénalité de performance supplémentaire
  • Sur des charges de travail réelles, on observe une réduction de 50 à 70 % de la latence de démarrage et une baisse de 30 à 40 % de la mémoire utilisée

Résumé du fonctionnement

  • Au premier accès à un objet lazy, une reification (import réel puis remplacement) se produit
  • Si du code externe accède au __dict__ du module, tous les objets lazy sont chargés de force (reification)
  • Lorsqu’on récupère le dictionnaire via globals(), le proxy lazy est conservé et nécessite un accès direct

Annotations de type et optimisation de TYPE_CHECKING

  • Avec lazy from module import nom, les imports utilisés uniquement pour les types garantissent un coût d’exécution nul
  • Cela permet de remplacer les conditions existantes basées sur from typing import TYPE_CHECKING par un code plus concis et plus clair

Différences avec le précédent PEP 690 et caractéristiques d’implémentation

  • PEP 810 adopte une approche opt-in explicite, par import individuel, fondée sur un objet proxy simple
  • À l’inverse, PEP 690 reposait sur une structure de lazy import globale et implicite

Points d’attention et interactions entre modules

  • Les star imports (*) ne sont pas pris en charge en lazy (ils restent toujours eager)
  • Les import hooks et loaders personnalisés continuent de fonctionner tels quels au moment de la reification
  • Même dans un environnement multithread, l’import n’a lieu qu’une seule fois de manière thread-safe, avec une liaison sûre garantie
  • Si un même module est utilisé à la fois en lazy et en eager, la version eager a toujours priorité

Guide d’application au code et migration

  • Lors de l’adoption dans du code existant, il est recommandé d’utiliser le profiling pour convertir uniquement les imports nécessaires en lazy et de procéder progressivement
  • L’utilisation de __lazy_modules__ permet aussi une compatibilité avec les versions antérieures à Python 3.15

Autres questions et réponses importantes

  • Les effets de bord au moment de l’import (par exemple des patterns d’enregistrement) sont différés jusqu’au premier accès. Si ces side effects sont indispensables, il est recommandé d’utiliser explicitement un schéma de fonction d’initialisation
  • Les problèmes de circular import ne sont pas entièrement résolus par les lazy imports (ils ne peuvent être atténués que si l’accès est lui-même retardé)
  • Les performances sur les hot paths sont automatiquement optimisées après le premier usage, car toute vérification lazy disparaît complètement (adaptive specialization du bytecode)
  • Le vrai module n’est enregistré dans sys.modules qu’après la reification (premier usage)
  • Contrairement à importlib.util.LazyLoader, aucune configuration séparée n’est nécessaire, les performances sont préservées et la syntaxe standard est plus claire

Conclusion

  • PEP 810 ajoute le mot-clé lazy aux instructions d’import Python, afin d’optimiser de façon concise et prévisible les problèmes de performance liés au chargement inutile de modules dans de nombreux contextes, comme les CLI à sous-commandes, les grandes applications ou les annotations de type
  • Ce nouveau mot-clé permet de définir finement le moment d’introduction et la cible, ce qui le rend adapté à une adoption progressive et au tuning des performances en production
  • Il s’agit d’une évolution concrète du système d’import de Python, capable de répondre simultanément aux exigences de visibilité, maintenabilité et performances

1 commentaires

 
GN⁺ 2025-10-04
Avis Hacker News
  • Mon outil CLI llm.datasette.io prend en charge les plugins, mais j’ai reçu beaucoup de plaintes sur son démarrage trop lent, même pour des commandes comme llm --help. En vérifiant, j’ai constaté que des plugins populaires importaient par défaut des paquets lourds comme pytorch, ce qui bloquait tout le démarrage. J’ai donc indiqué dans la documentation destinée aux auteurs de plugins qu’il fallait importer les dépendances uniquement à l’intérieur des fonctions, quand c’est nécessaire (lien vers la doc correspondante), mais ce serait bien mieux si Python gérait ça directement au niveau du langage

    • On peut implémenter cette fonctionnalité dès aujourd’hui dans des outils (lien explicatif), mais cette approche s’applique globalement à tout le processus, donc si on importe numpy en mode paresseux, tous ses sous-modules le deviennent aussi. Au final, si on n’a pas besoin de tout numpy, il peut ne jamais être importé, mais cela peut aussi disperser de façon imprévisible dans l’exécution les imports partiels au moment où ils deviennent nécessaires. Autre résultat d’expérimentation : avec import foo.bar.baz, foo et foo.bar sont tout de même chargés immédiatement, et seul foo.bar.baz devient paresseux. C’est probablement une partie de la raison pour laquelle le PEP parle de « mostly ». Si j’améliore encore mon implémentation, je pense pouvoir corriger ça

    • Je recommande de commencer par parser la ligne de commande afin de traiter des options comme --help sans import, et de ne lancer les imports que lorsque c’est réellement nécessaire. Dit plus simplement, on peut concevoir l’outil pour n’importer qu’une fois les options simples traitées et seulement s’il reste encore du travail à faire

  • Des propositions de lazy import ont déjà existé par le passé, et la plus récente a été rejetée en 2022 (lien vers la discussion correspondante). Si je me souviens bien, le lazy import existe déjà dans Cinder, la variante de CPython chez Meta, et ce sont justement des gens qui travaillaient sur Cinder qui portent ce PEP. Les débats portaient sur « opt-in ou opt-out ? », « jusqu’où s’applique-t-il ? », « faut-il l’ajouter comme build flag de CPython ? », etc. Au final, le Steering Council l’aurait rejeté à cause de la complexité liée à une sémantique d’import divisée en deux. J’espère vraiment que cette nouvelle proposition passera, j’ai très envie d’utiliser cette fonctionnalité

    • J’apprécie particulièrement le fait que ce soit en opt-in, avec une application granulaire à plusieurs niveaux et même un interrupteur global pour désactiver le tout. C’est une spécification très bien construite dans le cadre de contraintes nombreuses

    • Moi aussi j’espère que cette proposition passera, mais je ne suis pas optimiste. Cela va casser énormément de code et provoquer toute une série de problèmes inattendus. Les instructions import ont fondamentalement des effets de bord, et si on change le moment où ils se produisent, on risque de se battre longtemps avec des bugs incompréhensibles. Ce n’est pas du catastrophisme, c’est une inquiétude fondée. Si le lazy import n’a existé que chez Meta, ce n’est pas un hasard : il faut sans doute les ressources de Meta pour gérer quelque chose comme ça. Beaucoup de gens ne voient que « pandas, numpy, ou mon module bizarre mal fichu est trop lent, donc ce serait bien si c’était plus rapide », alors qu’à mon avis, très peu comprennent réellement le fonctionnement du système d’import de Python. Beaucoup soutiennent même l’idée sans savoir comment un lazy import s’implémente. Si on lit le PEP 690, les inconvénients sont nombreux : par exemple, du code qui ajoute des fonctions à un registre central via des décorateurs se casse. La bibliothèque Dash, par exemple, relie une interface JavaScript et des callbacks Python au moment de l’import grâce à des décorateurs ; si l’import devient paresseux, ce genre de front-end cesse tout simplement de fonctionner. Des services avec énormément d’utilisateurs peuvent casser immédiatement. On entend « c’est opt-in, donc si ça ne convient pas, il suffit de désactiver le lazy import », mais si les imports sont transitifs ? Que faire si un processus critique doit démarrer seulement après initialisation complète du front-end ? Dans un écosystème où s’entremêlent le code et les bibliothèques de nombreuses personnes, qui peut prévoir l’impact ? Contrairement aux type hints, cela modifie réellement le comportement à l’exécution. Les instructions import sont présentes dans pratiquement tout code Python non trivial, donc introduire le lazy import change fondamentalement la manière dont le code s’exécute. Et il y a d’autres cas bizarres encore mentionnés dans le PEP. C’est un problème bien plus difficile qu’il n’y paraît

    • Ce serait formidable de pouvoir écrire des imports avec version explicite comme import torch==2.6.0+cu124, import numpy>=1.2.6, et de pouvoir installer/importer simultanément plusieurs versions d’un paquet dans un même environnement Python. J’aimerais vraiment qu’on en finisse avec l’enfer conda/virtualenv/docker/bazel

  • Je ne déteste pas l’idée, mais je ne l’accueille pas non plus avec un immense enthousiasme. En l’état, on dirait qu’on va finir par mettre lazy devant presque tous les import, sauf dans quelques rares cas où il faut vraiment un import eager. Du coup, le code devient plus sale, et comme ce comportement ne deviendra probablement jamais la valeur par défaut, cette lourdeur syntaxique restera pour toujours. J’aurais préféré un système où c’est le module qui déclare son opt-in au lazy loading, sans changement dans la syntaxe import. Ainsi, seules les grosses bibliothèques auraient à se soucier de la paresse de chargement. Bien sûr, cela impliquerait que l’interpréteur explore le système de fichiers au moment de l’import, avec d’autres inconvénients

    • Si tout le monde se met à utiliser massivement le lazy import sans problème particulier, cela voudra dire qu’il aurait dû être le comportement par défaut, et que c’est plutôt eager qui aurait dû être le mot-clé optionnel. Ce genre de changement de paradigme n’est pas inédit en Python : divers éléments qui construisaient des listes de manière eager en v2 sont devenus des generators en v3, sans que cela ne pose vraiment de problème

    • S’il existait un flag en ligne de commande pour rendre paresseux les imports de modules dans tout Python, je l’utiliserais sans hésiter. En pratique, en dehors de scripts ou de code vraiment trivial, avoir des effets de bord au chargement d’un module est un antipattern à éviter absolument

    • Je ne pense pas que ce soit au module de décider s’il doit être chargé de façon paresseuse. Seul l’appelant peut savoir s’il a besoin d’un lazy load, donc il est logique que l’option soit du côté du code qui importe. N’importe quel module peut être chargé paresseusement, et même s’il a des effets de bord, l’appelant peut vouloir différer ceux-ci lui aussi

    • J’aimerais pouvoir déclarer des options de lazy loading via des regex dans pyproject.toml

    • À chaque nouvelle fonctionnalité passée — type hints, walrus, asyncio, dataclasses, etc. — on a entendu des inquiétudes similaires, mais en pratique, il n’y a pas eu une adoption massive uniforme ni un remplacement complet des anciens patterns. Beaucoup d’utilisateurs se contentent encore d’un Python modernisé au niveau de 2.4 et restent parfaitement productifs. Cela fonctionne très bien depuis 20 ans, donc je pense que ça ira

  • Si cela vous intéresse, je recommande lazyimp, qui implémente le lazy import de manière très pratique sous forme de context manager. En général, il suffit d’entourer les import par un bloc with, ce qui s’intègre bien aux outils existants, et si on a besoin de déboguer, il est facile de repasser en import eager. Grâce à une extension C qui modifie le f_builtins des frames, l’approche est plus puissante qu’un hook importlib. Ce n’est pas parfait, mais il existe aussi une version thread-safe et une version avec handler global. J’étais prudent au début, mais j’ai maintenant migré presque toute ma base de code vers ça ; je n’ai rencontré aucun vrai problème en pratique, à part ne pas avoir pris soin d’enregistrer certains traitements module par module, et le gain de vitesse perçu est énorme, donc j’en suis très satisfait

  • Le fait que les linters Python imposent de placer les imports en haut du fichier est vraiment pénible. À chaque fois que j’utilise la méthode évidente pour faire des lazy imports, j’obtiens une erreur de lint. Et le problème dépasse largement la simple performance : par exemple, lorsqu’on a besoin d’une bibliothèque spécifique à une plateforme, on peut vouloir ne l’importer que sur cette plateforme, alors qu’un import en haut de fichier peut faire échouer tout simplement l’import

    • Dans ce cas, je pense qu’il faut simplement corriger le linter

    • La plupart des linters peuvent être contournés avec un commentaire comme #noqa E402

  • J’ai vu dire qu’on pouvait automatiser dans une certaine mesure les lazy imports via la classe LazyLoader. Mais comme cela repose sur des entrailles du système d’import Python peu transparentes, même les explications sur Stack Overflow ne sont pas très élégantes (Q&R correspondante). J’ai donc implémenté moi-même un proof of concept qui rend tous les imports paresseux sans syntaxe explicite

import sys
import threading  # nécessaire en Python 3.13, au moins dans le REPL
from importlib.util import LazyLoader  # ceci doit impérativement être importé immédiatement !
class LazyPathFinder(sys.meta_path[-1]):  # hérite de _frozen_importlib_external.PathFinder
  @classmethod
  def find_spec(cls, fullname, path=None, target=None):
    base = super().find_spec(fullname, path, target)
    base.loader = LazyLoader(base.loader)
    return base
sys.meta_path[-1] = LazyPathFinder

Avec ça, on remplace le meta path finder par un wrapper, qui remplace le loader par LazyLoader. Lorsqu’un import s’exécute, le nom du module est d’abord lié à <class 'importlib.util._LazyModule'>, puis le vrai module est chargé lorsqu’on accède à un attribut. Code d’expérimentation :

import this  # n’affiche rien
print(type(this))  # <class 'importlib.util._LazyModule'>
rot13 = this.s  # affiche le Zen, chargement du module à ce moment-là
print(type(this))  # <class 'module'>

Je ne sais toutefois pas exactement ce que signifie le « mostly » employé dans le PEP

  • J’ai l’impression que les risques de sécurité vis-à-vis des threads sont sous-estimés avec les lazy imports. Il est totalement imprévisible de savoir quand un import va s’exécuter, dans quel thread, et sous quels verrous ; on ne peut rien garantir en dehors du verrou d’import lui-même. Avant, même si du code dangereux s’exécutait à l’import d’un module, cela se produisait en général pendant une phase d’initialisation majoritairement mono-thread, donc le problème restait limité. En mode lazy, les erreurs risquent d’apparaître de manière vraiment imprévisible, façon Heisenbug. Les imports au niveau des fonctions ont aussi ce risque, mais au moins ils gardent une certaine prévisibilité puisqu’ils s’exécutent explicitement au tout début du code concerné

  • Cela me semble être une bonne fonctionnalité, facile à expliquer, avec de vrais cas d’usage et une portée raisonnable (globale, ou simple via mot-clé). J’aime bien

    • Parmi les PEP récents, c’est probablement celui qui me paraît le plus propre du point de vue utilisateur. J’attends le résultat après le traditionnel bikeshedding syntaxique

    • Je trouve que c’est un PEP préparé avec beaucoup de soin : validation sur des cas réels et des edge cases, compromis appropriés, approche non excessive, peaufinage répété. C’est particulièrement impressionnant compte tenu du risque qu’il y a à toucher à un système aussi fondamental pour un grand langage utilisé par des communautés très diverses dans le monde entier

    • J’espère qu’ils ont bien retenu la leçon du rejet du PEP-690. Dans notre base de code aussi, on avait essayé d’implémenter quelque chose de ce genre nous-mêmes, mais on n’a jamais réussi à obtenir un comportement vraiment exploitable

  • Le danger du lazy import, c’est qu’il peut facilement produire des erreurs d’exécution inattendues dans des services de longue durée. Cela ressemble à un avantage de démarrage rapide, mais le compromis est d’accepter que l’exécution puisse s’arrêter plus tard à cause d’un échec d’import. Il peut aussi y avoir des edge cases supplémentaires où l’on ne peut plus garantir au lancement du programme ce qui sera importé ou non

    • Malgré tout, c’est un vrai problème qu’il faut absolument résoudre. Ce n’est pas seulement une question de vitesse de démarrage : avec de grosses dépendances, le démarrage de Python devient absurdement lent. Les gros projets ne peuvent pas non plus embarquer toutes les bibliothèques lourdes que tous les utilisateurs n’emploieront pas forcément ; les développeurs utilisent déjà des contournements encore plus tordus, qui ajoutent eux aussi des problèmes absurdes. Ce serait déjà une énorme avancée rien que de ne plus devoir cacher ou dupliquer des imports au niveau des fonctions, et il ne s’agit de toute façon que d’une fonctionnalité optionnelle du langage

    • Des tests automatisés peuvent largement atténuer le risque, et cela vaut la peine en échange d’un démarrage plus rapide. Le temps de démarrage n’est en aucun cas un problème purement « cosmétique ». Dans un monolithe Django, j’ai déjà vécu des attentes de 10 à 15 secondes à chaque management command, test ou reload de conteneur, uniquement à cause de quelques bibliothèques lourdes. Une fois les imports différés avec du lazy import, la différence a été énorme

  • Nous avons tendance à préférer les imports explicites en haut de fichier, précisément pour faire remonter les problèmes de dépendances dès le démarrage du programme. Avec des lazy imports, on introduit l’inconvénient de ne découvrir un problème que lorsqu’un certain chemin de code est exécuté, peut-être des heures ou des jours plus tard

    • À l’inverse, si tous les imports étaient automatiquement différés, l’exécution de pip sur de petites tâches serait immédiatement plus rapide
$ time pip install --disable-pip-version-check
ERROR: You must give at least one requirement to install (see "pip help install")

real  0m0.399s
user  0m0.360s
sys   0m0.041s

La majeure partie du temps sert en réalité à importer puis décharger des modules vendor inutilisés — par exemple, presque 100 modules rien que du côté de Requests. Après diagnostic, on constate que plus de 500 modules sont importés inutilement au total

  • Je ne comprends pas non plus pourquoi les générateurs de code produisent de plus en plus souvent des import locaux dans les fonctions au lieu d’imports en haut de fichier. Je n’ai pas envie d’encourager ce pattern, car il rend plus difficile l’analyse des dépendances du module et augmente le risque d’introduire plus tard des dépendances circulaires

  • Je n’ai pas encore lu tout le PEP, mais je me dis qu’il serait utile d’avoir une validation des dépendances via un flag en ligne de commande ou un outil externe, un peu comme les outils associés aux type hints

  • Je me demande qui désigne exactement ce « nous »

  • Est-ce que ce n’est pas simplement quelque chose que les tests devraient couvrir ?