English Русский Deutsch 日本語 Türkçe
preview
Dalla CPU alla GPU in MQL5: Un framework OpenCL pratico per accelerare la ricerca, le ottimizzazioni e il riconoscimento dei pattern.

Dalla CPU alla GPU in MQL5: Un framework OpenCL pratico per accelerare la ricerca, le ottimizzazioni e il riconoscimento dei pattern.

MetaTrader 5Integrazione |
19 0
MetaQuotes
MetaQuotes

Introduzione

Il passaggio dalla CPU alla GPU in MQL5 spesso sembra un passo ovvio: se il processore grafico è in grado di calcolare più velocemente, allora la ricerca nel trading dovrebbe accelerare automaticamente. In realtà, tutto è molto più complesso. La GPU è effettivamente in grado di fornire vantaggi significativi, ma solo quando il compito si adatta bene a un modello di calcolo parallelo. Altrimenti, potreste non ottenere alcuna accelerazione, ma solo un'architettura più complessa con costi uguali o addirittura superiori.

Ciò è particolarmente importante per il trading algoritmico. L'analisi dei dati di mercato, l'iterazione sui parametri, la verifica di ipotesi su larga scala e la ricerca di pattern ricorrenti spesso richiedono un'ingente quantità di calcoli. È qui che la GPU rivela il suo potenziale. È particolarmente efficace quando la stessa operazione deve essere eseguita su più elementi e il risultato può essere raccolto al termine dell'elaborazione parallela. In tali scenari, una scheda grafica cessa di essere un elemento decorativo e diventa una risorsa di calcolo a tutti gli effetti.

Ma le GPU hanno il loro prezzo. Prima di iniziare i calcoli, dobbiamo preparare i dati, trasmetterli al dispositivo, attendere l'esecuzione del kernel e restituire il risultato. Per attività di dimensioni contenute, questa logica potrebbe risultare troppo complessa. Nelle aree in cui la CPU lavora velocemente e senza costi aggiuntivi superflui, il trasferimento dei calcoli alla GPU non offre alcun vantaggio. A volte può persino essere d'intralcio, soprattutto se il compito cambia frequentemente, richiede una logica flessibile o è legato a piccole quantità di dati.

Nell'ambiente MQL5, OpenCL funge da collegamento tra la logica applicativa e la GPU. Questo ci permette di spostare la parte più complessa dei calcoli al di fuori del programma principale e di organizzare l'elaborazione batch dei dati sulla GPU. Tuttavia, OpenCL di per sé non è un pulsante magico di accelerazione. È utile solo quando l'architettura del compito tiene conto fin dall'inizio delle specificità del calcolo parallelo e riduce al minimo lo scambio di dati tra CPU e GPU.

In questo contesto, la GPU è considerata un livello separato del circuito di calcolo, destinato a operazioni complesse e ripetitive. Questo approccio è utile in attività di ricerca, ottimizzazione e individuazione di pattern, dove il volume dei calcoli cresce più rapidamente della tolleranza all'attesa del ricercatore. La conclusione pratica qui è semplice: prima di tutto dobbiamo capire cosa vale esattamente la pena trasferire alla GPU, e solo allora possiamo aspettarci che l'accelerazione abbia un effetto reale.

CPU vs GPU


Preparazione dell'ambiente

Lavorare con OpenCL non inizia con i calcoli, ma con la preparazione. Innanzitutto, il programma deve trovare un dispositivo disponibile, creare un contesto di lavoro, preparare il kernel e allocare memoria per i dati. Tutto ciò può sembrare una mera formalità tecnica, ma è proprio in questa fase che spesso si perde una notevole quantità di produttività.

L'errore principale è quello di trattare la GPU come se fosse una normale chiamata di funzione: la si chiama, si ottiene il risultato e si passa oltre. In realtà, dietro una simile chiamata si cela un'intera catena di azioni. Dobbiamo creare o connetterci ad un contesto, preparare un programma, compilare il kernel, allocare la memoria, trasferire i dati e solo allora iniziare il calcolo. Se ripetiamo questa operazione più e più volte, la GPU impiegherà troppo tempo per le fasi preparatorie anziché per i calcoli.

