Coût des closures en GO : closure, lambda ou goto

Je travaille actuellement sur un projet écris en Go ou les closure sont massivement utilisées. Sachant que l’appel d’un fonction n’est pas une chose neutre, je m’interroge ici sur l’impact de ces closures sur les performances du code. Je propose donc de comprendre comment fonctionne tout ceci et de faire un comparatif avec une méthode de gestion d’erreur plus classique à base de « goto ».

Voila donc donc trois approches pour gérer une séquence d’opération ou l’on souhaite s’arrêter dès la première erreur:

  • Closure : La closure utilise complètement le système de capture de contexte
    func test_closure(str string) {
    	var err error
    
    	func() {
    		if len(str) > 3 {
    			err = globalErr
    			return
    		}
    		if len(str) > 4 {
    			err = globalErr
    			return
    		}
    	} ()
    
    	if err != nil {
    		globalErrorCount++
    	}
    }
  • Lambda : similaire à une closure mais n’utilise pas du tout le système de capture de contexte.
    func test_lambda(str string) {
    	var err error
    
    	err = func(str_test string) error {
    		if len(str_test) > 3 {
    			return globalErr
    		}
    		if len(str_test) > 4 {
    			return globalErr
    		}
    		return nil
    	} (str)
    
    	if err != nil {
    		globalErrorCount++
    	}
    }
  • Goto : Usage d’un « goto » pour interrompre le flux.
    func test_goto(str string) {
    	var err error
    
    	if len(str) > 3 {
    		err = globalErr
    		goto process_error
    	}
    	if len(str) > 4 {
    		err = globalErr
    		goto process_error
    	}
    
    process_error:
    
    	if err != nil {
    		globalErrorCount++
    	}
    }

Chaque fonction est testé dans un cas ou l’opération s’effectue sans erreur, et dans un cas ou l’opération s’effectue avec une erreur dès le premier traitement. Le test de chaque type d’opération est mesuré durant 1 000 000 000 d’itérations afin d’avoir une mesure moyenne.

Voila les résultats. Il s'agit de la durée de l'exécution des 1 000 000 000 d'itérations et de l'écart au plus rapide en pourcentage.

ClosureLambdaGoto
Return error
3.87s +7.82%
4.4s +23.6%
3.56s
Return no error
4.37s +23.4%
4.06s +14.7%
3.54s

Les systèmes a closure ou lambda sont significativement plus lents. Voyons pourquoi :

Qu’est ce qu’une closure et comment ça marche ?

Une closure est une fonction qui conserve une référence a son environnement lexical. L’environnement lexical comprends les variables accessibles à l’endroit ou est défini la closure.

Techniquement, le compilateur analyse la closure et détermine les variables qu’elle doit utiliser. Pour que ces variables soient accessibles, elles seront allouées dans le tas plutôt que dans la pile.

Qu’implique un appel de fonction ?

Appeler une fonction n’est pas une chose sans impact. Le programme doit parfois sauvegarder ses registres dans la stack (le compilateur Go optimise cela), positionner les paramètre d’appel dans des registres selon la convention de l’OS et finalement exécuter l’instruction CALL qui va sauvegarder le point de retour de la fonction et sauter vers la première instruction de la fonction. Par ailleurs, un CALL peut impliquer un flush du pipeline d’instructions du CPU.

Quelle est la différence entre le tas et la pile ?

Le tas est une grande zone mémoire accessible de tout le programme. Afin de gérer les variable positionnées dans le tas, on utilise généralement un système d’allocation de mémoire. Dans le cas du Go, on utilise en plus un « garbage collector ». La mémoire dans la pile est plus simple à allouer, sa quantité est connue à l’avance par le compilateur et il suffit de changer la valeur du pointeur de pile.

Ce qui est allouée dans la pile est plus rapide à allouer et n’est pas soumis au « garbage collector ». en revanche, l’espace la pile est limité.

Voila le code assembleur généré pour ces trois méthodes. On peut noter que les méthode lambda et closure se traduisent par deux fonctions alors que la méthode goto n’en présente qu’une seule:

Analyse du code compilé

