Les performances de TLC/mldown

problème avec pygments

Source - le 03/09/2010 à 11:19:04

J'ai testé vite fait le moteur de TLC hier soir. J'espérais hacker facilement un truc pour avoir un aperçu des commentaires avant publication, mais mon attention a été détournée par une autre question : les performances. J'ai fait quelques mesures, mais je n'ai touché à rien.

Construire le site TLC avec la base d'articles actuelles, ça prend plus d'une minute. Ça me paraît super long (quand je lance une commande, je considère en général qu'au dessus de 5 secondes c'est long, et au dessus de 20, super long).

Mesures

J'ai regardé le code pour voir s'il était possible d'améliorer ça. Sans surprise, la partie qui prend la quasi-totalité du temps est le parsing des .text par Mldown. J'ai essayé de le paralléliser, mais j'ai observé que (de la façon dont je m'y suis pris, c'est à dire très naïvement) on ne gagne pas grand chose.

En fait, j'ai inspecté plus en profondeur, et il se trouve que c'est Pygments, le moteur de coloration syntaxique, qui prend quasiment tout le temps de calcul : chez moi, le scan complet prend 55 secondes environ, et pygments en prend 40.

Je trouve étrange que ce soit aussi lent. Tous les articles ne contiennent pas de code, mais certains articles prennent beaucoup de temps à colorer. Le dernier article sur les générateurs, par exemple, prend 10 secondes à colorer, et l'article sur Brainfuck en Caml prend 5 secondes.

Questions

Qu'est-ce qu'on pourrait faire pour améliorer ça ?

Je me demande s'il n'est pas possible de configurer pygments pour qu'il soit moins lent. Sur ma machine j'observe qu'il ne demande que 10 à 20% du CPU au total, et ce quel que soit le nombre de processus lancé (c'est pour ça que l'essai de parallélisme n'était pas intéressant je pense : avec 5 process pygments lancé, chacun prend 4% de CPU).

Ensuite, je me demande s'il ne serait pas possible d'intégrer dans mldown un système de mise en cache du code. Actuellement quand on change deux mots d'un article, tout le source (et tous les codes à colorer) sont reparsés. Peut-être qu'on pourrait stocker quelque part une base (hash du source, résultat de la coloration) pour éviter une partie des recalculs. Cette approche a cependant deux défauts :

  • Actuellement mldown fonctionne comme une boîte noire, il faudrait casser un peu d'abstraction pour lui donner des fichiers de mise en cache sur le disque.
  • Ces techniques n'accélèrent que pour le recalcul d'articles modifiés. Dans TLC, j'imagine que c'est une action plutôt rare, en comparaison à la publication de nouveaux articles ou commentaires.

Enfin, je me demande si une approche où la coloration se fait côté client, si elle a des performances convenable, ne serait pas intéressante. Dans un topic PM ont été évoqués MathJax et SHJS, qui pourraient être des choix intéressants. L'inconvénient, ça revient à faire recalculer aux gens au moment du rendu, au lieu de calculer côté serveur, donc globalement on perd quand même en performances pour les choses facilement cachable côté serveur. Un avantage de la coloration côté client est qu'elle est désactivable ou configurable côté client, et qu'elle permet de produire des documents HTML plus propres.

7 réponse(s)

gnomnain # - le 03/09/2010 à 14:18:03 - Répondre - Source
Avatar

J'ai fait quelques tests depuis la ligne de commande : ce qui a l'air de prendre beaucoup de temps, c'est de lancer pygments : si je prends tous les codes en C# de l'article sur les générateurs, le temps passé sur un petit code est du même ordre de grandeur que celui passé sur tous les codes mis dans le même fichier. Il faudrait donc peut-être ne lancer pygments qu'une seule fois, et lui passer les code à la suite en utilisant d'une manière ou d'une autre l'api python. On devrait donc pas avoir ce genre de problème avec markdown d'ailleurs (une trentaine de code ont été colorés presque immédiatement dans le test que j'ai fait).