Pertanto, in una buona implementazione, quasi tutto ciò che si può fare viene fatto una sola volta. Il contesto viene creato in anticipo e poi riutilizzato. Il kernel viene compilato una sola volta se il codice non subisce modifiche. È inoltre preferibile non ricreare i buffer di memoria se non necessario, ma riutilizzarli. Questo approccio riduce i costi generali e rende il lavoro con la GPU davvero utile.

Lo stesso vale per il trasferimento dei dati. La GPU non è adatta a gestire un gran numero di piccole attività che vengono costantemente inviate avanti e indietro. Con questo approccio, il tempo non viene impiegato nei calcoli, bensì nello scambio di dati. È molto più efficiente raccogliere i dati in lotti più grandi ed eseguire i calcoli con minore frequenza, ma con un carico maggiore. Un'unica grande operazione è quasi sempre preferibile a una serie di piccole operazioni.

Un altro problema comune è la sincronizzazione non necessaria. Se il programma si interrompe dopo ogni passaggio e attende il completamento della GPU, il dispositivo è inattivo. Ciò riduce l'effetto complessivo dell'accelerazione. È preferibile strutturare il lavoro in modo tale che la GPU riceva un compito, lo completi senza interruzioni non necessarie e restituisca il risultato solo quando è effettivamente richiesto.

Ciò è particolarmente importante per MQL5. I programmi di trading sono sensibili ai ritardi e le imprecisioni architetturali diventano rapidamente evidenti. Se il contesto viene costantemente ricreato, la memoria viene allocata in modo caotico e il kernel viene compilato a ogni calcolo, la GPU si trasforma in una fonte di ritardi anziché in un acceleratore.

Pertanto, il principio pratico fondamentale è molto semplice: tutto ciò che può essere preparato in anticipo dovrebbe essere preparato in anticipo. Tutto ciò che può essere riutilizzato non dovrebbe essere ricreato. Quando l'ambiente è ben organizzato, OpenCL aiuta realmente ad accelerare i calcoli complessi. In assenza di questa disciplina, il vantaggio si perde facilmente ancor prima di iniziare i calcoli.


Gestione della memoria

Quando si parla di GPU, molti pensano principalmente alla potenza di calcolo. In pratica, però, spesso la questione non si riduce tanto alla velocità dei calcoli, quanto piuttosto alle modalità precise con cui i dati vengono forniti. Anche una GPU molto veloce non fornirà buoni risultati se ha difficoltà a gestire la memoria.

In OpenCL, la memoria è molto più di un semplice luogo in cui vengono memorizzati i dati. Le prestazioni dipendono direttamente da come viene utilizzata. La GPU dispone di diversi livelli di memoria. Alcuni lavorano più velocemente, altri più lentamente. Le piccole aree di memoria interna sono accessibili rapidamente, ma l'accesso alla memoria principale del dispositivo è significativamente più dispendioso.

Ciò implica un'importante regola pratica: i dati devono essere organizzati in modo che la GPU acceda il meno possibile alla memoria lenta. Meno si legge e si scrive inutilmente, meglio è. È particolarmente importante evitare di trasferire ripetutamente gli stessi dati tra CPU e GPU inutilmente. Spesso è lo scambio di dati, piuttosto che il calcolo in sé, a costituire il principale collo di bottiglia.

Organizzazione della memoria GPU

In parole semplici, le GPU prediligono attività in cui è possibile inviare una grande quantità di dati una sola volta, eseguire molte operazioni simili e quindi restituire il risultato finale. È invece inefficiente quando il programma invia costantemente piccoli blocchi di dati e attende una risposta dopo ogni passaggio. In questa modalità, l'accelerazione scompare rapidamente.

Questo aspetto è particolarmente delicato ai fini del trading. Se un programma, in ogni fase, prima prepara i dati, poi li trasmette, poi attende il risultato e infine ripete tutto da capo, le prestazioni saranno instabili. È molto meglio raccogliere in anticipo i dati necessari, inviarli al dispositivo in un unico blocco, eseguire il calcolo e utilizzare il risultato nel programma principale.

È importante suddividere correttamente i ruoli in questo contesto. La CPU rimane il centro di controllo: raccoglie i dati, avvia i calcoli e prende la decisione finale. La GPU si occupa della parte del lavoro in cui è necessario ripetere rapidamente la stessa operazione molte volte. Questa struttura è quasi sempre più affidabile ed efficiente rispetto al tentativo di trasferire tutto su un unico dispositivo.

