Patrice Ferlet
Patrice Ferlet
Créateur de ce blog.
Publié le 28 septembre 2025 Temps de lecture: 14 min

Me suivre sur Mastodon

Oui, python peut rivaliser avec C et Rust

thumbnail for this post

Si je vous dis que vous pouvez développer en Python et faire du calcul (presque) aussi rapide qu'avec C ou Rust, vous allez penser que je trolle. Avec Cython, vous allez voir que vous allez pouvoir vous défendre.

Je ne sais pas si c’est par frustration ou par snobisme, mais Python n’a jamais été aussi critiqué pour sa “lenteur” que depuis qu’il est le langage le plus utilisé au monde.

On ne choisit pas Python pour sa vitesse d’exécution, mais pour sa vitesse de production.

Cependant, la plupart des détracteurs de Python oubli une chose importante, c’est que Python est utilisé comme un langage d’interface à des modules développés dans d’autres langages.

Python est très utilisé dans le domaine de l’IA. Vous êtes conscient que s’il y a bien un domaine dans lequel il est nécessaire d’avoir de la vitesse, c’est bien celui-là ! Pourtant, il reste le langage favori des chercheurs en data-science. C’est tout simplement parce que TensorFlow, PyTorch, NumPy et autres bibliothèques de calcul intensif ne sont pas directement développés en Python. Python nous donne accès à des trucs codés en C, C++, Rust, etc.

Alors, sommes-nous limité à devoir développer des modules en C/C++/Rust pour faire du calcul intensif ? Pas forcément.

Depuis 2007, une série d’outils dont vous avez certainement entendu parler, permettent de compiler du code Python et accéder à des performances proches de celles de C/C++/Rust. Et son nom, vous l’avez certainement déjà croisé, c’est Cython

TL;DR

Importez from cython import int, ccall dans votre module Python, ajoutez @ccall au-dessus de vos fonctions. Importez aussi les types que vous voulez utiliser (float, double, char, etc) depuis cython.

Ajoutez les annotations de type aux arguments et aux retours de vos fonctions.

from cython import int, float, ccall

@ccall
def votre_fonction(arg1: int, arg2: float) -> float:
    # votre code ici
    pass

Compilez votre module avec la commande :

cythonize -i -3 votre_module.py

Fini… Votre module rivalise avec C et Rust.

Maintenant, si vous voulez comprendre ce qu’il se passe, lisez la suite.

Cython

À ne pas confondre avec CPython qui est le nom que l’on donne à Python pour le différencier des autres implémentations (Jython, PyPY, mypy…). Cython, j’en avais déjà parlé dans un vieil article, mais il traitait essentiellement d’accéder à des librairies partagées.

Là, on va vraiment faire du code, et mesurer.

Cython peut faire plusieurs choses :

  • Compiler du code Python en passant par du C
  • Utiliser un langage adapté, le Pyrex, qui permet d’utiliser des types statiques
  • S’intégrer dans un module pour proposer des types et des décorateurs pour améliorer la compilation
  • Et pas mal de choses…

J’aime beaucoup Pyrex, mais je vais vous montrer une chose que j’ai pris l’habitude de faire pour gagner du temps.

Si vous avez pris l’habitude d’annoter vos fonctions (avec des types que Python n’utilise pas), alors ça va être ultra-simple.

Prenons l’exemple d’une suite de Fibonacci. C’est un classique, mais c’est un bon exemple pour faire un test de performances.

# fib.py
def fib(n: int) -> int:
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

Ce bout de code, si je l’appelle avec “n=40”, sur ma machine, il va prendre 10 secondes.

Pour les mesures, je vais l’appeler avec ce bout de code :

import sys
from fib import fib

iteration= sys.argv[1] if len(sys.argv) > 1 else 1
for i in range(int(iteration)):
  fib(40)

Si je le fait tourner plusieurs fois, c’est pour éviter d’avoir un biais dû au “warm up”. Parce que si je ne l’appelle qu’une seule fois, Python bat Rust à pleines coutures. Cette hérésie me vaudrait un bon vieux shitstorm et j’ai pas envie de passer mon temps à bannir des trolls.

En pur Python : pythnon main.py ne va appeler fib(40) qu’une seule fois et va prendre 10 secondes. 10 secondes, c’est énorme. Et là, oui, Python est lent. Plus lent que la majorité des langages compilés.

Première compilation, à la bourrin

On peut déjà compiler le code tel quel, sans rien changer. Le gain ne sera pas énorme, mais vous allez voir que ça a déjà un impact.