Voila le code de chaque fonction décompilé.

  • Closure
    TEXT main.test_closure(SB)
      comp.go:119:  CMPQ SP, 0x10(R14)			
      comp.go:119:  JBE 0x10b49ed				
      comp.go:119:  PUSHQ BP				
      comp.go:119:  MOVQ SP, BP				
      comp.go:119:  SUBQ $0x28, SP				
      comp.go:119:  MOVQ AX, 0x38(SP)			
      comp.go:119:  MOVQ BX, 0x40(SP)			
      comp.go:120:  MOVUPS X15, 0x18(SP)			
      comp.go:131:  MOVQ 0x38(SP), AX			
      comp.go:131:  MOVQ 0x40(SP), BX			
      comp.go:131:  LEAQ 0x18(SP), CX			
      comp.go:131:  CALL main.test_closure.func1(SB)	
      comp.go:133:  CMPQ 0x18(SP), $0x0			
      comp.go:133:  JNE 0x10b49dc				
      comp.go:133:  JMP 0x10b49e5				
      comp.go:134:  INCQ main.globalErrorCount(SB)		
      comp.go:134:  JMP 0x10b49e7				
      comp.go:133:  JMP 0x10b49e7				
      comp.go:136:  ADDQ $0x28, SP				
      comp.go:136:  POPQ BP					
      comp.go:136:  RET					
      comp.go:119:  MOVQ AX, 0x8(SP)			
      comp.go:119:  MOVQ BX, 0x10(SP)			
      comp.go:119:  CALL runtime.morestack_noctxt.abi0(SB)	
      comp.go:119:  MOVQ 0x8(SP), AX			
      comp.go:119:  MOVQ 0x10(SP), BX			
      comp.go:119:  JMP main.test_closure(SB)		
    
    TEXT main.test_closure.func1(SB)
      comp.go:122:  CMPQ SP, 0x10(R14)			
      comp.go:122:  JBE 0x10b4ad3				
      comp.go:122:  PUSHQ BP				
      comp.go:122:  MOVQ SP, BP				
      comp.go:122:  SUBQ $0x8, SP				
      comp.go:122:  MOVQ AX, 0x18(SP)			
      comp.go:122:  MOVQ BX, 0x20(SP)			
      comp.go:122:  MOVQ CX, 0x28(SP)			
      comp.go:123:  MOVQ BX, 0(SP)				
      comp.go:123:  CMPQ BX, $0x3				
      comp.go:123:  JG 0x10b4a4d				
      comp.go:123:  JMP 0x10b4a87				
      comp.go:124:  MOVQ main.globalErr+8(SB), AX		
      comp.go:124:  MOVQ main.globalErr(SB), DX		
      comp.go:124:  MOVQ DX, 0(CX)				
      comp.go:124:  CMPL runtime.writeBarrier(SB), $0x0	
      comp.go:124:  JE 0x10b4a69				
      comp.go:124:  JMP 0x10b4a6b				
      comp.go:124:  JMP 0x10b4a7d				
      comp.go:124:  CALL runtime.gcWriteBarrier2(SB)	
      comp.go:124:  MOVQ AX, 0(R11)				
      comp.go:124:  MOVQ 0x8(CX), DX			
      comp.go:124:  MOVQ DX, 0x8(R11)			
      comp.go:124:  JMP 0x10b4a7d				
      comp.go:124:  MOVQ AX, 0x8(CX)			
      comp.go:125:  ADDQ $0x8, SP				
      comp.go:125:  POPQ BP					
      comp.go:125:  RET					
      comp.go:127:  MOVQ BX, 0(SP)				
      comp.go:127:  CMPQ BX, $0x4				
      comp.go:127:  JG 0x10b4a93				
      comp.go:127:  JMP 0x10b4acd				
      comp.go:128:  MOVQ main.globalErr+8(SB), AX		
      comp.go:128:  MOVQ main.globalErr(SB), DX		
      comp.go:128:  MOVQ DX, 0(CX)				
      comp.go:128:  CMPL runtime.writeBarrier(SB), $0x0	
      comp.go:128:  JE 0x10b4aaf				
      comp.go:128:  JMP 0x10b4ab1				
      comp.go:128:  JMP 0x10b4ac3				
      comp.go:128:  CALL runtime.gcWriteBarrier2(SB)	
      comp.go:128:  MOVQ AX, 0(R11)				
      comp.go:128:  MOVQ 0x8(CX), DX			
      comp.go:128:  MOVQ DX, 0x8(R11)			
      comp.go:128:  JMP 0x10b4ac3				
      comp.go:128:  MOVQ AX, 0x8(CX)			
      comp.go:129:  ADDQ $0x8, SP				
      comp.go:129:  POPQ BP					
      comp.go:129:  RET					
      comp.go:131:  ADDQ $0x8, SP				
      comp.go:131:  POPQ BP					
      comp.go:131:  RET					
      comp.go:122:  MOVQ AX, 0x8(SP)			
      comp.go:122:  MOVQ BX, 0x10(SP)			
      comp.go:122:  MOVQ CX, 0x18(SP)			
      comp.go:122:  CALL runtime.morestack_noctxt.abi0(SB)	
      comp.go:122:  MOVQ 0x8(SP), AX			
      comp.go:122:  MOVQ 0x10(SP), BX			
      comp.go:122:  MOVQ 0x18(SP), CX			
      comp.go:122:  JMP main.test_closure.func1(SB)
  • Lambda
    TEXT main.test_lambda(SB)
      comp.go:138:  CMPQ SP, 0x10(R14)			
      comp.go:138:  JBE 0x10b4b4f				
      comp.go:138:  PUSHQ BP				
      comp.go:138:  MOVQ SP, BP				
      comp.go:138:  SUBQ $0x20, SP				
      comp.go:138:  MOVQ AX, 0x30(SP)			
      comp.go:138:  MOVQ BX, 0x38(SP)			
      comp.go:139:  MOVUPS X15, 0x10(SP)			
      comp.go:149:  MOVQ 0x30(SP), AX			
      comp.go:149:  MOVQ 0x38(SP), BX			
      comp.go:149:  CALL main.test_lambda.func1(SB)		
      comp.go:141:  MOVQ AX, 0x10(SP)			
      comp.go:141:  MOVQ BX, 0x18(SP)			
      comp.go:151:  TESTQ AX, AX				
      comp.go:151:  JNE 0x10b4b3e				
      comp.go:151:  JMP 0x10b4b47				
      comp.go:152:  INCQ main.globalErrorCount(SB)		
      comp.go:152:  JMP 0x10b4b49				
      comp.go:151:  JMP 0x10b4b49				
      comp.go:154:  ADDQ $0x20, SP				
      comp.go:154:  POPQ BP					
      comp.go:154:  RET					
      comp.go:138:  MOVQ AX, 0x8(SP)			
      comp.go:138:  MOVQ BX, 0x10(SP)			
      comp.go:138:  CALL runtime.morestack_noctxt.abi0(SB)	
      comp.go:138:  MOVQ 0x8(SP), AX			
      comp.go:138:  MOVQ 0x10(SP), BX			
      comp.go:138:  JMP main.test_lambda(SB)		
    
    TEXT main.test_lambda.func1(SB)
      comp.go:141:  PUSHQ BP			
      comp.go:141:  MOVQ SP, BP			
      comp.go:141:  SUBQ $0x18, SP			
      comp.go:141:  MOVQ AX, 0x28(SP)		
      comp.go:141:  MOVQ BX, 0x30(SP)		
      comp.go:141:  MOVUPS X15, 0x8(SP)		
      comp.go:142:  MOVQ 0x30(SP), CX		
      comp.go:142:  MOVQ CX, 0(SP)			
      comp.go:142:  CMPQ CX, $0x3			
      comp.go:142:  JG 0x10b4c49			
      comp.go:142:  JMP 0x10b4c67			
      comp.go:143:  MOVQ main.globalErr(SB), AX	
      comp.go:143:  MOVQ main.globalErr+8(SB), BX	
      comp.go:143:  MOVQ AX, 0x8(SP)		
      comp.go:143:  MOVQ BX, 0x10(SP)		
      comp.go:143:  ADDQ $0x18, SP			
      comp.go:143:  POPQ BP				
      comp.go:143:  RET				
      comp.go:145:  MOVQ 0x30(SP), CX		
      comp.go:145:  MOVQ CX, 0(SP)			
      comp.go:145:  CMPQ CX, $0x4			
      comp.go:145:  JG 0x10b4c78			
      comp.go:145:  JMP 0x10b4c96			
      comp.go:146:  MOVQ main.globalErr(SB), AX	
      comp.go:146:  MOVQ main.globalErr+8(SB), BX	
      comp.go:146:  MOVQ AX, 0x8(SP)		
      comp.go:146:  MOVQ BX, 0x10(SP)		
      comp.go:146:  ADDQ $0x18, SP			
      comp.go:146:  POPQ BP				
      comp.go:146:  RET				
      comp.go:148:  MOVUPS X15, 0x8(SP)		
      comp.go:148:  XORL AX, AX			
      comp.go:148:  XORL BX, BX			
      comp.go:148:  ADDQ $0x18, SP			
      comp.go:148:  POPQ BP				
      comp.go:148:  RET
  • Goto
    TEXT main.test_goto(SB)
      comp.go:156:  PUSHQ BP			
      comp.go:156:  MOVQ SP, BP			
      comp.go:156:  SUBQ $0x18, SP			
      comp.go:156:  MOVQ AX, 0x28(SP)		
      comp.go:156:  MOVQ BX, 0x30(SP)		
      comp.go:157:  MOVUPS X15, 0x8(SP)		
      comp.go:159:  MOVQ 0x30(SP), AX		
      comp.go:159:  MOVQ AX, 0(SP)			
      comp.go:159:  CMPQ AX, $0x3			
      comp.go:159:  JG 0x10b4ba9			
      comp.go:159:  JMP 0x10b4bc3			
      comp.go:160:  MOVQ main.globalErr(SB), AX	
      comp.go:160:  MOVQ main.globalErr+8(SB), CX	
      comp.go:160:  MOVQ AX, 0x8(SP)		
      comp.go:160:  MOVQ CX, 0x10(SP)		
      comp.go:161:  JMP 0x10b4bf0			
      comp.go:163:  MOVQ 0x30(SP), AX		
      comp.go:163:  MOVQ AX, 0(SP)			
      comp.go:163:  CMPQ AX, $0x4			
      comp.go:163:  JG 0x10b4bd4			
      comp.go:163:  JMP 0x10b4bee			
      comp.go:164:  MOVQ main.globalErr(SB), AX	
      comp.go:164:  MOVQ main.globalErr+8(SB), CX	
      comp.go:164:  MOVQ AX, 0x8(SP)		
      comp.go:164:  MOVQ CX, 0x10(SP)		
      comp.go:165:  JMP 0x10b4bf0			
      comp.go:170:  JMP 0x10b4bf0			
      comp.go:170:  CMPQ 0x8(SP), $0x0		
      comp.go:170:  JNE 0x10b4bfa			
      comp.go:170:  JMP 0x10b4c03			
      comp.go:171:  INCQ main.globalErrorCount(SB)	
      comp.go:171:  JMP 0x10b4c05			
      comp.go:170:  JMP 0x10b4c05			
      comp.go:173:  ADDQ $0x18, SP			
      comp.go:173:  POPQ BP				
      comp.go:173:  RET