Un altro errore tipico è il lancio di kernel troppo piccoli. A prima vista, questo sembra comodo: i nuovi dati arrivano e vengono immediatamente inviati alla GPU. Ma ogni lancio ha anch’esso un costo. Se gli invii sono troppi, i costi fissi iniziano a divorare tutto il profitto. Pertanto, nella maggior parte dei casi è meglio utilizzare la GPU meno frequentemente, ma affidarle un compito più ampio e semplice.

Di conseguenza, la gestione della memoria non è un dettaglio tecnico di poco conto, bensì uno dei principali fattori che influenzano le prestazioni. Se i dati sono organizzati correttamente, la GPU funziona come una pipeline. Altrimenti, anche un dispositivo potente rimarrà inattivo e sprecherà tempo in richieste e trasferimenti non necessari.

Conclusione: La GPU velocizza i calcoli solo quando i dati gli vengono forniti correttamente. Minori sono le trasmissioni, le richieste e i piccoli invii non necessari, maggiore è l'effetto reale. .


Creazione di un programma

Nel programma applicativo, CPU e GPU non sono in competizione tra loro, ma lavorano insieme. La CPU rimane il centro di controllo genera i dati iniziali, richiama il metodo richiesto, accetta il risultato e confronta i tempi di esecuzione. La GPU si occupa esclusivamente della parte di calcolo. Questa è una struttura classica: anziché trascinare l'intero programma sul dispositivo, dovremmo fornirgli solo la sezione in cui esiste un effettivo parallelismo.

Il modo più semplice per capire come si crea un programma usando OpenCL è attraverso un esempio specifico. La libreria standard MQL5 include un'implementazione esemplificativa della moltiplicazione di matrici. Nel programma principale, vengono innanzitutto create due matrici che vengono riempite con valori casuali.

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);
        }

Innanzitutto, un calcolo sequenziale viene eseguito sulla CPU, e successivamente lo stesso calcolo viene eseguito sulla 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;
     }

I calcoli veri e propri vengono eseguiti con metodi separati. La parte relativa alla GPU viene presentata in due implementazioni dello stesso problema: una semplice e una ottimizzata. Questo ci permette di vedere immediatamente la differenza non in parole, ma nel codice e nei tempi di esecuzione.

Analizziamo la versione per CPU. Qui tutto è estremamente chiaro: un classico triplo ciclo. Ciascun elemento della matrice risultante viene calcolato in sequenza.

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);
  }

Questo codice illustra l'idea di base del compito. Ci sono due matrici. Viene mostrata la somma dei prodotti per riga e per colonna. L'esecuzione avviene in sequenza. Sulla CPU, questo processo avviene in modo trasparente e senza alcuna preparazione superflua. Ma è proprio qui che risiede il limite principale: non appena le dimensioni della matrice aumentano, i calcoli sequenziali diventano sempre più onerosi.

Ora passiamo alla parte relativa alla GPU. È importante sottolineare il principio fondamentale: In questo esempio, OpenCL è nascosto in un metodo separato. Il programma principale rimane pulito e tutta la gestione della GPU è concentrata in un unico punto.

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);

Qui si può notare subito un aspetto importante: le matrici dei risultati per le due opzioni GPU sono preparate in anticipo. Questo ci impedisce di mescolare i calcoli con la preparazione della memoria. A questo livello il programma è già strutturato in modo disciplinato: prima viene l'allocazione degli array, poi OpenCL.

Successivamente, viene creato e inizializzato il contesto OpenCL.

//--- OpenCL
   ulong timei_gpu = GetMicrosecondCount();
   COpenCL OpenCL;
   if(!OpenCL.Initialize(cl_program, true))
     {
      PrintFormat("Error in OpenCL initialization. Error code=%d", GetLastError());
      return(false);
     }

È qui che diventa evidente il significato pratico di OpenCL. Fino a questo punto, il programma era un codice MQL5 convenzionale. Ora sta preparando l'ambiente di calcolo per la GPU. E ciò che è particolarmente importante è che questa operazione non è gratuita. Misuriamo il tempo di inizializzazione separatamente. Prima di parlare di accelerazione, dobbiamo esaminare onestamente quanto costa avviare l'ambiente stesso.

Successivamente vengono creati due kernel. Il primo è una semplice variante parallela. Il secondo è più avanzato e prevede gruppi locali.

//--- create kernels
   OpenCL.SetKernelsCount(2);
   OpenCL.KernelCreate(0, "MatrixMult_GPU1");
   OpenCL.KernelCreate(1, "MatrixMult_GPU2");

È opportuno sottolineare che il tempo di esecuzione dipende in larga misura dalla qualità dell'algoritmo utilizzato nel programma OpenCL. Possiamo semplicemente parallelizzare il calcolo, oppure possiamo migliorare la gestione della memoria. L'esempio contiene entrambe le opzioni, il che lo rende particolarmente chiaro.

Successivamente, si procede alla preparazione dei buffer. Le matrici di input vengono copiate sul dispositivo e viene creato un buffer separato per il risultato.

//--- 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);
     }

Questa è già una vera e propria struttura GPU funzionante. I dati vengono trasferiti al dispositivo, elaborati in loco e quindi restituiti. È questo ordine che rende possibile l'accelerazione. Se lo suddividiamo in piccoli pezzi, la GPU inizierà a impiegare troppo tempo nella preparazione e nello scambio, anziché nel calcolo vero e proprio.

Dopodiché, vengono impostati gli argomenti del primo kernel.

//--- 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 logica qui è molto semplice: il kernel ottiene i buffer dei dati e le dimensioni della matrice. All'interno del kernel OpenCL, viene deciso quale thread è responsabile di quale elemento. Questo è un punto importante: La GPU di per sé non sa come interpretare gli array. Questa struttura deve esserle comunicata in modo esplicito.

Successivamente, si imposta la dimensione del problema e si avvia la prima opzione di calcolo.

//--- 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);

Questa è la prima opzione GPU. Illustra l'idea di base: un thread calcola un elemento di output. Questa struttura offre già il parallelismo, ma non sfrutta ancora tutte le possibilità di ottimizzazione della memoria. Ecco perché nell'esempio c'è un secondo kernel subito dopo. Prima dell'avvio, gli argomenti vengono specificati nello stesso modo, ma il metodo di esecuzione è diverso - con un gruppo di lavoro locale.

//--- 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;

È qui che emerge la differenza pratica. La prima versione si limita a parallelizzare il calcolo. La seconda organizza già il calcolo in blocchi. Questo è più importante di quanto sembri a prima vista. Le GPU apprezzano non solo il parallelismo, ma anche una buona organizzazione della memoria. Pertanto, i blocchi e i gruppi locali offrono vantaggi significativi.

Nel secondo metodo di avvio del kernel si specifica anche la dimensione dei gruppi di lavoro locali.

//--- 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);
  }

Ora, la parte più interessante è ciò che accade all'interno del codice OpenCL. La prima versione del kernel sembra essere la più semplice possibile.

__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;
  }

Si tratta di un trasferimento pressoché letterale della logica matematica alla GPU. Ciascun thread riceve le proprie coordinate e calcola un elemento della matrice dei risultati.

La seconda versione è già più interessante. Utilizza array locali e la sincronizzazione dei thread.

__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;

Qui è già chiaro che il calcolo è strutturato in modo diverso. I thread vengono raggruppati e i dati vengono caricati nella memoria interna del blocco. Ciò riduce il numero di accessi alla memoria globale e rende il dispositivo più efficiente.

Successivamente, vengono caricati i frammenti e i thread vengono sincronizzati.

   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);
     }

Ecco la risposta alla domanda sul perché esistano due implementazioni GPU. Il primo esempio mostra la parallelizzazione. La seconda mostra l'ottimizzazione della memoria. E nella pratica, è proprio questo che spesso decide il destino dell'accelerazione.

L'output risultante viene reinserito nell'array di output.

   int offset_c = BLOCK_SIZE * (cols_b * group_j + group_i);
   matrix_c[offset_c + cols_b * i + j] = sum;
  };

Nel programma principale, questi due kernel vengono confrontati in base al tempo di esecuzione e la precisione dei calcoli viene controllata tramite un'opzione 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);

Per completezza, aggiungiamo al confronto anche l'utilizzo delle operazioni matriciali integrate.

//--- 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); 

In un esperimento pratico, è stato confrontato il tempo della moltiplicazione di matrici su un sistema ibrido: una CPU e due GPU : NVIDIA GeForce RTX 4060 Laptop GPU e Intel Iris Xe Graphics. La semplice implementazione CPU è stata utilizzata come riferimento, mostrando un tempo di circa 2056 – 2180 ms. In questo contesto, è opportuno sottolineare le operazioni matriciali ottimizzate, che hanno mostrato un tempo stabile di circa 32-34 ms, valore che per una CPU di questa classe può essere considerato vicino al livello di prestazioni atteso per l'elaborazione vettorializzata.