cythonize -i -3 fib.py

Cela produit un fichier “.so” (sur Linux) et Python est assez malin pour savoir qu’il doit importer la librairie à la place de “fib.py”.

Testons le temps d’exécution.

time python main.py

Cette fois, le temps d’exécution de fib(40) tombe à 7 secondes. Donc, un gain de 30%. Pas mal, mais on n’est toujours pas proche de ce que je vois en C, en Rust, ou Java ou en Go.

On va donc passer aux choses sérieuses.

Le coup de pouce

La documentation est vraiment claire. Soit, vous refaites votre code en Pyrex, soit vous utilisez un peu de magie. Et voici ce que je vous propose : ne pas toucher au code Python à part forcer les types et décorer la fonction.

Donc on ajoute juste deux lignes :

from cython import int, ccall

@ccall
def fib(n: int) -> int:
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

Le fait d’avoir annoté les types a été une bonne chose, car maintenant, le type “int” n’est plus le “type natif” de Python, mais un int qui sera vu tel quel dans le code C que Cython va produire.

Et, ccal, à lire “C Call” (appel en C), va faire en sorte de produire une fonction en C et une fonction accessible depuis Python lors de la compilation.

On va compiler le module avec la commande suivante :

cythonize -i -3 fib.py

Et là, le temps d’exécution de fib(40) tombe à… 0.14 seconde. Oui, vous avez bien lu, 0.14 seconde. Soit un facteur de réduction de 100 !

Non mais attend, c’est quoi tout ça ?

Je vous laisse aller lire les documentations, mais je vais essayer de vous résumer ce qu’il se passe.

Cython, à la base, permet de compiler du code Python en C. À l’origine, il fallait utiliser un langage dédié, le Pyrex, très proche de Python. Pyrex propose des définitions spécifiques pour définir les types, les fonctions et les classes. Et en soit, on aurait pu réécrire la fonction en Pyrex :

# fichier fib.pyx

cdef _fib(int n):
  # sera compilé en C
  if n <= 1:
      return n
  return fib(n - 1) + fib(n - 2)

def fib(int n):
  # enveloppe l'appel C pour la rendre accessible depuis Python
  return _fib(n)

La définition cdef permet de définir une fonction qui ne sera pas accessible depuis Python. C’est une fonction qui sera traduite en C.

Pyrex accepte aussi def qui permet de définir une fonction qui sera accessible depuis Python. Et donc non compilée. C’est pour cela que je fais, ici, une fonction qui permet d’envelopper l’appel C.

Cython propose aussi cpdef qui permet de faire les deux en même temps. C’est-à-dire, qu’il fait pour nous la fonction en C et l’enveloppe Python.

cpdef fib(int n):
  if n <= 1:
      return n
  return fib(n - 1) + fib(n - 2)

Pratique, mais on reste dans un langage dédié.

Mais depuis quelques années, nous avons pris l’habitude d’annoter nos fonctions Python avec des types. Python se fout de ses annotations, c’est surtout pratique pour les IDE et les outils de vérification de type comme mypy.

Sauf que les auteurs de Cython ont eu la bonne idée de proposer de les gérer. A condition de bien définir qu’un “int” est un “int” du langage C. C’est pour cela que j’importe int depuis cython.

Et, @ccall permet de dire que cette fonction doit être compilée en C et qu’une enveloppe Python doit être créée, tout comme on le ferait avec cpdef dans un script Pyrex.

Cela rend vraiment la vie plus simple. On peut garder son code Python, et juste ajouter des annotations de type et des décorateurs. Sans compilation, le code Python est exactement le même et fonctionne de la même manière. Un coup de cythonize et nous avons toutes les optimisations de Cython.

Dans un projet

On ne va pas s’amuser à taper des commandes cythonize à la main. Ce que l’on va faire, c’est adapter notre fichier pyproject.toml pour définir un module d’extension.

[build-system]
requires = ["setuptools", "cython"]

[tool.setuptools]
packages = ["example"]

[tool.setuptools.ext_modules]
fib = { sources = ["example/fib.py"], language = "c" }

Et quand votre projet sera installé, le module sera compilé automatiquement.

On compare avec C

Alors, on pourrait se demander si en C, on ferait mieux, et bien tentons :

#include <stdio.h>
#include <stdlib.h>

uint fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <n>\n", argv[0]);
        return 1;
    }
    int n = atoi(argv[1]);
    printf("%u\n", fib(n));
    return 0;
}

Si je compile avec :