(D'ailleurs, j'ai essayé de mettre une centaine de codes comme ça sur le sdz et c'est allé très vite à colorer, donc j'imagine qu'ils utilisent ce genre de système)

En cherchant un peu dans les archives du sdz, j'ai trouvé ce message de delroth, qui propose d'utiliser XML-RPC pour communiquer avec pygments.

gasche # - le 03/09/2010 à 14:43:50 - Répondre - Source
Avatar

Oui, j'ai remarqué ça ensuite.

Le plus propre serait sans doute un daemon pygments qui tourne en fond de tâche, reçoit des couples (langage, chaîne) et pond du HTML en sortie. J'ai googlé assez rapidement, mais je n'ai pas trouvé de code pour ça déjà disponible, ce que je trouve un peu curieux.

asmanur # - le 03/09/2010 à 18:36:45 - Répondre - Source
Avatar

Oui en effet lancer pygments est assez couteux. Faire un pont C entre OCaml et Python me semble bien overkill, après je ne sais pas si pygments peut fonctionner en démon de manière native.

La manière la plus simple de faire me semble être d'écrire un script en python qui lit comme gasche a dit un couple (langage, chaine) sur une FIFO et qui écrit l'output sur une autre FIFO. C'est une approche locale, à laquelle on peut brancher un serveur si besoin est (mais ne faire qu'un serveur me semble être trop lourd lors de la coloration en local).

EDIT: Une possibilité serait d'avoir une seule pipe (langage, chaîne, output) et ensuite d'inclure dynamiquement la coloration syntaxique en JS (comme ça on peut la désactiver).

acieroid # - le 03/09/2010 à 21:29:30 - Répondre - Source
Avatar

Quand paste.awesom.eu était fait en Perl, j'utilisais pygments via XML-RPC pour la coloration syntaxique (voir ici), comme décrit dans le message de delroth. Ça marchait assez bien niveau vitesse, mais j'ai eu des problèmes si les codes utilisaient des caractères UTF-8 un peu louches (erreurs au niveau d'expat). Mais ça restait quand même utilisable, et ça ne plantait que rarement. La mise en place est assez simple (le code python est simplissime, et au niveau du client ça dépend évidemment des libs disponibles, mais en perl c'était assez simple aussi).

gasche # - le 04/09/2010 à 12:22:34 - Répondre - Source
Avatar

XML-RPC c'est plus chiant à mettre en place côté Caml qu'un simple pipe (il faut un client toussa).

J'ai hacké un simple script avec un pipe. Ça marche très bien : il met 1.4 secondes pour colorer 400 one-liners. Pour 40 one-liners, il met 0.5 secondes, alors qu'un script shell appelant pygmentize en ligne de commande met 9 secondes.

test.ml

(* 
   ocamlfind ocamlc -package unix,json-wheel -linkpkg -o test test.ml
*)

type message = {
    lang : string;
    code : string;
  }

let to_json msg =
  let module JB = Json_type.Build in
  JB.objekt
    [
     "lang", JB.string msg.lang;
     "code", JB.string msg.code;
    ]

let of_json json =
  let module JB = Json_type.Browse in
  let msg = JB.objekt json in
  {
   lang = JB.string (List.assoc "lang" msg);
   code = JB.string (List.assoc "code" msg);
  }

let list_to_json = Json_type.Build.list to_json
let list_of_json = Json_type.Browse.list of_json

let call_python_handler python_command message_list =
  let module JI = Json_io in

  let inbuf, outbuf = Buffer.create 100, Buffer.create 100 in
  let input, output = Unix.open_process python_command in

  let input_json = list_to_json message_list in
  JI.Fast.print outbuf input_json;

  Buffer.output_buffer output outbuf;
  close_out output;

  while
    try Buffer.add_channel inbuf input 1; true
    with End_of_file -> false
  do
    ()
  done;
  close_in input;

  let output_json = JI.json_of_string (Buffer.contents inbuf) in
  list_of_json output_json

let () =
  let small_test_list = [
    {lang="ocaml"; code="let x = 1 in x"};
    {lang="scheme"; code="(let* ((x 1)) x)"};
    {lang="haskell"; code="x where x = 1"};
    {lang="python"; code="(lambda x: x) 1"};
  ] in

  let multiply_list n =
    Array.fold_left (@) []
      (Array.make n small_test_list) in

  let test_answer =
    call_python_handler "python test.py" (multiply_list 10) in

  List.iter
    (fun {lang=lang; code=code} ->
      Printf.printf "<h2>%s</h2> <div class=\"code\">%s</div>" lang code)
    test_answer

test.py

# JSON input-output
from sys import stdin, stdout
import json

def handle_json(code_transform):
    messages = json.load(stdin)
    for msg in messages:
        msg["code"] = code_transform(msg["lang"], msg["code"])
    json.dump(messages, stdout)

# Pygments source coloring
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter

def code_color(lang, code):
    lexer = get_lexer_by_name(lang)
    formatter = HtmlFormatter(noclasses=True)
    return highlight(code, lexer, formatter)