Risultati della moltiplicazione di matrici 1000*2000

Nel trasferimento dei calcoli alla GPU, i risultati sono stati contrastanti, evidenziando quanto l'efficienza di OpenCL dipenda dall'organizzazione dei blocchi di calcolo. Nel caso della RTX 4060, utilizzando gruppi di lavoro globali, il tempo di esecuzione era di circa 38 ms, il che formalmente non offre un vantaggio rispetto alle operazioni matriciali della CPU. Tuttavia, il passaggio a gruppi di lavoro locali ha cambiato radicalmente la situazione - il tempo è sceso a 11 ms. Ciò dimostra già il funzionamento completo dell'approccio a mosaico, in cui il riutilizzo dei dati nella memoria locale riduce la pressione sulla memoria globale e consente un utilizzo più efficiente delle unità di calcolo GPU.

Una tendenza simile, ma più evidente, si osserva nella GPU integrata Intel Iris Xe. In modalità gruppi globali, il tempo di esecuzione era di 213 ms, mentre utilizzando gruppi locali si riduceva a 45 ms. Nonostante persista un ritardo generale rispetto alle GPU dedicate, l'accelerazione relativa qui è ancora più evidente, il che enfatizza la sensibilità delle GPU meno produttive all'ottimizzazione dell'accesso alla memoria.

Il tempo di inizializzazione della GPU merita particolare attenzione. Per la RTX 4060, il tempo era di circa 99 ms, mentre per la Iris Xe di circa 4 ms. Questo fattore non può essere ignorato negli scenari applicativi, poiché influisce sull'efficienza complessiva della pipeline computazionale.

Nel complesso, i risultati mostrano un quadro classico della transizione da una modalità di esecuzione limitata dalla memoria a una modalità di esecuzione efficiente dal punto di vista computazionale. La CPU rimane competitiva a parità di scala del compito, mentre le GPU rivelano il loro vantaggio solo con la corretta organizzazione delle unità di calcolo locali. Ciò è particolarmente evidente sulla RTX 4060, dove la differenza tra un'implementazione subottimale e una ottimizzata raggiunge circa tre o quattro volte, definendo di fatto il confine tra un utilizzo inefficiente e un utilizzo completo dell'acceleratore grafico.


Testare i candlestick pattern

L'esempio della moltiplicazione di matrici mostra bene come una GPU possa velocizzare i calcoli. Ma questo non è sufficiente per un trader. È più importante capire se questo approccio può essere utilizzato per risolvere un problema che sia realmente legato al mercato. Il passo successivo è quindi passare dalla matematica astratta all'analisi dei candlestick pattern.

I candlestick pattern sono generalmente descritti come figure preconfezionate dai nomi ben noti. Sono facili da riconoscere in un’immagine e nei libri spesso appaiono convincenti. Ma se analizziamo la questione più nel dettaglio, sorge spontanea la domanda: in che misura tali modelli sono effettivamente supportati dalle statistiche? Dove sono i criteri precisi? Come si fa a sapere se un pattern funziona non in teoria, ma con dati reali?

Invece di cercare in anticipo figure familiari sui libri di testo, si può affrontare il problema in modo diverso. Non imporre sul mercato schemi predefiniti, ma valutare come si sono comportati in situazioni simili in passato.

Si prendono le ultime candele - ovvero la situazione attuale del mercato. Questo è il nostro modello di riferimento. Il programma analizza quindi lo storico e cerca aree che presentano una forma simile. Il confronto non si basa sul nome del modello, bensì sulle caratteristiche effettive della candela: il corpo, l'ombra superiore e l'ombra inferiore. Inoltre, è ammessa una leggera deviazione, poiché il mercato non ripete quasi mai lo stesso schema in modo perfetto.

Se nello storico è presente una sezione simile, il programma non fa ipotesi, ma verifica il risultato. In questa situazione, viene simulata un'operazione di trading impostando i livelli di Take Profit e Stop Loss, nonché un limite di tempo. Successivamente calcoliamo se casi simili in passato si sono conclusi con un profitto o una perdita.

È proprio in questo contesto che OpenCL si rivela particolarmente utile. Questo compito consiste in un gran numero di verifiche simili: dobbiamo esaminare molte sezioni storiche, confrontarle con il modello attuale e calcolare il risultato della transazione per ogni opzione. Per la CPU questo è possibile, ma con una grande quantità di dati i calcoli diventano difficili. Per le GPU, al contrario, si tratta di un carico naturale: molti calcoli indipendenti che possono essere eseguiti in parallelo.

Inoltre, l'articolo non testa solo una singola combinazione di parametri di transazione, ma un'intera griglia di opzioni contemporaneamente. In altre parole, il programma considera simultaneamente diversi valori di Take Profit e Stop Loss. Ciò ci consente di valutare quali parametri risultino più ragionevoli in condizioni di mercato simili.

Cominciamo dalla logica lato OpenCL — è qui che il compito assume la sua vera forma. In questa architettura, la CPU rimane il direttore d'orchestra, ma tutto il lavoro più impegnativo – ricerca, confronto, modellazione – viene svolto dalla GPU.

Quello che abbiamo qui è una pipeline di analisi.

__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);

I dati di origine sono organizzati in modo estremamente compatto. Lo storico viene passato come array vettoriale di tipo float4. Ogni voce rappresenta una candela: apertura, massimo, minimo, chiusura. Questo è importante. Non suddividiamo i dati in array separati e non complichiamo l'accesso. La GPU funziona meglio con strutture dense, e questo viene sfruttato appieno in questo caso.

Gli array tp e sl vengono passati separatamente. In questo modo, definiamo immediatamente la seconda e la terza dimensione del compito. Ogni thread funziona non solo con la propria sezione di storico, ma anche con una specifica combinazione di parametri di trade. Di conseguenza, lo spazio computazionale diventa tridimensionale: history × TP × SL.

Poi inizia il lavoro vero e proprio. Ogni thread all'interno del gruppo ottiene il proprio identificatore locale e inizia ad attraversare lo storico con un passo pari alla dimensione del gruppo.

   __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;
           }
        }

Questo è un metodo classico. Non creiamo un flusso di lavoro per ogni barra - sarebbe troppo costoso. Invece, ogni thread gestisce la propria striscia di dati. Il carico viene distribuito uniformemente, senza avvii superflui del kernel.

Prima dell'inizio del passaggio, si forma uno standard. Prendiamo le ultime candele dallo storico. Questo è il mercato attuale che ci interessa. Nessun modello esterno. Niente supposizioni. Solo lo stato attuale del prezzo.

Ora veniamo al punto cruciale: il confronto. Per ogni posizione nello storico, viene verificato se la sezione è simile allo standard. Inoltre, il confronto si basa sulla struttura della candela:

  • corpo;
  • ombra superiore;
  • ombra inferiore.

E tutto ciò avviene con una tolleranza. Si tratta di una sfumatura sottile ma importante. Non è necessaria una corrispondenza esatta. Il mercato non crea copie perfette. Ci interessa la forma, non l'identità dei pixel.

Se almeno un elemento è al di fuori della tolleranza, la corrispondenza viene rifiutata. Veloce e senza calcoli superflui. Se viene trovata una corrispondenza, inizia la seconda parte - la modellazione del trade. Il punto di ingresso viene individuato all'apertura di una nuova candela successiva al pattern.

      //--- 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;
        }
     }

Successivamente, vengono calcolati i livelli TP e SL per l'acquisto e la vendita. Notare il dettaglio: entrambi i lati vengono calcolati simultaneamente. Ciò consente di risparmiare sui calcoli e fornisce un quadro completo dell'andamento del mercato.

Si avvia quindi un passaggio in avanti attraverso lo storico con una limitazione dell'orizzonte temporale. Ad ogni passaggio vengono effettuati i seguenti controlli:

  • se SL è stato raggiunto;
  • se il TP è stato raggiunto.

SL viene controllato per primo. Non si tratta di un caso fortuito, ma di una scelta deliberata basata sull'ipotesi peggiore. Questo approccio rende la valutazione più prudente e, di conseguenza, più vicina alla realtà.

Una volta stabilito l'esito per entrambe le parti, il ciclo si interrompe. Non viene svolto alcun lavoro aggiuntivo.