gcc -Ofast -march=native -o fib fib.c
time ./fib 40

Et que je lance ./fib 40, j’obtiens un temps d’exécution de 0.10 seconde. Donc, oui, c’est plus rapide, mais pas de beaucoup.

Avec Clang :

clang -O3 -march=native -ffast-math -o fib fib.c
time ./fib 40

Cette fois, on est à 0.04 seconde. Donc, on est 3.5 fois plus rapide que Cython.

Oui, Clang est vraiment un bon compilateur. Et sur des tâches mathématiques, il est souvent plus rapide que GCC.

Benchmark

Pour ne pas se faire avoir par le “warm up”, il faut appeler plusieurs fois la fonction dans le même processus. Sinon, vous allez avoir un biais monstre. Je me suis amusé à coder la fonction en C, Go, en Rust, en Java, en pur Python avec et sans compilation cythonize.

  • Pour C, je tente avec deux compilateurs : clang et gcc.
  • Je n’ai pas trouvé comment bien utiliser clang avec cythonize, tous mes tests avaient un temps catastrophique. Je suis donc resté sur GCC.
  • J’utilise OpenJDK 21.0.8 pour la version Java
  • Go 1.25

Les options de compilation sont prévues pour optimiser au mieux :

  • rustc prend les options -C opt-level=3
  • clang prend les options -O3 -march=native -ffast-math
  • gcc utilise les options -Ofast -march=native

Il y a certainement de meilleures options, mais le but ici est de montrer que Cython donne un vrai gain.

C’est sans appel…

Benchmark no log

Clairement, Python “pur” est complètement à la ramasse. Cython fait une énorme différence.

On peut, bien entendu, mettre le graphique en échelle logarithmique pour mieux voir les différences entre les langages compilés.

Benchmark log avec Python

Si je supprime Python, on voit mieux les différences entre les langages compilés.

Benchmark

Bien entendu que Rust est plus rapide (et encore heureux, c’est son seul atout1…). Go semble très lent, mais je vous rappelle que Go n’utilise pas d’optimisation agressive comme Rust ou C/C++. Go est un langage qui compile vite, qui a des performances honorables, mais qui n’est pas fait pour faire du calcul intensif.

Java est dans la moyenne, mais il faut savoir que la JVM a un “warm up” important. Encore une fois, le but de Java n’est pas de faire du calcul intensif, mais d’avoir des performances honorables dans un environnement spécifique.

On reviendra sur le sujet dans quelques instants, mais j’insiste : on ne choisit pas un langage seulement pour sa vitesse d’exécution. On le choisit pour ses qualités d’intégration, de structuration, de maintenabilité etc. Sinon, on ferait tous de l’assembleur…

On remarque aussi que CLang est particulièrement bon. Je l’utilise plus souvent que GCC depuis quelques années, et je ne m’étais pas vraiment penché sur ses qualités d’optimisation. Il n’est clairement pas mauvais le bougre.

Attention

Il ne faut pas faire de déduction hâtive. Le code de Fibonacci est récursif et n’utilise pas de structures de données. C’est un cas particulier. Mais il montre que Cython peut faire une grosse différence.

Soyez sympas de ne pas utiliser cet article de manière fallacieuse. Oui, Rust est super rapide, oui Go est largement plus lent que Rust ou C.

Si je choisis très souvent le Go, c’est parce qu’il est largement assez rapide, que son langage est simple pour traiter des cas complexes d’asynchronisme, que son écosystème est riche. Je choisis Python pour traiter des données hétérogènes, accéder aux modules d’IA de manière simple, et comme je suis près à attendre 100 ms pour qu’une réponse HTTP arrive.

Je choisis rarement Rust, même si je suis parfaitement conscient de ses qualités. Mais sa syntaxe est lourde pour mon domaine d’activité.

J’ai arrêté le Java puisqu’il ne m’apporte absolument rien par rapport à Go, Python ou Rust.

Est-ce que vous devez faire pareil que moi ? NON !

Par contre, je donne un conseil général, apprenez plusieurs langages. Cela vous permet d’appréhender plusieurs méthodologies, plusieurs concepts que vous pouvez adapter à d’autres langages. Cela vous permet d’aller plus vite sur certaines tâches.

Par exemple, j’ai souvent besoin de “post-process” avec Helm, pour modifier du YAML à la volée. Croyez-moi, je ne le ferai jamais en Rust ou en C. Python est parfait pour ça.