# main
handle_json(code_color)

test.sh

function pygments {

echo "<h1>ocaml</h1>"
echo "let x = 1 in x" | pygmentize -l ocaml -f html -O noclasses=true

echo "<h1>scheme</h1>"
echo "(let* ((x 1)) x)" | pygmentize -l scheme -f html -O noclasses=true

echo "<h1>python</h1>"
echo "(lambda x: x) 1" | pygmentize -l python -f html -O noclasses=true

echo "<h1>haskell</h1>"
echo "x where x = 1" | pygmentize -l haskell -f html -O noclasses=true

}

for i in `seq 1 10`
do
    pygments
done

Remarque : il y a une source de fragilité dans le script, qui vient de l'utilisation d'une liste pour passer l'ensemble des codes à colorer. Pour s'en servir en pratique, on veut associer à chaque fragment non-coloré son équivalent coloré, ce qui suppose que l'ordre de la liste est préservé.

C'est le cas avec cette implémentation, mais ça pourrait facilement ne plus être vrai et casser le code. Pour un protocole plus solide, il serait sans doute mieux de passer une liste dont les éléments sont

indexés par des entiers, ou carrément de prendre en entrée une table indexée par des chaînes arbitraires. Ainsi on récupèrerait chaque fragment de code coloré en sortie par sa clé (la même que le code entrant correspondant), au lieu de faire confiance à l'ordre dans la liste.

Autre remarque : la méthode de lecture char-par-char de la sortie côté Caml n'est pas terrible. Ça reste le plus simple à coder, et je ne pense que ce soit critique pour les perfs de toute façon. Dans le meilleur des mondes, au lieu d'envoyer une seule liste JSON, on enverrait plusieurs paquets précédés par leur taille, mais en pratique les libs JSON ne sont pas pensées pour lire seulement une partie d'un fichier, donc j'ai gardé une approche batch. On pourrait quand même donner la taille, mais c'est plus compliqué pour pas grand chose.

spider-mario # - le 04/09/2010 à 12:34:38 - Répondre - Source
Avatar

Faire un pont C entre OCaml et Python me semble bien overkill

Ça ne semble plus maintenu, mais quelqu’un semble avoir déjà tenté ;)

gasche # - le 04/09/2010 à 13:43:58 - Répondre - Source
Avatar

En fait si, c'est plutôt maintenu, mais par d'autres gens.

Le truc c'est que ce genre de technos c'est toujours galère, et c'est moins flexible qu'un design à plusieurs processus communicants, puisque c'est spécifique à la paire de langages utilisés. Avec la méthode multi-process (que ce soit un pipe, des sockets, RPC etc.) tu peux facilement changer de moteur de coloration syntaxique vers un autre langage sans avoir à toucher au côté Caml, ou inversement réutiliser le programme Python dans une autre implémentation de markup engine.


Pour détailler un peu un point délicat du choix d'impl. : cette solution avec pipe est ce que asmanur appelle "locale", on l'utilise quand on a sous la main une liste de codes à colorer, et elle s'utilise en lançant un processus python qui gère ces codes.

L'avantage, c'est que ça permet de continuer à faire agir mldown comme une boîte noire : on appelle mldown sur chaque fichier .text, et lui de façon invisible pour l'utilisateur utilise ce sous-process python pour colorer toutes les sources d'un .text.

L'inconvénient, c'est que le sous-process python est lancé pour chaque .text parsé par mdown. Pour un programme comme TLC qui fait plein d'appels successifs à mldown avec plein de documents différents, le coût de lancement de pygments reste dupliqué pour chaque document. C'est mieux que quand on le payait pour chaque snippet de code, mais c'est moins bien que ce qu'on aurait avec une solution non-locale où on passe à chaque appel mdown une instance globale d'un process python (une sorte de "pygments daemon" quoi).

L'intérêt des FIFO c'est qu'elles permettent de faire les deux à la fois, du local et du global. Par contre, il me semble ça utilise les "named FIFOs" qui existent sous Unix, mais pas sous Windows. Une solution basée sur des sockets serait plus portable. Avec l'implémentation ci-dessus, on utilise un pipe, qui à priori n'est une solution que locale, mais qui pourrait peut-être être utilisée globalement (par exemple le process appelé pourrait contacter un serveur/daemon global en lui faisant passer ses descripteurs d'entrée/sortie).

Dans tous les cas, la partie du code "interopérabilité des données inter-langages" utilisant JSON est réutilisable.

Répondre à "Les performances de TLC/mldown"

Vous devez être connecté pour poster.