Se non viene raggiunto né il TPlo SL, la posizione viene chiusa forzatamente in base al tempo. Questo è un altro elemento importante. Non lasciamo trade aperti — ogni situazione deve produrre un risultato.

Tutti i risultati vengono accumulati nell'array privato local_stat. Questo è fondamentale. Al momento non è prevista alcuna sincronizzazione. Ogni thread funziona in modo indipendente e rapido.

Ora passiamo all'aspetto per cui tutto è stato creato - l'aggregazione locale. I primi BLOCK_SIZE thread scrivono i loro risultati in buf_stat. Si tratta di memoria locale, è veloce e condivisa dal gruppo.

//--- 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);

Segue poi un ulteriore passaggio che comprime i dati, qualora il numero di thread superi la dimensione del buffer. Questo è un metodo efficace per forzare una dimensione arbitraria del gruppo a rientrare in una finestra di riduzione fissa.

   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);
     }

Dopodiché, viene eseguita la riduzione classica - somma ricorsiva a coppie con riduzione a passi.

//--- 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);
     }

L'output è un singolo flusso che contiene statistiche aggregate per l'intero gruppo. Esegue la fase finale - la registrazione del risultato.

//--- 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;
     }
  }

Qui avviene un'importante trasformazione:

  • il numero totale di corrispondenze rimane invariato;
  • i valori rimanenti vengono ridotti a probabilità.

Una volta completato il calcolo, il risultato non è un insieme di numeri grezzi, bensì statistiche già pronte. Possiamo vedere:

  • quante volte si è verificata una situazione simile nei dati storici;
  • con quale frequenza è stato attivato il Take Profit per gli acquisti;
  • con quale frequenza è stato attivato lo Stop Loss ;
  • come si sono comportati i trade in vendita;
  • Quali combinazioni di parametri sembravano migliori?

La decisione viene quindi presa dal programma principale. Si basa sulle statistiche piuttosto che su un modello esteticamente gradevole. Se i dati sono insufficienti, il segnale viene ignorato. Se il campione è sufficiente, vengono confrontate le probabilità di acquisto e di vendita. Successivamente, vengono selezionati valori più appropriati per Take Profit e Stop Loss. Solo dopo si potrà aprire una posizione.

Questo è un punto importante. In una struttura di questo tipo, la decisione non si basa su una supposizione o su un insieme rigido di regole. Si basa sulla verifica di come il mercato si comporta solitamente in condizioni simili. Questo approccio non garantisce profitto, ma è molto più coerente con l'idea di analisi dei dati di sistema.

Risultati dei test Risultati dei test

I risultati dei test lo confermano. Il sistema non sembra perfetto e non mostra una curva di rendimento molto gradevole. Potrebbe anche essere un bene - quello che abbiamo di fronte non è un modello sovradattato, ma una struttura di lavoro piuttosto semplice che affronta le normali fluttuazioni del mercato, una serie di perdite e drawdown. Tuttavia, il risultato è comunque positivo.

Ciò che è particolarmente importante in questo caso non è tanto che il profitto finale si sia rivelato moderato, quanto piuttosto che siamo stati in grado di creare un meccanismo di trading statisticamente significativo senza fare affidamento su indicatori classici e pattern predeterminati. In questa configurazione, OpenCL svolge il ruolo di acceleratore, rendendo tale analisi praticamente fattibile in termini di tempo.

In altre parole, la GPU è necessaria qui non per la redditività della strategia. È necessaria per testare rapidamente un gran numero di corrispondenze storiche e trasformare i dati di mercato in un'ipotesi misurabile.

Risultati dei test

Conclusione: OpenCL è utile per l'analisi dei candlestick pattern perché ci permette di analizzare rapidamente diverse situazioni simili, calcolare i risultati dei trade e basarci sulle statistiche anziché su supposizioni visive.


Conclusioni

OpenCL in MQL5 dovrebbe essere considerato uno strumento per una specifica classe di attività. Il suo valore emerge in presenza di grandi quantità di dati, operazioni ripetute e la capacità di parallelizzare efficacemente i calcoli. In tutti gli altri casi, la CPU rimane la scelta più razionale: è più semplice, più flessibile e spesso più veloce quando si tratta di calcoli di piccole dimensioni o difficilmente scalabili.

L'articolo affronta l'intero processo, partendo dalla comprensione dei limiti architetturali fino ad arrivare a una struttura pratica per il trasferimento dei calcoli alla GPU. È stato dimostrato che il risultato è determinato non solo dalle capacità della GPU, ma anche dalla qualità dell'organizzazione del circuito di calcolo. Inizializzazioni frequenti, trasferimenti di dati ridondanti e attività troppo piccole annullano facilmente qualsiasi vantaggio. Al contrario, quando si riutilizza il contesto, si lavora con array di grandi dimensioni e si passano i dati con attenzione, la GPU inizia a esprimere i suoi punti di forza.

Ciò è particolarmente evidente nell'esempio della moltiplicazione di matrici: con un volume di dati sufficiente, l'elaborazione parallela fornisce un incremento multiplo rispetto alla CPU. Ma il valore pratico della struttura si rivela non solo nei test sintetici. In un'attività più vicina al trading reale, la GPU consente di accelerare la ricerca relativa all'ottimizzazione di massa, alla verifica delle ipotesi e alla ricerca di pattern stabili nei dati. È qui che diventa chiaro che la scheda grafica è utile non di per sé, ma come mezzo per scalare la disciplina del calcolo.

La conclusione principale rimane di natura pratica. Il passaggio alla GPU non rende redditizio un sistema di trading e non sostituisce un modello di mercato significativo. Al contrario, consente l'accesso a un volume di esplorazione e analisi che risulterebbe troppo oneroso o troppo lento su una CPU. Il suo vero valore risiede proprio in questo: non promettere miracoli, ma consentire un ciclo di ricerca più ampio e sistematico in MQL5.


Programmi utilizzati nell'articolo

# Nome Tipo Descrizione
1 PatternStats.mq5 Expert Advisor Test EA
2 PatternStats.cl Code Base Libreria di codice di programma OpenCL

Tradotto dal russo da MetaQuotes Ltd.
Articolo originale: https://www.mql5.com/ru/articles/22242

File allegati |
MQL5.zip (4.34 KB)
Arriva il Nuovo MetaTrader 5 e MQL5 Arriva il Nuovo MetaTrader 5 e MQL5
Questa è solo una panoramica di MetaTrader 5. Non posso descrivere tutte le nuove funzionalità del sistema per un periodo di tempo così breve: i test sono iniziati il 09.09.2009. Questa è una data simbolica e sono sicuro che sarà un numero fortunato. Sono passati alcuni giorni da quando ho ricevuto la versione beta del terminale MetaTrader 5 e MQL5. Non sono riuscito a provare tutte le sue funzionalità, ma sono già sorpreso.
MetaTrader 5 e il calendario economico di MQL5: Come Trasformare le Notizie in un Sistema di Trading Riproducibile MetaTrader 5 e il calendario economico di MQL5: Come Trasformare le Notizie in un Sistema di Trading Riproducibile
L'articolo presenta un approccio sistematico al trading di notizie in MetaTrader 5 utilizzando il calendario economico integrato: struttura dei dati, funzioni API, regole di sincronizzazione temporale e filtraggio degli eventi. Vengono descritti metodi di caching e aggiornamento incrementale senza sovraccaricare il server. L'articolo fornisce anche un meccanismo funzionante per esportare la cronologia in una risorsa .EX5 per test deterministici utilizzando lo stesso algoritmo.
Utilizza i canali MQL5.community e le chat di gruppo Utilizza i canali MQL5.community e le chat di gruppo
Il sito web MQL5.com riunisce trader di tutto il mondo. Gli utenti pubblicano articoli, condividono codici gratuiti, vendono prodotti nel Market, offrono servizi da freelance e copiano segnali di trading. Puoi comunicare con loro sul Forum, nelle chat dei trader e nei canali MetaTrader.
Python + MetaTrader 5: Framework Rapido per la Ricerca su Dati, Feature e Prototipi Python + MetaTrader 5: Framework Rapido per la Ricerca su Dati, Feature e Prototipi
L'articolo dimostra come l'integrazione tra Python e MetaTrader 5 combini la flessibilità della ricerca e l'esecuzione dei trade in un unico flusso di lavoro. Python viene utilizzato per l'analisi dei dati, la selezione delle feature e l'addestramento dei modelli, mentre MetaTrader 5 viene utilizzato per i test e l'automazione del trading. Questo approccio semplifica il trasferimento delle soluzioni nella pratica, aumenta la riproducibilità e rende lo sviluppo dei sistemi di trading più rapido e strutturato.