Voila ce que l'on peut noter sur le code compilé:

Pour la closure et la lambda

  • On peut observer l'utilisation de deux fonctions distinctes (main.test_closure et main.test_closure.func1), ce qui confirme la création d'une fonction anonyme.
  • On voit l'usage des instructions CALL et RET qui sont coûteuse en temps d'exécution.
  • Le code assembleur généré contient plus d'instruction, et sera donc probablement plus long a exécuter (ceci n'est pas toujours vrai).

Pour le Goto

  • Dans le cas du goto, on ne voit qu'une seule fonction (main.test_goto), ce qui montre une structure plus simple.
  • Les intructions JMP dans la version Goto, sont l'implémentation directe du goto.

La polémique du goto

Voilà un vieux débat qui n’a pas lieu d’être. Ce débat vit toujours car beaucoup de personnes répètent ad nauseam la diatribe « Il ne faut jamais utiliser « goto » car on peut toujours le remplacer par une structure à base de boucles ». Hélas ces braves gens ne savent pas pourquoi ils répètent ça, et leur assurance crée des adeptes qui prêchent à leur tour la bonne parole.

Ce vieux débat provient de Dijkstra qui considère que « goto » à tendance à rendre le code illisible, et qu’il peut toujours être remplacé par autre chose, ce qu’il défend dans cet article ( https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf ). C’est un point de vue intéressant. Seulement, Dijkstra fait de l’algorithmique et pas du développement, dans l’algorithmique, on s’affranchit de la gestion des cas d’erreur, de la libération de ressources, de perte de connexion, et d’autre concepts qui sont facilement traités par un « goto ». Quand on développe, on ne peut pas s’affranchir de ces cas-là, aussi le « goto » est une manière particulièrement élégante de les traiter.

Par ailleurs, si le prêcheur fait un appel à l’autorité en citant « Dijkstra », il sera facile de trouver d’autre figure d’autorité qui soutiennent le contraire comme par exemple « Torvalds » ( https://lkml.org/lkml/2003/1/12/128 ).

Oui, l’abus de « goto » rends le code difficile à lire. Faut-il pour autant se radicaliser et proscrire « goto » de tout code ? L’important est que l’usage ou non de cette pratique corresponde à vos enjeux. Personnellement, je n’ai jamais rencontré un enjeu qui nécessite de proscrire l’usage raisonnable du « goto ».

Optimisation du compilateur

Tout le code de ces tests sont compilés en désactivant les optimisations. Si on les active (elles sont activées par défaut) le compilateur détecte que ces 3 fonctions font la même chose et il les réduit à leur plus petite expression, notamment en supprimant les closures et fonctions lambda. Il produit donc 3 fois le même code.

TEXT main.test_closure(SB)
  comp.go:119:  MOVQ AX, 0x8(SP)		
  comp.go:119:  NOPL				
  comp.go:123:  CMPQ BX, $0x3			
  comp.go:123:  JLE 0x108b395			
  comp.go:124:  MOVQ main.globalErr(SB), AX	
  comp.go:131:  JMP 0x108b397			
  comp.go:131:  XORL AX, AX			
  comp.go:133:  TESTQ AX, AX			
  comp.go:133:  JE 0x108b3a3			
  comp.go:134:  INCQ main.globalErrorCount(SB)	
  comp.go:136:  RET				

TEXT main.test_lambda(SB)
  comp.go:138:  MOVQ AX, 0x8(SP)		
  comp.go:138:  NOPL				
  comp.go:142:  CMPQ BX, $0x3			
  comp.go:142:  JLE 0x108b3d5			
  comp.go:149:  MOVQ main.globalErr(SB), AX	
  comp.go:149:  JMP 0x108b3d7			
  comp.go:149:  XORL AX, AX			
  comp.go:151:  TESTQ AX, AX			
  comp.go:151:  JE 0x108b3e3			
  comp.go:152:  INCQ main.globalErrorCount(SB)	
  comp.go:154:  RET				

TEXT main.test_goto(SB)
  comp.go:156:  MOVQ AX, 0x8(SP)		
  comp.go:159:  CMPQ BX, $0x3			
  comp.go:159:  JLE 0x108b414			
  comp.go:160:  MOVQ main.globalErr(SB), AX	
  comp.go:161:  JMP 0x108b416			
  comp.go:161:  XORL AX, AX			
  comp.go:170:  TESTQ AX, AX			
  comp.go:170:  JE 0x108b422			
  comp.go:171:  INCQ main.globalErrorCount(SB)	
  comp.go:173:  RET

Que retenir de tout ceci

Bien que le compilateur soit très efficace pour détecter des situations à optimiser, n’est-il pas préférable d’écrire directement du code économe, plutôt que d’écrire du code expansif pour lequel on espèrera un optimisation par le compilateur ?

Pour le reste, ce test mesure principalement le temps d’appel d’une fonction. Dans le cas ou le code contenu dans la closure est conséquent, ce temps d’appel deviendra négligeable devant le temps d’exécution. Dans ce cas, je dirais qu’il faut prioriser la lisibilité du code.

Lorsque je lit le code avec une fonction lambda ou une closure, je ne peux pas lire le code linéairement, je suis obligé de sauter à la fin de la fonction (➋ et ➌) afin de savoir si je défini une fonction qui sera appelée plus tard ou immédiatement et dans ce second cas, quel sont les arguments qui lui sont passés.

En revanche, la lecture d’une fonction à base de goto est parfaitement linéaire et donc plus compréhensible.

Les fichiers source: