SOCIÉTÉ

BLOGS

Rien ne sert de partir, il faut courir à point

24.11.2011
par Olivier Deschanels

La recherche des optimisations de code est souvent difficile car les pertes de temps ne se situent pas forcément là où nous les attendons. En effet l'optimisation est faite de tout petits riens qui font toute la différence.

Pour ce billet nous allons utiliser une table contenant l'historique des pages visitées d'un site web. Elle est constituée d’un champ nommé ID_Page représentant le numéro d'identifiant unique de chaque page de notre site. La table abrite d'autres champs qui ne nous intéressent pas aujourd'hui.

L'exercice consiste à produire un classement des pages les plus visitées et d'en retenir les 10 meilleures. Ce classement sera proposé sur la page d'accueil de notre site, nous n'avons donc pas le droit de perdre du temps sur ce calcul !

 

 

Comment réaliser le calcul ?

Nous ne disposons que du champ contenant les identifiants des pages. Nous devons donc nous en servir pour en extraire les informations. La première idée est de se servir de la commande VALEURS DISTINCTES afin d'obtenir un tableau des identifiants uniques des pages visitées. Nous pouvons à présent construire un second tableau de même taille qui totalisera le nombre d'occurrences de chaque identifiant dans la table. Il ne nous restera alors qu'à trier les deux tableaux de manière synchronisée et décroissante (par rapport au nombre d'occurrences) avant de n'en retenir que les dix premières valeurs.

Tout le problème est donc d'obtenir le nombre d'occurrences. Il serait illusoire de vouloir boucler sur les enregistrements (cf. mes précédents billets) pour compter les occurrences. Evidemment, cela serait trop long pour l'objectif ciblé. Nous allons utiliser la commande SELECTION VERS TABLEAU pour pouvoir ensuite travailler uniquement en mémoire et donc sans nouvel accès au disque ou au réseau.
Les occurrences étant présentes dans un tableau, la fonction Compter dans tableau paraît être la bonne idée. Nous devons donc boucler sur le tableau des identifiants uniques pour compter à chaque pas les occurrences présentes et ainsi remplir le second tableau. Voici le code réalisant ce travail :
 

$time:=Nombre de millisecondes

CHERCHER([Table_1];[Table_1]Champ_1#500) //voir la note plus bas       
VALEURS DISTINCTES([Table_1]Champ_1;$_ids)
SELECTION VERS TABLEAU([Table_1]Champ_1;$_id)
TABLEAU ENTIER LONG($_ids_num;Taille tableau($_ids))

Boucle ($i;1;Taille tableau($_ids);1)
   $_ids_num{$i}:=Compter dans tableau($_id;$_ids{$i})
Fin de boucle

TRIER TABLEAU($_ids_num;$_ids;<)
$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))


Notez que nous faisons ici une recherche initiale et non un TOUT SELECTIONNER afin de ne pas bénéficier de l'optimisation spéciale de la commande VALEURS DISTINCTES dans le cas de la globalité de la table. Ainsi nous nous plaçons dans le cas défavorable, ce qui est toujours important lorsqu'on parle d'optimisation.

 

Ce code, simple, est facilement compréhensible. Il est temps de le tester. Pour cela il nous faut un jeu de données. Supposons que notre site web compte actuellement 5.000 pages différentes pour un million de visites. Si vous ne disposez pas d'une telle table vous pouvez utiliser le code suivant pour en remplir une à titre d'essai :

 

Boucle ($i;1;1000000;1)
CREER ENREGISTREMENT([Table_1])
   [Table_1]Champ_1:=Hasard%5000
STOCKER ENREGISTREMENT([Table_1])
Fin de boucle