Je vais le répéter, mais le but d’un langage n’est pas nécessairement la vitesse d’exécution. Python est exceptionnel pour travailler vite, traiter des données complexes sans avoir à passer des heures sur l’implémentation.

Rust a ses qualités, Go en a d’autres, Java aussi. Et dans les faits, tous les langages sont valables, tous ont une qualité que les autres n’ont pas.

Ce que je voulais vous montrer ici, c’est que pour Python, il existe un moyen de le faire calculer très vite, bien plus vite que si on le laissait en “pur Python”.

Je le répète, on passe de 10 secondes à 0.14 seconde !

Le code pour les benchmarks

Le code utilisé est le suivant, pour chaque langage :

use std::env::args;

fn fibo(n: u32) -> u32 {
    if n <= 1 {
        return n;
    }
    fibo(n - 1) + fibo(n - 2)
}

fn main() {
    let iteration: i32 = args()
        .collect::<Vec<_>>()
        .get(1)
        .expect("Please provide the iteration argument")
        .parse()
        .expect("Iteration must be an integer");


    let n = 40;
    for _ in 0..iteration {
        _ = fibo(n);
    }
}
package main

import (
	"os"
	"strconv"
)

func fibo(n uint) uint {
	if n <= 1 {
		return n
	}
	return fibo(n-2) + fibo(n-1)
}

func main() {
	iteration := os.Args[1]
	nbIteration, err := strconv.Atoi(iteration)
	if err != nil {
		panic(err)
	}

	for range nbIteration {
		_ = fibo(40)
	}
}
public class Main {
  public static void main(String[] args) {
    int iteration = Integer.parseInt(args[0]);
    for (int i = 0; i < iteration; i++) {
      fibonacci(40);
    }
  }

  public static int fibonacci(int n) {
    if (n <= 1) {
      return n;
    } else {
      return fibonacci(n - 1) + fibonacci(n - 2);
    }
  }
}
#include <stdint.h>
#include <stdlib.h>

uint32_t fibo(uint32_t n) {
  if (n <= 1)
    return n;

  return fibo(n - 1) + fibo(n - 2);
}

int main(int argc, char **argv) {
  if (argc != 2)
    return EXIT_FAILURE;

  char *count = argv[1];
  int n = atoi(count);

  // launch
  for (int i = 0; i < n; i++)
    fibo(40);

  return EXIT_SUCCESS;
}

Pour Python, le code du module est celui donné dans l’article, et le script de lancement est le suivant :

import sys

from fibo import fibo

if __name__ == "__main__":
    iteration = int(sys.argv[1]) if len(sys.argv) > 1 else 1
    for _ in range(iteration):
        fibo(40)

Toutes les importations sont appelées pour lancer 10 fois le calcul de fibo(40). Il m’a suffi de prendre le temps total et le diviser par 10 pour avoir le temps moyen.

Au final

Bien entendu, Python est un langage interprété, et il ne sera jamais aussi rapide que C, Rust ou Go. Je ne cesserai de le rappeler, mais on ne choisit pas un langage pour sa vitesse d’exécution. On le choisit pour sa capacité à nous permettre de travailler dans de bonnes conditions.

Cython apporte de la compilation à Python, et permet de faire du calcul intensif. Ce n’est pas la panacée, mais c’est une vraie solution pour améliorer les performances de certains modules.

Si les performances sont un critère important, alors il faut savoir que Python peut faire du calcul intensif. Au prix d’un peu de configuration, on peut obtenir des performances très honorables, proche du C, voire de Rust.

Mais, si la vitesse est le premier critère, alors il faut se tourner vers des langages compilés, adaptés à la tâche.

Je ne choisis Rust que dans les cas où j’ai besoin de performances extrêmes, et où je ne peux pas me permettre d’attendre 100 ms pour une réponse. Dans ce cas je suis prêt à passer du temps à écrire du code plus lourd et moins productif. Oui, vous l’avez compris, je ne suis pas fan de Rust…

Go propose un bon compromis entre vitesse, simplicité et productivité. C’est pour cela que je l’utilise très souvent.

Cependant, j’utilise énormément Python, parce que c’est un langage qui me permet de faire plein de choses rapidement. Si la vitesse me pose un souci (ce qui est rare), alors j’utilise Cython. Si je pense qu’un autre langage est plus approprié, je l’utilise. Je neme fige pas, j’ai appris une quinzaine de langages justement pour être à l’aise dans un maximum de situations.

Chaque langage a sa place.


  1. oui je trolle un peu, j’ai le droit, c’est mon blog. ↩︎

comments powered by Disqus