Du CPU au GPU en MQL5 : un framework OpenCL pratique pour accélérer la recherche, l’optimisation et la détection de motifs
Introduction
La transition du CPU au GPU en MQL5 semble souvent une étape évidente : si le GPU peut calculer plus rapidement, la recherche en matière de trading devrait s’accélérer automatiquement. En réalité, tout est beaucoup plus subtil. Le GPU est effectivement capable d'apporter des gains significatifs, mais seulement lorsque la tâche s’adapte bien à un modèle d’exécution parallèle. Autrement, vous risquez de ne pas obtenir d'accélération, mais seulement une architecture plus complexe, pour un coût égal, voire supérieur.
Ceci est particulièrement important pour le trading algorithmique. L'analyse des données de marché, l'itération sur les paramètres, la validation d’hypothèses à grande échelle et la recherche de motifs récurrents nécessitent souvent d'importants volumes de calcul. C'est là que le GPU révèle tout son potentiel. Il est particulièrement performant lorsqu'une même opération doit être effectuée sur un grand nombre d’éléments et que le résultat peut être récupéré une fois le traitement parallèle terminé. Dans ce cas, une carte graphique cesse d'être un simple accessoire et devient une ressource de calcul à part entière.
Mais le GPU a un prix. Avant de commencer les calculs, il faut préparer les données, les transmettre au périphérique, attendre l'exécution du noyau et rapatrier le résultat final vers le CPU. Pour les tâches compactes, cette logique peut s'avérer trop lourde. Dans les domaines où le CPU fonctionne rapidement et sans surcoût inutile, le transfert des calculs vers le GPU n'apporte aucun avantage. Parfois, cela devient même un obstacle, surtout si la tâche change fréquemment, exige une logique flexible ou porte sur de petites quantités de données.
Dans l'environnement MQL5, OpenCL sert de lien entre la logique applicative et le GPU. Cela nous permet de déplacer la partie des calculs intensifs en dehors du programme principal et d'organiser le traitement des données par lots sur le GPU. Mais OpenCL n'est pas en soi un bouton magique pour accélérer le processus. Il n'est utile que lorsque l'architecture de la tâche prend en compte dès le départ les spécificités du calcul parallèle et minimise les échanges de données entre le CPU et le GPU.
Ici, le GPU est considéré comme un niveau distinct du circuit de calcul, destiné aux opérations lourdes et répétitives. Cette approche est utile dans les tâches de recherche, d'optimisation et de détection de modèles où le volume de calculs augmente plus vite que la patience du chercheur. La conclusion pratique est simple : il faut d’abord comprendre ce qui mérite d’être transféré sur le GPU, et ce n’est qu’ensuite que l’accélération aura un réel effet.
Préparation de l'environnement
Travailler avec OpenCL ne commence pas par des calculs, mais par la préparation. Tout d'abord, le programme doit trouver un périphérique disponible, créer un contexte de travail, préparer le noyau (kernel) et allouer de la mémoire pour les données. Tout cela semble n'être qu'une formalité technique, mais c'est à ce stade qu'une part importante des performances est souvent perdue.
L'erreur principale consiste à traiter le GPU comme s'il s'agissait d'un appel de fonction ordinaire : l'appeler, obtenir le résultat et passer à autre chose. En réalité, toute une série d'actions se cache derrière un tel appel. Nous devons créer ou connecter un contexte, préparer un programme, compiler le noyau, allouer de la mémoire, transférer des données et seulement ensuite démarrer le calcul. Si nous répétons cela sans cesse, le GPU passera trop de temps en préparation plutôt qu'en calculs.
Par conséquent, dans une bonne mise en œuvre, presque tout ce qui peut être fait l'est une seule fois. Le contexte est créé à l'avance puis réutilisé. Le noyau est compilé une seule fois si le code ne change pas. Il est également préférable de ne pas recréer les buffers mémoire sauf si cela est nécessaire, mais de les réutiliser. Cette approche réduit les surcoûts et rend l'utilisation du GPU réellement utile.
Il en va de même pour le transfert de données. Le GPU n'est pas bien adapté aux nombreuses petites tâches qui font constamment des allers-retours. Avec cette approche, le temps est consacré non pas aux calculs, mais à l’échange de données. Il est beaucoup plus efficace de collecter les données par lots plus importants et d'effectuer les calculs moins fréquemment, mais avec une charge plus élevée. Un traitement plus massif est presque toujours préférable à une série de petites exécutions.
Il existe un autre problème courant : la synchronisation inutile. Si le programme s'arrête après chaque étape et attend la fin du traitement par le GPU, le périphérique reste inactif. Cela réduit l'effet d'accélération global. Il est préférable de structurer le travail de telle sorte que le GPU reçoive une tâche, l'exécute sans interruptions inutiles et ne renvoie le résultat que lorsque cela est réellement nécessaire.
Ceci est particulièrement important pour MQL5. Les programmes de trading sont sensibles aux retards, et les négligences architecturales deviennent rapidement perceptibles. Si le contexte est constamment recréé, la mémoire est allouée de manière chaotique et le noyau est recompilé à chaque calcul, le GPU devient une source de retards plutôt qu'un accélérateur.
Le principe de base est donc très simple : tout ce qui peut être préparé à l'avance doit l'être. Ce qui peut être réutilisé ne devrait pas être recréé. Lorsque l'environnement est bien organisé, OpenCL contribue réellement à accélérer les calculs intensifs. En l'absence de cette discipline, l'avantage est facilement perdu avant même que les calculs ne commencent.
Gestion de la mémoire
En matière de GPU, beaucoup de gens pensent avant tout à la puissance de calcul. Mais en pratique, ce qui compte souvent, ce n'est pas tant la vitesse des calculs que la manière précise dont les données sont fournies. Même un GPU très rapide ne donnera pas de bons résultats s'il a des difficultés à gérer la mémoire.
En OpenCL, la mémoire est bien plus qu'un simple lieu de stockage des données. Ses performances dépendent directement de son utilisation. Le GPU possède plusieurs niveaux de mémoire. Certains sont plus rapides, d’autres plus lents. Les petites zones de mémoire interne sont rapidement accessibles, mais l'accès à la mémoire principale du périphérique est nettement plus coûteux.
Cela implique une règle empirique importante : les données doivent être organisées de manière à ce que le GPU accède le moins possible à la mémoire lente. Moins il y a de lectures et d'écritures inutiles, mieux c'est. Il est particulièrement important de ne pas transférer inutilement les mêmes données entre le CPU et le GPU à répétition. C’est l’échange de données, plutôt que le calcul lui-même, qui devient souvent le principal goulot d’étranglement.
En clair, le GPU privilégie les tâches où l'on peut envoyer une grande quantité de données une seule fois, effectuer de nombreuses opérations similaires, puis rapatrier le résultat final vers le CPU. Il n'apprécie pas la situation où le programme envoie constamment de petits fragments de données et attend une réponse après chaque étape. Dans ce mode, l'accélération disparaît rapidement.
C'est particulièrement sensible dans les applications de trading. Si à chaque étape un programme prépare d'abord des données, puis les transmet, puis attend le résultat, puis répète tout cela, ses performances seront instables. Il est bien préférable de collecter les données nécessaires à l'avance, de les envoyer au périphérique en un seul bloc, d'effectuer le calcul et d'utiliser le résultat dans le programme principal.
Il est important de bien répartir les rôles ici. Le CPU reste le centre de contrôle : il collecte les données, lance les calculs et prend la décision finale. Le GPU prend en charge la partie du travail où il est nécessaire de répéter rapidement la même opération plusieurs fois. Cette structure est presque toujours plus fiable et efficace que de tenter de tout transférer sur un seul périphérique.
Il existe une autre erreur typique : des lancements de noyau trop petits. À première vue, cela semble pratique : de nouvelles données arrivent et sont immédiatement envoyées au GPU. Mais chaque lancement a aussi un coût en temps. Si le nombre de lancements est trop élevé, les coûts généraux finissent par absorber tous les bénéfices. Par conséquent, dans la plupart des cas, il est préférable de solliciter moins souvent le GPU, mais de lui confier une tâche plus importante et plus simple.
Par conséquent, la gestion de la mémoire n'est pas un détail technique mineur, mais l'un des principaux facteurs de performance. Si les données sont correctement organisées, le GPU fonctionne comme un pipeline. Sinon, même un périphérique puissant restera inactif et perdra du temps en requêtes et transferts inutiles.
Conclusion : le GPU accélère les calculs uniquement lorsque les données lui sont correctement fournies. Moins il y a de transmissions, de requêtes et de petits lancements inutiles, plus l'effet réel est important .
Construction d'un programme
Dans l'application, le CPU et le GPU ne sont pas en concurrence, mais travaillent ensemble. Le CPU reste le centre de contrôle : il génère les données initiales, appelle la méthode requise, récupère le résultat et compare le temps d'exécution. Le GPU ne prend en charge que la partie calcul. Il s'agit d'une structure classique : au lieu de transférer tout le programme vers le GPU, il convient de ne lui fournir que la section où il existe un véritable parallélisme.
Le moyen le plus simple de comprendre comment structurer un programme avec OpenCL est d'utiliser un exemple concret. La bibliothèque standard MQL5 propose une implémentation illustrative de la multiplication matricielle. Dans le programme principal, deux matrices sont d'abord créées et remplies de valeurs aléatoires.
void OnStart() { //--- matrix A 1000x2000 int rows_a = 1000; int cols_a = 2000; //--- matrix B 2000x1000 int rows_b = cols_a; int cols_b = 1000; //--- matrix C 1000x1000 int rows_c = rows_a; int cols_c = cols_b; //--- matrix A: size=rows_a*cols_a int size_a = rows_a * cols_a; int size_b = rows_b * cols_b; int size_c = rows_c * cols_c; //--- prepare matrix A float matrix_a[]; ArrayResize(matrix_a, rows_a * cols_a); for(int i = 0; i < rows_a; i++) for(int j = 0; j < cols_a; j++) { matrix_a[i * cols_a + j] = (float)(10 * MathRand() / 32767); } //--- prepare matrix B float matrix_b[]; ArrayResize(matrix_b, rows_b * cols_b); for(int i = 0; i < rows_b; i++) for(int j = 0; j < cols_b; j++) { matrix_b[i * cols_b + j] = (float)(10 * MathRand() / 32767); }
Tout d'abord, un calcul séquentiel est appelé sur le CPU, puis le même calcul est effectué sur le GPU.
//--- CPU: calculate matrix product matrix_a*matrix_b float matrix_c_cpu[]; ulong time_cpu = 0; if(!MatrixMult_CPU(matrix_a, matrix_b, matrix_c_cpu, rows_a, cols_a, cols_b, time_cpu)) { PrintFormat("Error in calculation on CPU. Error code=%d", GetLastError()); return; } //--- calculate matrix product using GPU float matrix_c_gpu_method1[]; float matrix_c_gpu_method2[]; ulong time_gpu_method1 = 0; ulong time_gpu_method2 = 0; if(!MatrixMult_GPU(matrix_a, matrix_b, matrix_c_gpu_method1, matrix_c_gpu_method2, rows_a, cols_a, cols_b, size_a, size_b, size_c, time_gpu_method1, time_gpu_method2)) { PrintFormat("Error in calculation on GPU. Error code=%d", GetLastError()); return; }
Les calculs eux-mêmes sont effectués selon des méthodes distinctes. La partie GPU est présentée dans deux implémentations du même problème : naïve et optimisée. Cela nous permet de constater immédiatement la différence, non pas en mots, mais au niveau du code et du temps d'exécution.
Examinons la version CPU. Ici, tout est extrêmement clair : un cycle triple classique. Chaque élément de la matrice résultante est calculé séquentiellement.
bool MatrixMult_CPU(const float &matrix_a[], const float &matrix_b[], float &matrix_c[], const int rows_a, const int cols_a, const int cols_b, ulong &time_cpu) { int size = rows_a * cols_b; if(ArrayResize(matrix_c, size) != size) return(false); //--- CPU calculation started time_cpu = GetMicrosecondCount(); for(int i = 0; i < rows_a; i++) { for(int j = 0; j < cols_b; j++) { float sum = 0.0; for(int k = 0; k < cols_a; k++) { sum += matrix_a[cols_a * i + k] * matrix_b[cols_b * k + j]; } matrix_c[cols_b * i + j] = sum; } } //--- CPU calculation finished time_cpu = ulong((GetMicrosecondCount() - time_cpu) / 1000); //--- return(true); }
Ce code illustre l'idée de base de la tâche. Il y a deux matrices. Chaque élément est le résultat d’une somme de produits par ligne et par colonne. Il s'agit d'une exécution séquentielle. Sur le CPU, cela fonctionne de manière transparente et sans aucune préparation inutile. Mais c’est précisément là que réside la principale limitation : dès que la taille de la matrice augmente, les calculs séquentiels deviennent de plus en plus coûteux.
Vient ensuite la partie concernant le GPU. Il est important ici de souligner le principe principal : Dans cet exemple, OpenCL est encapsulé dans une méthode distincte. Le programme principal reste propre et toute la gestion du GPU est concentrée en un seul endroit.
bool MatrixMult_GPU(const float &matrix_a[], const float &matrix_b[], float &matrix1_c[], float &matrix2_c[], const int rows_a, const int cols_a, const int cols_b, const int size_a, const int size_b, const int size_c, ulong &time1_gpu, ulong &time2_gpu) { const int task_dimension = 2; //--- prepare matrices for result if(ArrayResize(matrix1_c, size_c) != size_c || ArrayResize(matrix2_c, size_c) != size_c) return(false); ArrayFill(matrix1_c, 0, size_c, (float)0.0); ArrayFill(matrix2_c, 0, size_c, (float)0.0);
Vous pouvez immédiatement constater un point important : les matrices de résultats pour deux options GPU sont préparées à l’avance. Cela nous évite de mélanger les calculs avec la préparation de la mémoire. À ce niveau, le programme est déjà structuré de manière disciplinée : d’abord l’allocation des tableaux, puis OpenCL.
Ensuite, le contexte OpenCL est créé et initialisé.
//--- OpenCL ulong timei_gpu = GetMicrosecondCount(); COpenCL OpenCL; if(!OpenCL.Initialize(cl_program, true)) { PrintFormat("Error in OpenCL initialization. Error code=%d", GetLastError()); return(false); }
C’est là que le sens pratique d’OpenCL devient évident. Jusqu'à présent, le programme était un code MQL5 conventionnel. À ce stade, il prépare l'environnement de calcul pour le GPU. Et ce qui est particulièrement important, c'est que cette opération n'est pas gratuite. Nous mesurons séparément le temps d'initialisation. Avant de parler d'accélération, il nous faut examiner honnêtement le coût du lancement de l'environnement lui-même.
Deux noyaux sont ensuite créés. Le premier est une variante parallèle simple. Le second est plus avancé, avec des groupes locaux.
//--- create kernels OpenCL.SetKernelsCount(2); OpenCL.KernelCreate(0, "MatrixMult_GPU1"); OpenCL.KernelCreate(1, "MatrixMult_GPU2");
Il convient de noter ici que le temps d'exécution dépend largement de la qualité de l'algorithme utilisé dans le programme OpenCL. On peut simplement paralléliser le calcul, ou bien améliorer la gestion de la mémoire. L'exemple contient les deux options, ce qui rend la démonstration particulièrement parlante.
Ensuite, on prépare les buffers. Les matrices d'entrée sont copiées sur le périphérique, et un buffer mémoire distinct est créé pour le résultat.
//--- create buffers OpenCL.SetBuffersCount(3); //--- if(!OpenCL.BufferFromArray(0, matrix_a, 0, size_a, CL_MEM_READ_ONLY)) { PrintFormat("Error in BufferFromArray for matrix A. Error code=%d", GetLastError()); return(false); } if(!OpenCL.BufferFromArray(1, matrix_b, 0, size_b, CL_MEM_READ_ONLY)) { PrintFormat("Error in BufferFromArray for matrix B. Error code=%d", GetLastError()); return(false); } if(!OpenCL.BufferCreate(2, size_c * sizeof(float), CL_MEM_WRITE_ONLY)) { PrintFormat("Error in BufferCreate for matrix C. Error code=%d", GetLastError()); return(false); }
Il s'agit déjà d'une véritable architecture GPU fonctionnelle. Les données sont transférées vers le périphérique, traitées sur place, puis renvoyées. C'est cet ordre qui rend l'accélération possible. Si on le divise en petits morceaux, le GPU va passer trop de temps à préparer et à échanger des données, plutôt qu'à effectuer le calcul lui-même.
Ensuite, les arguments du premier noyau sont définis.
//--- prepare arguments for kernel 0 int kernel_index = 0; OpenCL.SetArgumentBuffer(kernel_index, 0, 0); OpenCL.SetArgumentBuffer(kernel_index, 1, 1); OpenCL.SetArgumentBuffer(kernel_index, 2, 2); OpenCL.SetArgument(kernel_index, 3, rows_a); OpenCL.SetArgument(kernel_index, 4, cols_a); OpenCL.SetArgument(kernel_index, 5, cols_b); timei_gpu = ulong((GetMicrosecondCount() - timei_gpu) / 1000); PrintFormat("time of initialization GPU =%d ms", timei_gpu);
La logique est ici très simple : le noyau obtient les buffers de données et les tailles de matrice. À l’intérieur du kernel OpenCL, on détermine quel thread calcule quel élément. C'est un point important : Le GPU lui-même ne sait pas comment interpréter les tableaux. Cette structure doit lui être explicitement spécifiée.
Ensuite, la taille du problème est définie et la première option de calcul est lancée.
//--- set task dimension a_rows x b_cols uint global_work_size[2]; //--- set dimensions global_work_size[0] = rows_a; global_work_size[1] = cols_b; uint global_work_offset[2] = {0, 0}; //--- GPU calculation start kernel 0 time1_gpu = GetMicrosecondCount(); if(!OpenCL.Execute(kernel_index, task_dimension, global_work_offset, global_work_size)) { PrintFormat("Error in Execute. Error code=%d", GetLastError()); return(false); } if(!OpenCL.BufferRead(2, matrix1_c, 0, 0, size_c)) { PrintFormat("Error in BufferRead for matrix1 C. Error code=%d", GetLastError()); return(false); } //--- GPU calculation finished time1_gpu = ulong((GetMicrosecondCount() - time1_gpu) / 1000);
Il s'agit de la première option GPU. Cela illustre l'idée de base : un thread calcule un élément de sortie. Cette structure assure déjà le parallélisme, mais n'exploite pas encore toutes les possibilités d'optimisation de la mémoire. C'est pourquoi il y a un deuxième noyau à côté dans l'exemple. Avant son lancement, les arguments sont spécifiés de la même manière, mais la méthode d'exécution est différente : avec un groupe de travail local.
//--- prepare arguments for kernel 1 kernel_index = 1; //--- set arguments OpenCL.SetArgumentBuffer(kernel_index, 0, 0); OpenCL.SetArgumentBuffer(kernel_index, 1, 1); OpenCL.SetArgumentBuffer(kernel_index, 2, 2); OpenCL.SetArgument(kernel_index, 3, rows_a); OpenCL.SetArgument(kernel_index, 4, cols_a); OpenCL.SetArgument(kernel_index, 5, cols_b); uint local_work_size[2]; local_work_size[0] = BLOCK_SIZE; local_work_size[1] = BLOCK_SIZE;
C'est là que la différence pratique apparaît. Le premier noyau se contente de paralléliser le calcul. Le second organise déjà le calcul par blocs. C'est plus important qu'il n'y paraît au premier abord. Le GPU apprécie non seulement le parallélisme, mais aussi une bonne organisation de la mémoire. Par conséquent, les blocs et les groupes locaux apportent des avantages considérables.
On définit ensuite la taille des groupes de travail pour le second lancement du noyau.
//--- GPU calculation start, kernel1 time2_gpu = GetMicrosecondCount(); if(!OpenCL.Execute(kernel_index, task_dimension, global_work_offset, global_work_size, local_work_size)) { PrintFormat("Error in Execute. Error code=%d", GetLastError()); return(false); } if(!OpenCL.BufferRead(2, matrix2_c, 0, 0, size_c)) { PrintFormat("Error in BufferRead for matrix2 C. Error code=%d", GetLastError()); return(false); } //--- GPU calculation finished time2_gpu = ulong((GetMicrosecondCount() - time2_gpu) / 1000); //--- remove OpenCL objects OpenCL.Shutdown(); //--- return(true); }
Le plus intéressant, maintenant, c'est ce qui se passe à l'intérieur du code OpenCL. La première version du noyau semble aussi simple que possible.
__kernel void MatrixMult_GPU1(__global float *matrix_a, __global float *matrix_b, __global float *matrix_c, int rows_a, int cols_a, int cols_b) { int i = get_global_id(0); int j = get_global_id(1); float sum = 0.0; for(int k = 0; k < cols_a; k++) { sum += matrix_a[cols_a * i + k] * matrix_b[cols_b * k + j]; } matrix_c[cols_b * i + j] = sum; }
Il s'agit d'un transfert quasi littéral de la logique mathématique vers le GPU. Chaque thread reçoit ses coordonnées et calcule un élément de la matrice de résultat.
La deuxième version est déjà plus intéressante. Elle utilise des tableaux locaux et la synchronisation des threads.
__kernel void MatrixMult_GPU2(__global float *matrix_a, __global float *matrix_b, __global float *matrix_c, int rows_a, int cols_a, int cols_b) { int group_i = get_group_id(0); int group_j = get_group_id(1); int i = get_local_id(0); int j = get_local_id(1); __local float submatrix_a[BLOCK_SIZE][BLOCK_SIZE]; __local float submatrix_b[BLOCK_SIZE][BLOCK_SIZE]; int offset_b = BLOCK_SIZE * group_i; int offset_a_start = cols_a * BLOCK_SIZE * group_j; float sum = (float)0.0;
Il est déjà clair ici que le calcul est construit différemment. Les threads sont regroupés et les données sont chargées dans la mémoire interne du bloc. Cela réduit le nombre d'appels à la mémoire globale et permet au périphérique de fonctionner plus efficacement.
Ensuite, les fragments sont chargés et les threads sont synchronisés.
for(int offset_a = offset_a_start; offset_a < offset_a_start + cols_a; offset_a += BLOCK_SIZE, offset_b += BLOCK_SIZE * cols_b) { submatrix_a[i][j] = matrix_a[offset_a + cols_a * i + j]; submatrix_b[i][j] = matrix_b[offset_b + cols_b * i + j]; barrier(CLK_LOCAL_MEM_FENCE); for(int k = 0; k < BLOCK_SIZE; k++) sum += submatrix_a[i][k] * submatrix_b[k][j]; barrier(CLK_LOCAL_MEM_FENCE); }
Voici la réponse à la question de savoir pourquoi il existe deux implémentations GPU. Le premier exemple illustre la parallélisation. Le second illustre l'optimisation de la mémoire. Et dans la pratique, c'est précisément ce qui décide souvent du sort de l'accélération.
Le résultat final est écrit dans le tableau de sortie.
int offset_c = BLOCK_SIZE * (cols_b * group_j + group_i);
matrix_c[offset_c + cols_b * i + j] = sum;
};
Ces deux noyaux sont comparés dans le programme principal en fonction du temps d'exécution et la précision des calculs est contrôlée par rapport à une option CPU.
//--- calculate CPU/GPU ratio double CPU_GPU_ratio1 = 0; double CPU_GPU_ratio2 = 0; if(time_gpu_method1 != 0) CPU_GPU_ratio1 = 1.0 * time_cpu / time_gpu_method1; if(time_gpu_method2 != 0) CPU_GPU_ratio2 = 1.0 * time_cpu / time_gpu_method2; PrintFormat("time CPU=%d ms, time GPU global work groups =%d ms, CPU/GPU ratio: %f", time_cpu, time_gpu_method1, CPU_GPU_ratio1); PrintFormat("time CPU=%d ms, time GPU local work groups =%d ms, CPU/GPU ratio: %f", time_cpu, time_gpu_method2, CPU_GPU_ratio2); PrintFormat("time matrix CPU=%d ms", time_mat);
Par souci d'exhaustivité, ajoutons à la comparaison l'utilisation des opérations matricielles intégrées.
//--- matrix matrix<float> A, B, C; if(!A.Assign(matrix_a) || !B.Assign(matrix_b)) { PrintFormat("Error of copy data to matrices. Error code=%d", GetLastError()); return; } if(!A.Reshape(rows_a, cols_a) || !B.Reshape(rows_b, cols_b)) { PrintFormat("Error of copy data to matrices. Error code=%d", GetLastError()); return; } ulong time_mat = GetMicrosecondCount(); C = A.MatMul(B); time_mat = ulong((GetMicrosecondCount() - time_mat) / 1000);
Dans une expérience pratique, le temps de multiplication matricielle sur un système hybride a été comparé : un CPU et deux GPU : Carte graphique pour ordinateur portable NVIDIA GeForce RTX 4060 et carte graphique Intel Iris Xe. L'implémentation CPU naïve a été utilisée comme référence, affichant un temps d'environ 2056 à 2180 ms. Il convient de noter ici les opérations matricielles optimisées, qui ont montré un temps stable d'environ 32 à 34 ms, ce qui, pour un CPU de cette classe, peut être considéré comme proche du niveau de performance attendu pour le traitement vectorisé.

Lors du transfert des calculs vers le GPU, les résultats ont été mitigés, soulignant à quel point l'efficacité d’OpenCL dépend de l'organisation des blocs de calcul. Dans le cas de la RTX 4060, lors de l'utilisation de groupes de travail globaux, le temps d'exécution était d'environ 38 ms, ce qui, formellement, ne présente aucun avantage par rapport aux opérations matricielles du CPU. Cependant, le passage à des groupes de travail locaux a radicalement changé la situation : le temps est tombé à 11 ms. Cela illustre clairement l’efficacité de l'approche par tuiles, dans laquelle la réutilisation des données en mémoire locale réduit la pression sur la mémoire globale et permet une utilisation plus efficace des unités de calcul du GPU.
Une tendance similaire, mais plus marquée, est observée dans le processeur graphique intégré Intel Iris Xe. En mode groupes globaux, le temps d'exécution était de 213 ms, tandis qu'en mode groupes locaux, il tombait à 45 ms. Malgré la persistance d'un retard général par rapport aux GPU discrets, l'accélération relative est encore plus perceptible ici, ce qui souligne la sensibilité des GPU moins productifs à l'optimisation de l'accès à la mémoire.
Le temps d'initialisation du GPU mérite une attention particulière. Pour la RTX 4060, il était d'environ 99 ms, tandis que pour l’Iris Xe, il était d'environ 4 ms. Ce facteur ne peut être ignoré dans les scénarios d'application, car il affecte l'efficacité globale du pipeline de calcul.
Globalement, les résultats illustrent parfaitement la transition d'un mode d'exécution limité par la mémoire à un mode d'exécution efficace en termes de calcul. Le CPU reste compétitif à l'échelle de la tâche donnée, tandis que les GPU ne révèlent leur avantage qu'avec une organisation correcte des unités de calcul locales. Cela est particulièrement visible sur la RTX 4060, où la différence entre une implémentation sous-optimale et une implémentation optimisée atteint environ trois à quatre fois, ce qui définit effectivement la limite entre une utilisation inefficace et une utilisation à part entière de l'accélérateur graphique.
Test des figures de chandeliers japonais
L'exemple de la multiplication matricielle illustre parfaitement comment un GPU peut accélérer les calculs. Mais cela ne suffit pas pour un trader. Il est plus important de comprendre si cette approche peut être utilisée pour résoudre un problème véritablement lié au marché. L'étape suivante consiste donc à passer des mathématiques abstraites à l'analyse des figures de chandeliers japonais.
Les configurations en chandeliers sont généralement décrites comme des schémas préétablis portant des noms bien connus. Ils sont faciles à reconnaître sur une image, et dans les livres, ils paraissent souvent convaincants. Mais si l'on examine la question de plus près, une autre question se pose : dans quelle mesure de tels modèles sont-ils réellement étayés par les statistiques ? Où sont les critères exacts ? Comment savoir si un modèle fonctionne non pas en théorie, mais sur des données réelles ?
Au lieu de rechercher à l'avance des figures familières dans les manuels scolaires, vous pouvez aborder le problème différemment. Il ne s’agit pas d’imposer au marché des schémas tout faits, mais plutôt d’examiner comment ils se sont comportés dans des situations similaires par le passé.
On prend en compte les dernières bougies, c'est-à-dire la situation actuelle du marché. C’est notre configuration de référence. Le programme parcourt ensuite l'historique et recherche des zones de forme similaire. La comparaison ne se base pas sur le nom du modèle, mais sur les caractéristiques réelles de la bougie : le corps, l’ombre supérieure et l’ombre inférieure. De plus, une légère déviation est tolérée, car le marché ne reproduit presque jamais parfaitement le même schéma.
S'il existe une section similaire dans l'historique, le programme ne fait aucune supposition, mais vérifie le résultat. Dans ce cas, une transaction est simulée en définissant des niveaux de Take Profit et de Stop Loss, ainsi qu'une limite de temps. Nous calculons ensuite si de tels cas se sont soldés par un profit ou une perte dans le passé.
C'est là qu’OpenCL devient particulièrement utile. Cette tâche consiste en un grand nombre de vérifications similaires : nous devons parcourir de nombreuses sections de l’historique, les comparer avec le modèle actuel et calculer le résultat de la transaction pour chaque option. Pour un CPU, c'est possible, mais avec une grande quantité de données, les calculs deviennent difficiles. Pour le GPU, au contraire, il s'agit d'une charge naturelle : de nombreux calculs indépendants qui peuvent être effectués en parallèle.
De plus, l'article teste non pas une seule combinaison de paramètres de transaction, mais toute une grille d'options à la fois. Autrement dit, le programme prend simultanément en compte différentes valeurs de Take Profit et de Stop Loss. Cela nous permet d'évaluer quels paramètres semblaient les plus raisonnables dans des conditions de marché similaires.
Commençons par la logique côté OpenCL — c'est là que la tâche prend sa véritable forme. Dans cette conception, le CPU reste le chef d’orchestre, mais tout le travail difficile (recherche, comparaison, modélisation) est effectué par le GPU.
On a ici une véritable chaîne d’analyse.
__kernel void PatternStats3D(__global const float4 *price, __global const float *tp, __global const float *sl, __global float *global_stats, const int bars, const float tolerance, const int horizon) { const int lid = get_local_id(0); const int itp = get_global_id(1); const int isl = get_global_id(2); const int total_loc = get_local_size(0); const int tp_count = get_global_size(1); const int sl_count = get_global_size(2);
Les données sources sont organisées de manière extrêmement compacte. L'historique est transmis sous forme de tableau vectoriel de type float4. Chaque entrée correspond à une bougie : ouverture, plus haut, plus bas, clôture. C'est important. Nous ne séparons pas les données en tableaux distincts et nous ne compliquons pas l'accès. Le GPU fonctionne mieux avec les structures denses, et c'est ici pleinement exploité.
Les tableaux tp et sl sont transmis séparément. De cette manière, nous définissons immédiatement les deuxième et troisième dimensions de la tâche. Chaque thread fonctionne non seulement avec sa propre section d'historique, mais aussi avec une combinaison spécifique de paramètres de trading. En conséquence, l'espace de calcul devient tridimensionnel : historique × TP × SL.
Puis le travail proprement dit commence. Chaque thread au sein du groupe reçoit son propre lid (identifiant local) et commence à parcourir l'historique par pas égal à la taille du groupe.
__local int buf_stat[BLOCK_SIZE][STAT_DIM]; int local_stat[STAT_DIM]; for(int i=0;i<STAT_DIM;i++) local_stat[i]=0; //--- float4 pattern[PATTERN_SIZE]; if(bars < (PATTERN_SIZE + horizon)) return; for(int i = 0; i < PATTERN_SIZE; i++) pattern[i] = price[bars - 1 - PATTERN_SIZE + i]; //--- border for(int i = lid; i < (bars - horizon - PATTERN_SIZE); i += total_loc) { bool match = true; //--- pattern check for(int k = 0; k < PATTERN_SIZE ; k++) { float4 a = price[i + k]; float body_a = a.w - a.x; float body_b = pattern[k].w - pattern[k].x; if(fabs(body_a - body_b) > tolerance) { match = false; break; } float upper_a = a.y - fmax(a.w, a.x); float upper_b = pattern[k].y - fmax(pattern[k].w, pattern[k].x); if(fabs(upper_a - upper_b) > tolerance) { match = false; break; } float lower_a = fmin(a.w, a.x) - a.z; float lower_b = fmin(pattern[k].w, pattern[k].x) - pattern[k].z; if(fabs(lower_a - lower_b) > tolerance) { match = false; break; } }
C'est une méthode classique. Nous ne créons pas un thread pour chaque barre - ce serait trop coûteux. Au lieu de cela, chaque thread gère sa propre bande de données. La charge est répartie uniformément, sans lancements de noyau inutiles.
Avant le début du passage, une norme est établie. Utilisons les dernières bougies de l'historique. C’est ce marché actuel qui nous intéresse. Aucun motif prédéfini. Pas de suppositions. Seul l’état actuel du marché est pris en compte.
Vient ensuite le point clé : la comparaison. Pour chaque position dans l'historique, on vérifie si la section est similaire à la norme. De plus, la comparaison est basée sur la structure des bougies :
- corps;
- ombre supérieure ;
- ombre inférieure.
Et tout cela se fait avec une tolérance. Il s'agit d'une nuance subtile mais importante. Nous n'exigeons pas une correspondance exacte. Le marché ne crée pas de copies parfaites. Ce qui nous intéresse, c'est la forme, pas l'identité visuelle.
Si au moins un élément est hors tolérance, la correspondance est rejetée. Rapide et sans calculs inutiles. Si une correspondance est trouvée, la deuxième partie commence : la simulation de la transaction. Le point d'entrée est pris à l'ouverture d'une nouvelle bougie après la formation du motif.
//--- simulate a trade if(match) { local_stat[0] += 1; int open = i + PATTERN_SIZE; float4 bar = price[open]; float entry = bar.x; float tp_val = tp[itp]; float sl_val = sl[isl]; float buy_tp = entry + tp_val; float buy_sl = entry - sl_val; float sell_tp = entry - tp_val; float sell_sl = entry + sl_val; bool buy_tp_hit = 0, buy_sl_hit = 0; bool sell_tp_hit = 0, sell_sl_hit = 0; for(int k = 0; k < horizon; k++) { bar = price[open + k]; float high = bar.y; float low = bar.z; // SL is checked first (worst-case) buy_sl_hit |= (buy_tp_hit == 0) & (buy_sl_hit == 0) & (low <= buy_sl); buy_tp_hit |= (buy_tp_hit == 0) & (buy_sl_hit == 0) & (high >= buy_tp); sell_sl_hit |= (sell_tp_hit == 0) & (sell_sl_hit == 0) & (high >= sell_sl); sell_tp_hit |= (sell_tp_hit == 0) & (sell_sl_hit == 0) & (low <= sell_tp); if((buy_tp_hit | buy_sl_hit) & (sell_tp_hit | sell_sl_hit)) break; } // forced closing by time buy_sl_hit |= (buy_tp_hit == 0) & (buy_sl_hit == 0) & (entry > bar.w); buy_tp_hit |= (buy_tp_hit == 0) & (buy_sl_hit == 0) & (entry < bar.w); sell_sl_hit |= (sell_tp_hit == 0) & (sell_sl_hit == 0) & (entry < bar.w); sell_tp_hit |= (sell_tp_hit == 0) & (sell_sl_hit == 0) & (entry > bar.w); //--- local_stat[1] += (int)buy_tp_hit; local_stat[2] += (int)buy_sl_hit; local_stat[3] += (int)sell_tp_hit; local_stat[4] += (int)sell_sl_hit; } }
Ensuite, les niveaux TP et SL sont calculés pour l'achat et la vente. Notez ce détail : les deux côtés sont comptés simultanément. Cela permet d'économiser des calculs et d'obtenir une image complète du comportement du marché.
On entame ensuite un parcours de l’historique en avant avec une limitation d’horizon. À chaque étape, les éléments suivants sont vérifiés :
- si le SL a été atteint ;
- si le TP a été atteint.
Le SL est vérifié en premier. Il ne s'agit pas d'un accident, mais d'une hypothèse délibérée envisageant le pire scénario. Cette approche rend l'évaluation plus prudente – et donc plus proche de la réalité.
Une fois le résultat déterminé pour les deux parties, la boucle est stoppée. Aucun travail supplémentaire n'est effectué.
Si ni le TP ni le SL ne sont atteints, la position est fermée de force en fonction du temps. Voici un autre élément important. Nous ne laissons pas de positions ouvertes — chaque situation doit aboutir à un résultat.
Tous les résultats sont accumulés dans le tableau privé local_stat. C'est fondamental. Aucune synchronisation n'est effectuée à ce stade. Chaque thread fonctionne indépendamment et rapidement.
Ensuite, nous passons à ce pour quoi tout a été conçu : l'agrégation locale. Les premiers threads BLOCK_SIZE écrivent leurs résultats dans buf_stat. Il s'agit de la mémoire locale, elle est rapide et partagée par le groupe.
//--- write to 'local' if(lid < BLOCK_SIZE) for(int k = 0; k < STAT_DIM; k++) buf_stat[lid][k] = local_stat[k]; barrier(CLK_LOCAL_MEM_FENCE);
Vient ensuite une passe supplémentaire, qui compresse les données, s'il y a plus de threads que la taille du buffer. C'est une méthode astucieuse pour contraindre une taille de groupe arbitraire à une fenêtre de réduction fixe.
for(int i = BLOCK_SIZE; i < total_loc; i += BLOCK_SIZE) { if(lid >= i && lid < (i + BLOCK_SIZE)) for(int k = 0; k < STAT_DIM; k++) buf_stat[lid-i][k] += local_stat[k]; barrier(CLK_LOCAL_MEM_FENCE); }
Ensuite, on effectue la réduction classique : une somme par paires avec réduction par étapes.
//--- reduction for(int stride = BLOCK_SIZE / 2; stride > 0; stride >>= 1) { if(lid < stride) { for(int k = 0; k < STAT_DIM; k++) { buf_stat[lid][k] += buf_stat[lid + stride][k]; buf_stat[lid + stride][k] = 0; } } barrier(CLK_LOCAL_MEM_FENCE); }
Le résultat est un bloc unique de statistiques agrégées pour l’ensemble du groupe. Il correspond à la dernière étape : l’enregistrement du résultat.
//--- write the result if(lid == 0) { int idx = (itp * sl_count + isl) * STAT_DIM; int count = buf_stat[0][0]; global_stats[idx + 0] = count; float norm = (count > 0) ? (1.0f / ((float)count)) : 0.0f; for(int k = 1; k < STAT_DIM; k++) global_stats[idx + k] = buf_stat[0][k] * norm; } }
Une transformation importante se produit ici :
- le nombre total de correspondances est conservé tel quel ;
- les valeurs restantes sont réduites à des probabilités.
Une fois le calcul terminé, le résultat n'est pas un ensemble de nombres bruts, mais des statistiques déjà établies. Nous pouvons constater :
- combien de fois une situation similaire s'est produite dans les données historiques ;
- à quelle fréquence a été déclenché le Take Profit pour les achats ;
- à quelle fréquence a été déclenché le Stop Loss ;
- comment les positions vendeuses se sont comportées ;
- quelles combinaisons de paramètres semblaient les plus performantes.
C'est ensuite le programme principal qui prend la décision. Il se base sur les statistiques plutôt que sur un joli motif visuel. Si les données sont insuffisantes, le signal est ignoré. Si l'échantillon est suffisant, les probabilités d'achat et de vente sont comparées. Ensuite, les meilleures valeurs du Take Profit et du Stop Loss sont sélectionnées. Ce n'est qu'après cela qu'une position pourra être ouverte.
C'est un point important. Dans une telle structure, la décision ne repose pas sur une supposition ou sur un ensemble de règles rigides. Elle repose sur l'analyse du comportement habituel du marché dans des conditions similaires. Cette approche ne garantit pas les profits, mais elle est beaucoup plus cohérente avec l'idée d'analyse systémique des données.
Les résultats des tests le confirment. Le système ne semble pas parfait et ne présente pas une courbe de capital particulièrement esthétique. Cela pourrait même être une bonne chose : ce que nous avons devant nous n'est pas un modèle sur-ajusté, mais une structure de travail relativement simple qui fait face aux fluctuations normales du marché, à des séries de pertes et à des baisses. Cependant, le résultat reste positif.
Ce qui importe particulièrement ici, ce n'est pas tant que le profit final ait été modéré, mais plutôt que nous ayons réussi à créer un mécanisme de trading statistiquement significatif sans nous appuyer sur des indicateurs classiques et des schémas prédéterminés. Dans cette architecture, OpenCL joue le rôle d'accélérateur, ce qui rend une telle analyse pratiquement réalisable dans un temps raisonnable.
Autrement dit, le GPU n'est pas nécessaire ici pour la rentabilité de la stratégie. Il est nécessaire de tester rapidement un grand nombre de cas historiques similaires et de transformer les données de marché en une hypothèse mesurable.

Conclusion : OpenCL est utile pour l'analyse des configurations en chandeliers japonais car il nous permet de parcourir rapidement diverses situations similaires, de calculer les résultats des transactions et de nous appuyer sur des statistiques plutôt que sur des suppositions visuelles.
Conclusion
OpenCL dans MQL5 doit être perçu comme un outil destiné à une catégorie spécifique de tâches. Il démontre toute sa valeur lorsqu'il existe un grand nombre de données, des opérations répétitives et la possibilité de paralléliser efficacement les calculs. Dans tous les autres cas, le CPU reste le choix le plus rationnel : il est plus simple, plus flexible et souvent plus rapide pour les calculs de petite taille ou difficilement extensibles.
L'article aborde tous les aspects, depuis la compréhension des limitations architecturales jusqu'à une structure pratique pour décharger les calculs sur le GPU. Il a été démontré que le résultat est déterminé non seulement par les capacités du GPU, mais aussi par la qualité de l'organisation du circuit de calcul. Les initialisations fréquentes, les transferts de données redondants et les tâches trop petites anéantissent facilement tout gain. Au contraire, lors de la réutilisation du contexte, du traitement de grands tableaux et du transfert rigoureux des données, le GPU commence alors à fonctionner dans les conditions où il est le plus efficace.
Cela est particulièrement évident dans l'exemple de la multiplication matricielle : avec un volume de données suffisant, le traitement parallèle offre une accélération de plusieurs fois par rapport au CPU. Mais la valeur pratique de cette structure ne se révèle pas seulement lors de tests synthétiques. Dans une tâche plus proche du trading réel, le GPU permet d'accélérer la recherche liée aux optimisations de masse, aux tests d'hypothèses et à la recherche de modèles stables dans les données. C’est là qu’il devient évident que la carte graphique n’est pas utile en soi, mais est un moyen de passer à une échelle de calcul supérieure.
La principale conclusion demeure d'ordre pratique. Le transfert vers le GPU ne rend pas un système de trading rentable et ne remplace pas un modèle de marché pertinent. Au lieu de cela, il ouvre l’accès au volume de calculs exploratoires et d’analyse qui serait trop coûteux ou trop lent sur le CPU. Voici sa véritable valeur : non pas promettre des miracles, mais permettre un cycle de recherche plus large et plus systématique en MQL5.
Programmes utilisés dans l'article
| # | Nom | Type | Description |
|---|---|---|---|
| 1 | PatternStats.mq5 | Expert Advisor | EA de test |
| 2 | PatternStats.cl | Base de code | Bibliothèque de code OpenCL |
Traduit du russe par MetaQuotes Ltd.
Article original : https://www.mql5.com/ru/articles/22242
Avertissement: Tous les droits sur ces documents sont réservés par MetaQuotes Ltd. La copie ou la réimpression de ces documents, en tout ou en partie, est interdite.
Comment Échanger des Données : Une DLL pour MQL5 en 10 minutes
MetaTrader 5 et le Calendrier Économique MQL5 : comment transformer les actualités en un système de trading reproductible
L'Histogramme des prix (Profile du Marché) et son implémentation en MQL5
Trading algorithmique sans tâches routinières : analyse rapide des opérations dans MetaTrader 5 avec SQLite
- Applications de trading gratuites
- Plus de 8 000 signaux à copier
- Actualités économiques pour explorer les marchés financiers
Vous acceptez la politique du site Web et les conditions d'utilisation