Le code de calcul proposé plus haut prend sur ma machine de l'ordre de 7 secondes pour s'exécuter (en mode compilé cela va de soi si l'on cherche la performance !). Clairement ce n'est pas compatible avec notre objectif. Avant d'imaginer une autre solution, étudions les causes de ce temps de calcul important.
Les premiers appels aux commandes permettant de remplir les tableaux sont peu gourmands en temps. Nous pouvons aisément le vérifier en utilisant le log de débogage que nous activons de la manière suivante :

 

$time:=Nombre de millisecondes

FIXER PARAMETRE BASE(Enreg événements debogage;1)

CHERCHER([Table_1];[Table_1]Champ_1#500)
VALEURS DISTINCTES([Table_1]Champ_1;$_ids)
SELECTION VERS TABLEAU([Table_1]Champ_1;$_id)
TABLEAU ENTIER LONG($_ids_num;Taille tableau($_ids))

Boucle ($i;1;Taille tableau($_ids);1)
   $_ids_num{$i}:=Compter dans tableau($_id;$_ids{$i})
Fin de boucle

TRIER TABLEAU($_ids_num;$_ids;<)

FIXER PARAMETRE BASE(Enreg événements debogage;0)

$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

 

Dans le fichier obtenu, nous pouvons vérifier que le temps de calcul est de l'ordre de quelques millisecondes pour le code situé avant la boucle. Prenez soin de faire exécuter le code une première fois avant d'enregistrer le log afin d'éliminer les phénomènes de cache dans votre étude.

1 p=3 puid=22 Log level: 1
1 p=3 puid=22     (0) cmd: CHERCHER (Test 1).
1 p=3 puid=22     (0) cmd: VALEURS DISTINCTES (Test 1).
54 p=3 puid=22     (0) cmd: SELECTION VERS TABLEAU (Test 1).
173 p=3 puid=22     (0) cmd: Taille tableau (Test 1).
173 p=3 puid=22     (0) cmd: TABLEAU ENTIER LONG (Test 1).
173 p=3 puid=22     (0) cmd: Taille tableau (Test 1).
173 p=3 puid=22     (0) cmd: Compter dans tableau (Test 1).
174 p=3 puid=22     (0) cmd: Compter dans tableau (Test 1).
176 p=3 puid=22     (0) cmd: Compter dans tableau (Test 1).
177 p=3 puid=22     (0) cmd: Compter dans tableau (Test 1).
178 p=3 puid=22     (0) cmd: Compter dans tableau (Test 1).
180 p=3 puid=22     (0) cmd: Compter dans tableau (Test 1).
181 p=3 puid=22     (0) cmd: Compter dans tableau (Test 1).
183 p=3 puid=22     (0) cmd: Compter dans tableau (Test 1).
...

 

Si l'on continue l'étude du fichier de log, nous voyons clairement que la perte de temps est liée à l'exécution de la boucle et plus particulièrement à l'appel répétitif de la fonction Compter dans tableau.
Effectivement avec le code proposé, nous appelons la fonction Compter dans tableau autant de fois qu'il y a de valeurs d'identifiant. Or pour réaliser sa mission, cette fonction doit parcourir à chaque appel le tableau des occurrences, c'est à dire un tableau d'un million de lignes. Ce n'est pas un calcul anodin et surtout il est répété près de cinq mille fois. Nous pouvons lire dans le fichier de logs que la commande Compter dans tableau prend entre 1 et 2 millisecondes, et cela cinq mille fois. Soit entre 5 et 10 secondes.
Nous tenons là le coupable !

Revenir aux fondamentaux pour aller plus vite

Une fois n'est pas coutume, pour la bonne cause, je vais vous inviter à complexifier votre code ! En effet, je prêche régulièrement pour vous rappeler que le code simple est souvent le plus rapide ; mais souvent ne veut pas dire toujours ! Aujourd'hui nous sommes en face d'un cas où le code simple n'est pas suffisant.

Nous venons de voir que le problème de vitesse est lié au parcours répété du tableau des occurrences. Nous allons donc chercher à réduire cette répétition. Pour cela nous allons inverser notre approche. Au lieu de chercher les occurrences d'une valeur en bouclant sur le tableau des identifiants, nous allons compter les occurrences en bouclant sur le tableau qui les contient et alimenter le tableau au fur et à mesure.
Le code révisé se trouve dans la méthode suivante :

 

$time:=Nombre de millisecondes

CHERCHER([Table_1];[Table_1]Champ_1#500)
VALEURS DISTINCTES([Table_1]Champ_1;$_ids)
SELECTION VERS TABLEAU([Table_1]Champ_1;$_id)
TABLEAU ENTIER LONG($_ids_num;Taille tableau($_ids))

Boucle ($i;2;Taille tableau($_id);1)
   $trouve:=Chercher dans tableau($_ids;$_id{$i})
   Si ($trouve>0)
      $_ids_num{$trouve}:=$_ids_num{$trouve}+1
   Fin de si
Fin de boucle

TRIER TABLEAU($_ids_num;$_ids;<)
$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

 

Le temps nécessaire pour compléter l'exécution du code est de 3,5 secondes, soit un gain de 50%.

Cependant il est possible de faire bien mieux. En effet avec la méthode précédente nous avons gagné du temps mais le nombre d'affectations successives reste encore très important. Il y en a autant que de lignes dans le tableau des occurrences; soit un million.
De plus, l'appel de la commande Chercher dans tableau est effectué un million de fois, ce n'est pas forcément l'optimum.

Essayons de limiter le nombre d'affectations et d'appels aux commandes. Pour cela, nous allons commencer par trier le tableau des occurrences. Bien que le tableau ait une dimension de un million de lignes, le tri est très rapide, la commande étant exécutée directement par 4D avec du code proche du système. Mais surtout elle n'est appelée qu'une seule fois !
Reste ensuite à parcourir le tableau pour y lire et compter les valeurs les unes après les autres. Le code se présente alors ainsi  :

 

$time:=Nombre de millisecondes

CHERCHER([Table_1];[Table_1]Champ_1#500)
VALEURS DISTINCTES([Table_1]Champ_1;$_ids)
SELECTION VERS TABLEAU([Table_1]Champ_1;$_id)
TABLEAU ENTIER LONG($_ids_num;Taille tableau($_ids))

TRIER TABLEAU($_id)
$current:=$_id{1}
$compteur:=1
$j:=1
Boucle ($i;2;Taille tableau($_id);1)
   Si ($current#$_id{$i})
      $_ids_num{$j}:=$compteur
      $compteur:=1
      $j:=$j+1
      $current:=$_id{$i}
   Sinon
      $compteur:=$compteur+1
   Fin de si
Fin de boucle
$_ids_num{$j}:=$compteur

TRIER TABLEAU($_ids_num;$_ids;<)
$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

 

Par rapport au premier code proposé, celui-ci est d'une lecture moins directe et nécessite un peu plus d'attention pour le comprendre. Cependant il ne prend que 800 millisecondes pour mener à bien sa tâche. Le gain est à présent de 89% ; cela demande quelques éclaircissements ...

Explication des gains

Dans le dernier code proposé, nous avons réduit au strict nécessaire le code effectué au sein de la boucle, mais surtout nous n'y avons appelé aucune fonction 4D nécessitant un retour dans le moteur de 4D. En procédant ainsi, le code compilé que nous obtenons sera le plus proche possible du processeur sans retour dans le moteur de la base de données. En soi, un retour dans le moteur de la base de données ne prend pas beaucoup de temps et n'est pas facilement mesurable, mais cela n'est plus le cas si les retours sont au nombre d'un million. Pour vous en persuader comparez les temps des deux boucles suivantes :

 

C_ENTIER LONG($i)
$time:=Nombre de millisecondes

Boucle ($i;1;1000000;1)
   $a:=$i%50
Fin de boucle

$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

et

 

C_ENTIER LONG($i)
$time:=Nombre de millisecondes

Boucle ($i;1;1000000;1)
   $a:=Modulo($i;50)
Fin de boucle

$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

 

Sur ma machine de test et en mode compilé, nous sommes près de 1000 fois plus rapide avec l'utilisation de l'opérateur % et pourtant les résultats sont identiques.

 

Que se passe-t-il si l'on utilise une méthode générique ?

Révisons donc notre code pour l'écrire de la manière suivante :

 

$time:=Nombre de millisecondes

CHERCHER([Table_1];[Table_1]Champ_1#500)
VALEURS DISTINCTES([Table_1]Champ_1;$_ids)
SELECTION VERS TABLEAU([Table_1]Champ_1;$_id)
TABLEAU ENTIER LONG($_ids_num;Taille tableau($_ids))

TRIER TABLEAU($_id)
$current:=$_id{1}
$compteur:=1
$j:=1
Boucle ($i;2;Taille tableau($_id);1)
   Si ($current#$_id{$i})
      $_ids_num{$j}:=$compteur
      $compteur:=1
      $j:=Incrementer_compteur ($j)
      $current:=$_id{$i}
   Sinon
      $compteur:=Incrementer_compteur ($compteur)
   Fin de si
Fin de boucle
$_ids_num{$j}:=$compteur
TRIER TABLEAU($_ids_num;$_ids;<)
$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

 

Avec la méthode Incrementer_compteur :

 

C_ENTIER LONG($1)
C_ENTIER LONG($0)

$compteur:=$1
$compteur:=$compteur+1
$0:=$compteur

 

Le temps d'exécution est de 900 millisecondes soit une augmentation de 12 % par rapport au meilleur code.
Les différences ne sont pas énormes, mais n'oublions pas que nous venons de faire un million d'appels à la sous routine, et dans le cas qui nous préoccupe aujourd'hui, la différence est suffisamment grande pour ne pas s'autoriser le luxe de cette méthode générique.

 

Regardons de la même manière le code utilisant une méthode générique acceptant des pointeurs en paramètres :

 

$time:=Nombre de millisecondes

CHERCHER([Table_1];[Table_1]Champ_1#500)
VALEURS DISTINCTES([Table_1]Champ_1;$_ids)
SELECTION VERS TABLEAU([Table_1]Champ_1;$_id)
TABLEAU ENTIER LONG($_ids_num;Taille tableau($_ids))

TRIER TABLEAU($_id)
$current:=$_id{1}
$compteur:=1
$j:=1
Boucle ($i;2;Taille tableau($_id);1)
   Si ($current#$_id{$i})
      $_ids_num{$j}:=$compteur
      $compteur:=1
      Incrementer_compteur2 (->$j)
      $current:=$_id{$i}
   Sinon
      Incrementer_compteur2 (->$compteur)
   Fin de si
Fin de boucle
$_ids_num{$j}:=$compteur

TRIER TABLEAU
($_ids_num;$_ids;<)
$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

Avec la méthode Incrementer_compteur2 :

 

C_POINTEUR($1)

$pt:=$1
$pt->:=$pt->+1

Cette fois le temps nécessaire est de 1 700 millisecondes, soit plus du double du meilleur temps. Autant dire que la combinaison méthode générique et pointeurs n'est pas à conseiller si vous êtes en recherche de vitesse.


Typer les variables


Le code est exécuté en compilé ; quelle est l'influence du typage des variables de compteur sur le temps d'exécution de notre code. Jusqu'à présent nous avions les réglages suivants dans les préférences de compilation :

 

Les compteurs sont donc typés en réel car ils ne sont pas explicitement déclarés. Déclarons-les en entier long, comme il se doit, surtout pour 1 million d'itérations pour voir ce que cela change :

 

C_ENTIER LONG($i;$j;$compteur;$current)

$time:=Nombre de millisecondes

CHERCHER([Table_1];[Table_1]Champ_1#500)
VALEURS DISTINCTES([Table_1]Champ_1;$_ids)
SELECTION VERS TABLEAU([Table_1]Champ_1;$_id)
TABLEAU ENTIER LONG($_ids_num;Taille tableau($_ids))

TRIER TABLEAU($_id)
$current:=$_id{1}
$compteur:=1
$j:=1
Boucle ($i;2;Taille tableau($_id);1)
   Si ($current#$_id{$i})
      $_ids_num{$j}:=$compteur
      $compteur:=1
      $j:=$j+1
      $current:=$_id{$i}
   Sinon
      $compteur:=$compteur+1
   Fin de si
Fin de boucle
$_ids_num{$j}:=$compteur

TRIER TABLEAU
($_ids_num;$_ids;<)
$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

 

Nous gagnons environ 30 millisecondes, ce qui est faible mais non négligeable !

Pour aller au bout des déclarations, déclarons à présent les tableaux utilisés :

 

C_ENTIER LONG($i;$j;$compteur;$current)

$time:=Nombre de millisecondes

TABLEAU ENTIER LONG($_id;0)
TABLEAU ENTIER LONG($_ids;0)

CHERCHER([Table_1];[Table_1]Champ_1#500)
VALEURS DISTINCTES([Table_1]Champ_1;$_ids)
SELECTION VERS TABLEAU([Table_1]Champ_1;$_id)
TABLEAU ENTIER LONG($_ids_num;Taille tableau($_ids))

TRIER TABLEAU($_id)
$current:=$_id{1}
$compteur:=1
$j:=1
Boucle ($i;2;Taille tableau($_id);1)
   Si ($current#$_id{$i})
      $_ids_num{$j}:=$compteur
      $compteur:=1
      $j:=$j+1
      $current:=$_id{$i}
   Sinon
      $compteur:=$compteur+1
   Fin de si
Fin de boucle
$_ids_num{$j}:=$compteur

TRIER TABLEAU
($_ids_num;$_ids;<)
$time:=Nombre de millisecondes-$time
ALERTE(Chaine($time;"### ### ##0 ms"))

 

Ici le temps d'exécution reste inchangé. En effet les tableaux $_ids et $_id étaient au préalable déclarés en entier long par 4D pour être en accord avec le champ auquel ils étaient associés dans les commandes VALEURS DISTINCTES et SELECTION VERS TABLEAU. Il est donc logique de ne rien gagner ici en déclarant les tableaux, si ce n'est en lisibilité.

 

Règles d'or

A la lumière de ces explications, il est temps de vous livrer mes règles d'or en matière d'optimisation d'un code demandant un grand nombre d'itérations.

La première règle est de mesurer le temps des différentes parties et de le mettre en regard des volumes présents (votre jeu d'essai) et à venir (ce que sera votre base après quelques temps de mise en production). Vous aurez alors les clefs pour décider des parties nécessitant tous vos soins.

La deuxième règle est de ne pas considérer que moins d'itérations sera toujours plus rapide. Dans notre exemple la boucle principale est passée de cinq mille à un million d'itérations avec un gain de performance considérable. Ce qui compte n'est pas la boucle, mais ce qui est « dans » la boucle !

La troisième règle est de faire le plus de chose possible soi-même. Non pas que 4D produise du code lent mais simplement à cause des appels au moteur et à la redondance de calculs que vous imposez en utilisant des commandes de manière trop répétitive. Dans le premier exemple l'utilisation de la commande Compter dans tableau montrait bien les conséquences du non respect de cette règle. Une commande doit s'adapter à toutes les situations et ne peut tirer bénéfice d'éléments déjà calculés ; votre code peut (et doit) le faire !

La quatrième règle est de déclarer les variables de compteur (ou assimilées) en entier long ; le gain ne sera pas énorme, mais cela sera toujours cela de pris et ce sans trop d'efforts !

Enfin la dernière règle est de bannir les méthodes génériques a fortiori dans ce cadre. Il ne peut y avoir de code rapide utilisant des méthodes génériques appelées de manière répétitive. La décision de supprimer des appels aux méthodes génériques pour les remplacer par du code spécifique sera souvent salvatrice. Lorsque je présente cette dernière règle, on m'oppose souvent les arguments de la maintenance du code plus difficile, et dont la relecture est moins aisée. Certes, mais comme le dit l'adage on ne fait pas d'omelette sans casser d'oeufs. J'ajouterais que faire une bonne omelette demande de l'expérience afin d'optimiser sa cuisson et son assaisonnement ! De plus rien ne vous interdit de commenter votre code afin de faciliter sa relecture et d'aider l'éventuelle maintenance.
 


Lorsque vous aurez goûté aux joies de l'optimisation des performances de vos bases, en ne modifiant que quelques lignes, dans des proportions aussi importantes que présentées dans ce billet, vous saurez où porter le plus grand soin dans vos boucles et parfois réviserez de fond en comble vos habitudes de programmation dans 4D.

 

RSS 5 commentaire(s) pour ce billet
Bonjour Olivier, L'exercice est éloquent sur l'attention qu'on doit porter au contenu d'une boucle. En performance pure, j'envisage une variante qui fait gagner du temps chez moi : je n'écarte pas le premier essai qui boucle sur le 'petit' tableau, car après tout réduire le nb d'occurrences a aussi un gros intérêt, en tentant d'optimiser le contenu =>compter dans tableau est chronophage, l'exemple est clair. J'essaye de le remplacer par du code. Ici, je me sert de la troisième règle (faire soi-même plutôt que par 4D), et j'abandonne la deuxième (n'écartons pas trop vite le gain sur le nb d'iterations). ma boucle donnerait cela (sur ma config, le gain est d'environ 40% par rapport à la boucle sur le 'grand' tableau d'Olivier qui prends 800 ms dans le billet : TRIER TABLEAU($_id)// pas besoin de trier $_ids, le valeurs distinctes s'en charge. $compteur:=1 Boucle ($i;1;Taille tableau($_ids)) Si ($i
bon, ben mon bout de code n'est pas bien tokenisé dans la zone texte on dirait. mais le principe est décrit. So long.

@ Xavier :  Il faut bien le trier tableau car il ne faut pas confondre $_id et $_ids ...

Merci Olivier pour ce nouveau billet qui, dans la foulée du précédent "Bouclez-la !", illustre bien les obstacles qu'il faut savoir ou apprendre à enjamber. De plus, il éclaire aussi tes mises en garde contre le "générique" ou le temps mis pour "dépointer", surtout comme ici, quand il faut se hâter. Dans le contexte d'un véritable site web (non un exemple de billet de blog), faudra-t-il envisager de mettre en cache les résultats d'une statistique aussi consommatrice, et la calculer périodiquement à une fréquence qui sera déterminée empiriquement ? Enfin, mais juste par curiosité ou peut-être pour se remérorer l'adjudant de Fernand Raynaud à propos du fût du canon ;-), combien de temps reste-t-il raisonnable de ne pas rendre la main à 4D ? ou pouvons-nous ne pas nous en préoccuper ?
Voici un billet utile, nous venons de passer de 50 000 à 3 millions d'enregistrements, et de 1 heure de génération de pages web statiques à 72h ... Une bonne partie du développement de l'Annuaire Français est a refaire, et la question de passer de la génération statique vers la génération dynamique se pose, et en parallèle, la capacité de 4D face à de très gros volumes de requêtes web...

Poster un nouveau commentaire

  • Les adresses de pages web et de messagerie électronique sont transformées en liens automatiquement.
  • You may use [view:viewname] tags to display listings of nodes.
  • Each email address will be obfuscated in a human readable fashion or (if JavaScript is enabled) replaced with a spamproof clickable link.

Plus d'informations sur les options de formatage