Diagramme Go channels

🇬🇧 English version avalaible here

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 error3.87s +7.82%4.4s +23.6%3.56s
Return no error4.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.

Lecture de code avec closure

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.

Lecture de code avec goto

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

Les fichiers source: