BT

Disseminando conhecimento e inovação em desenvolvimento de software corporativo.

Contribuir

Tópicos

Escolha a região

Início Artigos TornadoVM: Acelerando o Java com GPUs e FPGAs

TornadoVM: Acelerando o Java com GPUs e FPGAs

Pontos Principais

  • O TornadoVM é uma programação e um framework para descarregar e executar aplicações na JVM em hardwares heterogêneos, como CPUs com vários núcleos, GPUs e FPGAs;

  • O TornadoVM estende o compilador Graal JIT com sendo um novo backend para o OpenCL;

  • As aplicações criadas para o TornadoVM são de fonte única, ou seja, o mesmo código é usado para expressar o código do host e o código acelerado;

  • O TornadoVM pode executar a migração de tarefas em tempo real em dispositivos de computação.

Em março de 2020, dei uma palestra na QCon-Londres sobre o TornadoVM, onde fiz uma breve introdução ao TornadoVM e expliquei como ele funcionava. Neste artigo, vou expandir o que foi dito na QCon-Londres e mostrar com mais detalhes como os desenvolvedores podem se beneficiar executando o Java automaticamente em um hardware heterogêneo.

Primeiramente, irei fornecer uma visão geral do projeto e da arquitetura do TornadoVM. Posteriormente, explicarei as diferentes partes do TornadoVM com um exemplo prático.

Por que precisamos do TornadoVM?

Não existe uma única arquitetura de computador que seja melhor para executar todos os tipos de cargas de trabalho com eficiência. Isto levou à proliferação de hardwares heterogêneos nos últimos anos, que em outras palavras quer dizer que todo o sistema que programamos provavelmente terá uma mistura de elementos de computação.

Cada um desses elementos possui diferentes características de hardware. A heterogeneidade permite que os programadores melhorem o desempenho das aplicações enquanto diminuem o consumo de energia.

Esses novos dispositivos heterogêneos para computação incluem CPUs com vários núcleos, GPUs (Graphics Processing Units) e FPGAs (Field Programmable Gate Arrays). Essa diversidade é grande, mas nós precisamos de uma maneira de programar com eficiência nestes novos dispositivos.

Um exemplo comum é representado pelas duas linguagens de programação heterogêneas mais populares, CUDA e OpenCL. No entanto, eles expõem vários recursos de baixo nível na API, dificultando o uso de pessoas que não são especializadas. Destaco a seguinte citação do padrão OpenCL 3.0:

O foco do OpenCL são programadores especializados que desejam escrever código portátil e eficiente. [...] Portanto, o OpenCL fornece uma abstração de hardware de baixo nível, além de uma estrutura que dá suporte à programação, e muitos detalhes do hardware implícito são expostos.

A declaração anterior também se aplica ao CUDA e em modelos de programação paralela similares. Ao invés de usar linguagens de programação de baixo nível, os desenvolvedores deste setor e da academia tendem a usar linguagens de programação orientadas a objetos de alto nível, normalmente executadas em ambientes de tempo de execução gerenciado, como Java, R, Python e JavaScript. Embora muitos programadores possam esperar que essas linguagens de programação já tenham sido adaptadas para execução transparente em hardware heterogêneo, a realidade é que o suporte é muito limitado ou até mesmo, ausente.

Neste artigo, iremos explorar o TornadoVM, uma alternativa às linguagens de programação paralela de baixo nível para computação heterogênea. Nós iremos mostrar como os desenvolvedores podem fazer uso de CPUs e GPUs com vários núcleos sem nenhum conhecimento necessário sobre as arquiteturas de computação ou modelos de programação paralelas.

Em resumo, o TornadoVM é um framework de programação paralela para linguagens da JVM que pode descarregar de forma transparente e dinâmica os bytecodes Java no OpenCL e executar o código gerado no hardware heterogêneo. Além disso, o TornadoVM integra em tempo de execução otimizado, que pode reutilizar buffers de dispositivos e salvar transferências de dados entre dispositivos e um novo componente de reconfiguração de aplicação dinâmico para executar a migração de tarefas em tempo real entre dispositivos de computação.

Vamos começar!

A figura abaixo mostra uma visão geral de alto nível do projeto TornadoVM. Como podemos ver, o TornadoVM é composto por uma arquitetura de software em camadas e de microkernel, na qual o componente principal é o mecanismo de execução do TornadoVM. No nível superior, o TornadoVM expõe uma API para os desenvolvedores. Isso ocorre porque o framework atualmente não detecta paralelismo. Ao invés disso, o TornadoVM explora o paralelismo. Portanto, precisa de uma maneira de identificar quais métodos ou funções são candidatos à execução dos GPUs e FPGAs.

Além disso, o TornadoVM contém um core-runtime, dividido em vários componentes: a) O otimizador de fluxo de dados com um novo gerador de bytecode; b) Um pequeno intérprete de bytecode para executar os novos bytecodes; e c) O compilador JIT e o gerenciador de memória. Neste artigo, vamos focar na API, no tempo de execução e em uma visão geral do compilador JIT.

Finalmente, como mostra a imagem anterior, o TornadoVM atualmente suporta o Java 8, usando o JDK (u242) e o JVMCI mais recente e, o OpenJDK 11 via GraalVM 19.3.0. O TornadoVM também é compatível com o OpenCL 1.2 e isso leva à execução em um amplo conjunto de dispositivos, como GPUs (AMD e NVIDIA), FPGAs (Xilinx e Intel), GPUs integradas (como Mali ARM e Intel HD Graphics) bem como CPUs com vários núcleos.

TornadoVM na prática

Vamos falar dos detalhes com um exemplo prático. A seguir, vamos mostrar como programar e executar a multiplicação de matrizes com o TornadoVM em CPUs com múltiplos núcleos e GPUs dedicadas e integradas. A multiplicação de matrizes é um código fácil para ilustrar diferentes conceitos no TornadoVM e constitui o núcleo de muitas aplicações de machine learning e deep learning.

Nota: embora o TornadoVM seja programado em Java, os kernels de computação podem ser expostos a outras linguagens da JVM por meio do framework Polyglot do GraalVM (Truffle).

O seguinte trecho de código mostra a multiplicação de matrizes programada em Java:

class Compute { 
   public static void matrixMultiplication(final float[] A, final float[] B, final float[] C, final int size) { 
    	for (int i = 0; i < size; i++) { 
        	for (int j = 0; j < size; j++) { 
            	float sum = 0.0f; 
            	for (int k = 0; k < size; k++)  
                	sum += A[(i * size) + k] * B[(k * size) + j]; 
                C[(i * size) + j] = sum; 
        	} 
    	  } 
    } 
} 

O trecho deste código mostra o exemplo clássico e canônico de multiplicação de matriz para a computação de GPU. Para acelerar esse trecho com o TornadoVM, primeiro temos que especificar os loops que podem ser paralelizados. Nesse caso, podemos paralelizar totalmente os dois loops externos, nos quais não há dependências entre as iterações. Anotamos o código usando a anotação do TornadoVM @Parallel da seguinte maneira:

class Compute { 
   public static void matrixMultiplication(final float[] A, final float[] B, final float[] C, final int size) { 
    	for (@Parallel int i = 0; i < size; i++) { 
        	for (@Parallel int j = 0; j < size; j++) { 
            	float sum = 0.0f; 
            	for (int k = 0; k < size; k++)  
                	sum += A[(i * size) + k] * B[(k * size) + j]; 
                C[(i * size) + j] = sum; 
        	} 
    	 } 
    } 
}

A anotação @Parallel é usada como dica pelo compilador TornadoVM JIT, que transforma bytecode Java em OpenCL.

O compilador TornadoVM JIT não força a paralelização, ele verifica se os loops anotados podem ser paralelizados e os substitui os loops do for para a indexação paralela equivalente no OpenCL (get_global_id(dimension)). Se os loops do for não puderem ser paralelizados, o TornadoVM efetua a recuperação e executa o código sequencial.

Além disso, os desenvolvedores devem identificar quais métodos Java devem ser acelerados. Para isso, o TornadoVM expõe uma API leve, baseada em tarefas, que define a lista de métodos a serem acelerados, onde cada método corresponde a uma tarefa. Os desenvolvedores podem criar um grupo de tarefas por meio de um agendador de tarefas. O fragmento de código abaixo mostra como criar uma programação de tarefa para o exemplo de multiplicação de matriz:

TaskSchedule t = new TaskSchedule("s0") 
       .task("t0", Compute::matrixMultiplication, matrixA, matrixB, result, size) 
      .streamOut(result); 

Criamos um objeto de agendamento de tarefas (t). No construtor, vamos passar um nome para a tarefa. Pode ser qualquer nome. Ele será útil para mudar o dispositivo no qual todas as tarefas serão executadas. Em seguida, vamos definir um conjunto de tarefas. Neste exemplo, temos apenas uma, mas pode ser qualquer número de tarefas.

Os parâmetros para as tarefas são os seguintes: Iremos passar um nome, neste caso é "t0", e uma referência ao método que queremos acelerar, no caso, apontando para o método matrixMultiplication da classe Java que faz o cálculo. o resto dos parâmetros correspondem ao conjunto de parâmetros do método.

Finalmente, indicamos quais variáveis, ou matrizes, queremos sincronizar com o host (a CPU). Isso é necessário porque geralmente as GPUs e FPGAs não compartilham a mesma memória que a CPU. Portanto, em tempo de execução o TornadoVM irá alocar espaço para todas as variáveis no dispositivo de destino e executará uma transferência de dados do host (CPU) para o dispositivo (por exemplo, uma GPU). Portanto, para finalmente obter o resultado, sincronizamos a lista de variáveis por meio da chamada streamOut da API do TornadoVM.

Até agora, declaramos nossas tarefas e as colocamos no código onde a paralelização pode ser realizada. Para executar a aplicação com TornadoVM, precisamos chamar o método execute () do objeto TaskSchedule.

Esta é uma chamada de bloqueio que criará todos os buffers do OpenCL, que criará um gráfico de execução, compilará todas as tarefas de bytecode Java para o OpenCL e finalmente, irá executar o programa OpenCL gerado no dispositivo de destino. Além disso, o TornadoVM pode combinar muitos métodos para serem compilados juntos em uma única unidade de compilação e executar todos no mesmo dispositivo, como por exemplo, na mesma GPU. Isso irá criar uma oportunidade para otimizar as transferências de dados entre o host e os dispositivos heterogêneos, uma vez que eles geralmente não compartilham a memória com o host primário, a menos que o dispositivo seja uma GPU integrada, como AMD APU, ARM Mali ou GPUs Intel HD Graphics.

Observe que não definimos nenhuma informação específica do dispositivo no código fonte e compartilhamos o mesmo código para execução na CPU, GPUs e FPGAs de vários núcleos. O tempo de execução do TornadoVM e o compilador JIT irão otimizar automaticamente o código, dependendo da arquitetura.

Então, vamos executar o código. Mostrarei primeiro como configurar o ambiente do TornadoVM. Existe um repositório no Github com todos estes exemplos.

Executando a multiplicação da matriz: Configurando o TornadoVM

Iremos executar o TornadoVM usando o Graal 19.3.0 como JDK. Note que atualizamos a versão Graal com muita frequência. A integração do Graal 20.x no TornadoVM está prevista para o final de 2020. Para executar o código, assumimos que o OpenCL está instalado. Veja todos os requisitos de instalação aqui.

$ mkdir -p TornadoVM 
$ cd TornadoVM 
$ wget https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-19.3.0/graalvm-ce-java11-linux-amd64-19.3.0.tar.gz 
$ tar -xf graalvm-ce-java11-linux-amd64-19.3.0.tar.gz 
$ export JAVA_HOME=$PWD/graalvm-ce-java11-19.3.0 
$ git clone --depth 1 https://github.com/beehive-lab/TornadoVM 
$ cd TornadoVM 
$ export PATH=$PWD/bin/bin:$PATH 
$ export TORNADO_SDK=$PWD/bin/sdk 
$ export CMAKE_ROOT=<SET YOUR PATH TO CMAKE ROOT> 
$ make graal-jdk-11 
$ export TORNADO_ROOT=$PWD  

Agora vamos clonar o repositório com os exemplos.

$ git clone https://github.com/jjfumero/qconlondon2020-tornadovm
$ cd qconlondon2020-tornadovm/ 
$ export JAVA_HOME=/path/to/graalvm-ce-java11-19.3.0
$ export PATH="${PATH}:${TORNADO_ROOT}/bin/bin/"  ## Defined previously
$ export TORNADO_SDK=${TORNADO_ROOT}/bin/sdk 
$ export CLASSPATH=target/tornado-1.0-SNAPSHOT.jar 
$ mvn clean install

Agora temos tudo pronto para executar os códigos. Podemos começar explorando quais dispositivos estão disponíveis e visíveis no TornadoVM.

$ tornado --devices
Number of Tornado drivers: 1
Total number of devices  : 3

Tornado device=0:0
	NVIDIA CUDA -- GeForce GTX 1050
		Global Memory Size: 3.9 GB
		Local Memory Size: 48.0 KB
		Workgroup Dimensions: 3
		Max WorkGroup Configuration: [1024, 1024, 64]
		Device OpenCL C version: OpenCL C 1.2

Tornado device=0:1
	Intel(R) OpenCL -- Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz
		Global Memory Size: 31.0 GB
		Local Memory Size: 32.0 KB
		Workgroup Dimensions: 3
		Max WorkGroup Configuration: [8192, 8192, 8192]
		Device OpenCL C version: OpenCL C 1.2
Tornado device=0:2
	Intel(R) OpenCL HD Graphics -- Intel(R) Gen9 HD Graphics NEO
		Global Memory Size: 24.8 GB
		Local Memory Size: 64.0 KB
		Workgroup Dimensions: 3
		Max WorkGroup Configuration: [256, 256, 256]
		Device OpenCL C version: OpenCL C 2.0

No caso apresentado, tenho três dispositivos disponíveis no notebook: Um GPU NVIDIA, um CPU multi-core Intel e uma Intel HD Graphics (GPU integrada). O TornadoVM seleciona o dispositivo 0 por padrão. No entanto, nós podemos mudar o dispositivo associando tarefas aos dispositivos. Vamos começar com a configuração padrão.

$ tornado qconlondon.MatrixMultiplication 512 tornado

This program executes the Matrix Multiplication method 100 times and reports the total time per iteration. This method is a simple example to demonstrate what's happening - later on we'll do a proper performance comparison using JMH.

Este programa executa o método MatrixMultiplication 100 vezes e relata o tempo total por iteração. Este método é um exemplo simples para demonstrar o que está acontecendo, mais tarde, iremos fazer uma comparação de desempenho adequada usando o JMH.

$ tornado qconlondon.MatrixMultiplication 512 tornado
Computing MxM of 512x512
Total time: 77568790 (ns), 0.0776 (s)
Total time: 3133182 (ns), 0.0031 (s)
Total time: 3126146 (ns), 0.0031 (s)
…

Observe que a primeira iteração leva mais tempo do que o resto das iterações, isso se deve a inicialização da compilação JIT que desaparecerá quando estivermos usando o JMH.

Na primeira vez que executamos uma programação de tarefa, o TornadoVM invoca o compilador OpenCL JIT para otimizar e transformar o código OpenCL emC, a partir do bytecode Java. Então, uma vez que o código é gerado, o TornadoVM instala-o gerado em um cache de código e os binários podem ser reutilizados se a mesma tarefa for executada novamente a qualquer momento durante o tempo de execução. Para garantir que o TornadoVM esteja em execução na GPU (dispositivo 0), podemos habilitar as informações de depuração da seguinte maneira:

$ tornado --debug qconlondon.MatrixMultiplication 512 tornado
Computing MxM of 512x512
task info: s0.t0
	platform          : NVIDIA CUDA
	device            : GeForce GTX 1050 CL_DEVICE_TYPE_GPU (available)
	dims              : 2
      global work offset: [0, 0]
	global work size  : [512, 512]
	local  work size  : [32, 32, 1]

Isso é ótimo, o TornadoVM está executando o código Java para multiplicação de matrizes em um NVIDIA GTX 1050. Como referência, vamos executar a aplicação usando o modelo sequencial. Isso é feito sem chamar o compilador JIT do TornadoVM para acelerar o código. Passamos um parâmetro extra para nosso programa para indicar isso:

$ tornado qconlondon.MatrixMultiplication 512 sequential
Computing MxM of 512x512
Total time: 259398036 (ns), 0.2594 (s)
Total time: 247857535 (ns), 0.2479 (s)
…

O que vemos é que, mesmo com o compilador TornadoVM do JIT, a primeira iteração é 3,3x vezes mais rápida. Então, a partir da segunda iteração, obtemos uma aceleração de 80x sobre o código Java. No entanto, precisamos tomar cuidado com esses números. Na próxima seção, iremos falar sobre uma comparação de desempenho usando o Java JMH.

Como mudar o dispositivo?

Podemos alterar o dispositivo no qual queremos executar a aplicação a partir do seguinte comando. Por exemplo, para executar no Intel Integrated Graphics, podemos executar mudando as seguintes opções:

$ tornado -Ds0.t0.device=0:2 --debug qconlondon.MatrixMultiplication 512 tornado
Computing MxM of 512x512
task info: s0.t0
	platform          : Intel(R) OpenCL HD Graphics
	device            : Intel(R) Gen9 HD Graphics NEO CL_DEVICE_TYPE_GPU (available)
	dims              : 2
	global work offset: [0, 0]
	global work size  : [512, 512]
	local  work size  : [16, 16, 1]

A sintaxe é a seguinte:

-D<taskScheduleName>:<taskName>.device=0:<deviceIndex>

Desempenho do TornadoVM para MxM na execução de um notebook Dell XPS 15

Com essas opções, podemos facilmente começar a obter alguns resultados de desempenho. A imagem a seguir mostra a performance do TornadoVM ao executá-lo em diferentes dispositivos OpenCL em relação a implementação Java sequencial (quanto mais alto, melhor). O aumento de velocidade relatado corresponde ao valor médio usando o framework Java JMH para benchmarking. Observe que o eixo y é representado em escala logarítmica devido às altas acelerações. Todos os benchmarks usando o JMH estão incluídos no mesmo repositório. Como podemos observar, a execução na CPU de vários núcleos com o TornadoVM pode atingir até 3,6x em comparação com o Java Hotspot. Quando executado em GPUs, podemos alcançar até 39x e 270x em comparação com Java para o Intel HD graphics e NVIDIA 1050, respectivamente.

Modelo de execução e compilação

Até agora, explicamos rapidamente a API do TornadoVM e como executar as aplicações com TornadoVM a nível de usuário. Agora vamos um pouco mais fundo para ver como o TornadoVM executa o código no dispositivo de destino.

A figura abaixo mostra uma representação do fluxo de execução entre a JVM e o TornadoVM.

A definição do cronograma de tarefas e a invocação do método execute da API do TornadoVM é executada em uma única thread Java (por exemplo, a thread master). O método execute é uma chamada de bloqueio e quando a execução do método retorna, garante que a execução no dispositivo paralelo foi finalizada. Quando o método execute é chamado, o TornadoVM constrói um gráfico de fluxo de dados que representa como os dados são comunicados em diferentes tarefas dentro de uma agenda de tarefas. Este gráfico é usado para otimizar as transferências de dados.

Em seguida, o TornadoVM gera novos bytecodes (instruções simples para orquestrar a execução nos dispositivos alvo, como COPY_IN, LAUNCH, COPY_OUT, BARRIER, etc.). Quando o código é iniciado pela primeira vez (por meio do bytecode LAUNCH), o TornadoVM chama o compilador OpenCL JIT e transforma os bytecodes Java de entrada de cada tarefa (cada método Java a ser acelerado) em um código OpenCL C otimizado.

O TornadoVM é especializado no código OpenCL C dependendo do dispositivo de destino, o que significa que o código gerado para uma GPU é diferente para as CPUs e para os FPGAs. Isso se deve ao fato de que o código OpenCL é portátil entre os dispositivos, mas o desempenho não é uniforme. Portanto, o TornadoVM aumenta o desempenho ao se especializar e aplicar diferentes otimizações por dispositivo.

NOTA: O compilador TornadoVM JIT é executado em uma única thread, portanto, é necessário tomar cuidado devido ao potencial esgotamento dos recursos do compilador quando tiver uma carga pesada, assim como vemos no HotSpot.

A etapa final da compilação é realizada por meio de uma invocação de driver OpenCL para compilar o código OpenCL C otimizado e especializado para a plataforma de destino. Por exemplo, se a aplicação for executada em GPUs NVIDIA, esta etapa gera o código PTX correspondente.

Uma vez que o código OpenCL é gerado e compilado, o TornadoVM inicia a aplicação no dispositivo de destino. Para fazer isso, o TornadoVM implanta muitas threads para executar o kernel. A quantidade de threads a serem implantadas depende dos tamanhos de entrada das aplicações e das características do hardware.

Por exemplo, a multiplicação de matriz que mostramos anteriormente é implantada na GPU usando um bloco de 512 por 512 threads. Isso significa que o TornadoVM implementa um bloco de threads de 512x512 na aplicação Java de thread única que foi programada. Se o dispositivo de destino for uma CPU de vários núcleos, o TornadoVM implanta a mesma quantidade de threads que o número máximo de núcleos da CPU disponível.

Uma vez que a execução paralela no dispositivo terminar, o TornadoVM copia os resultados para a memória heap do Java (para torná-la visível para o lado do host por meio do bytecode COPY_OUT) e finalmente, retorna o controle para a thread master na JVM.

Também podemos consultar os bytecodes que o TornadoVM gera para cada aplicação. Por exemplo, o seguinte fragmento de código mostra uma saída simplificada ao executar a multiplicação da matriz com informações de depuração do bytecode do TornadoVM:

$ tornado --printBytecodes qconlondon.MatrixMultiplication 512 tornado

vm: COPY_IN [F@3e694b3f on NVIDIA -- GeForce GTX 1050
vm: COPY_IN [F@397fbdb on NVIDIA -- GeForce GTX 1050
vm: COPY_IN [F@33d512c1 on NVIDIA -- GeForce GTX 1050
vm: LAUNCH task s0.t0-matrixMultiplication on NVIDIA -- GeForce GTX 1050
vm: STREAM_OUT_BLOCKING [F@33d512c1 on NVIDIA -- GeForce GTX 1050

O método de multiplicação de matrizes que apresentamos anteriormente recebe três parâmetros (matrizes A, B e C). Para cada variável, o TornadoVM realiza uma transferência de dados do host para o dispositivo (usando o COPY_IN). Em seguida, executa a aplicação usando o bytecode LAUNCH.

Só para lembrar, na primeira vez que o LAUNCH é executado, o TornadoVM invoca o compilador OpenCL JIT, no qual o código é especializado e otimizado para o dispositivo. Por fim, o TornadoVM realiza uma cópia (STREAM_OUT_BLOCKING) do dispositivo para os hosts principais para obter os resultados.

Analisando o código gerado do OpenCL

Vamos mergulhar no kernel do OpenCL que o TornadoVM gera. Com o TornadoVM, podemos depurar e verificar o kernel gerado usando o sinalizador --printKernel da seguinte maneira:

$ tornado --printKernel qconlondon.MatrixMultiplication 512 tornado

O TornadoVM gera um kernel por tarefa dentro de um agendador de tarefa. Além disso, gera um kernel chamado lookupBufferAddress, que é executado durante o bootstrap da VM. A razão por trás desse kernel é que o TornadoVM aloca apenas um grande buffer que atua como uma memória heap no dispositivo de destino. Para fazer isso, ele precisa de um ponteiro válido que será usado como um endereço base do dispositivo de destino no qual o TornadoVM pode realizar transferências de dados. O kernel lookupBufferAddress retorna este ponteiro base.

O segundo kernel corresponde ao código OpenCL dos métodos Java que aceleramos. O fragmento de código a seguir mostra uma simplificação do kernel gerado com comentários sobre os principais pontos do código Java e do OpenCL. Observe que o kernel gerado pode ser diferente dependendo da arquitetura de destino. Observe também que TornadoVM gera o código OpenCL C a partir da representação Static Single Assignment (SSA), em que cada variável é atribuída exatamente uma vez. Isso ocorre porque o TornadoVM é uma extensão do Graal-IR, que funciona em uma representação SSA (assim como o compilador JIT convencional do HotSpot, C2).

__kernel void lookupBufferAddress(...parameters) {
  __global ulong *_frame = (__global ulong *) &_heap_base[_frame_base];
  _frame[0]  =  (ulong) _heap_base;
}

__kernel void matrixMultiplication(...parameters) {
   // Declaração das variáveis 

  // Acesso ao stack-frame
  __global ulong *_frame = (__global ulong *) &_heap_base[_frame_base];
  // Acesso aos elementos dentro do stack-frame
  ul_0  =  (ulong) _frame[6];   // Endereço base do input da matriz A
  ul_1  =  (ulong) _frame[7];   // Endereço base do input da matriz B
  ul_2  =  (ulong) _frame[8];   // Endereço base do input da matriz C
  i_3  =  get_global_id(1);     // Indexando o Parallel OpenCL (Segunda dimensão)
  i_4  =  i_3;
  for(;i_4 < 512;)  {
    i_5  =  get_global_id(0);   // O Parallel OpenCL idexando (Primeira dimensão)
    i_6  =  i_5;
    for(;i_6 < 512;)    {
      i_7  =  i_4 << 9;
      f_8  =  0.0F;
      i_9  =  0;
      for(;i_9 < 512;)      {
        i_10  =  i_9 + 1;
        i_11  =  i_7 + i_9;
        l_12  =  (long) i_11;
        l_13  =  l_12 << 2;
        l_14  =  l_13 + 24L;                  // Pulando o header do objeto Java
        ul_15  =  ul_0 + l_14;
        f_16  =  *((__global float *) ul_15); // Carregando o elemento fora da matriz A
        i_17  =  i_9 << 9;
        i_18  =  i_17 + i_6;
        l_19  =  (long) i_18;
        l_20  =  l_19 << 2;
        l_21  =  l_20 + 24L;
        ul_22  =  ul_1 + l_21;
        f_23  =  *((__global float *) ul_22);// Carregando o elemento dora da matriz B

        f_24  =  fma(f_16, f_23, f_8);       // Computando (fuse-multiple-add)
        f_8  =  f_24;
        i_9  =  i_10;
      }
      i_25  =  i_6 + i_7;
      l_26  =  (long) i_25;
      l_27  =  l_26 << 2;
      l_28  =  l_27 + 24L;
      ul_29  =  ul_2 + l_28;
      *((__global float *) ul_29)  =  f_8;    // Guardando o resultado na Matriz C
      i_30  =  get_global_size(0);
      i_31  =  i_30 + i_6;
      i_6  =  i_31;
    }
    i_32  =  get_global_size(1);
    i_33  =  i_32 + i_4;
    i_4  =  i_33;
  }
}

Como o TornadoVM está sendo usado?

Neste artigo, focamos em um exemplo simples, a multiplicação de matrizes, para mostrar diferentes partes do tempo de execução do TornadoVM e do compilador JIT de maneira simples. No entanto, com o TornadoVM, podemos programar mais do que apenas uma única tarefa, com tipos de dados simples. O TornadoVM foi usado para acelerar aplicações SLAM (Simultaneous Localization and Mapping) com o Microsoft Kinect Fusion, acelerando até 90 frames por segundo de aceleração em comparação com o Java em GPUs NVIDIA. Esta aplicação contém cerca de 7 mil linhas de código Java que são aceleradas com o TornadoVM e destaca a complexidade das construções Java que o TornadoVM é capaz de gerar.

No geral, o TornadoVM é adequado para acelerar cargas de trabalho que seguem o padrão SIMD (Single Instruction Multiple Data) e aplicações de pipeline. Surpreendentemente, essa categorização inclui uma ampla variedade de aplicações, de deep learning, machine learning, simulações matemáticas e físicas, fotografia computacional, visão computacional, aplicações financeiras, processamento de sinais e química.

Além disso, os desenvolvedores podem utilizar o TornadoVM em Python, R, Ruby, Javascript ou qualquer outra linguagem em cima do GraalVM (como foi mostrado na QCon-Londres, onde foi acelerado uma aplicação Node.js).

O TornadoVM nasceu na Universidade (e atualmente está em desenvolvimento na Universidade de Manchester), mas já existem algumas empresas utilizando-o para acelerar aplicações de deep learning.

Um exemplo é a Exus Ltd., uma empresa de tecnologia com sede em Londres que está atualmente melhorando o sistema UK NHS (o SUS do Reino Unido) para prever o número de readmissões hospitalares de pacientes, o que melhorou com sucesso, o desempenho da fase de treinamento de um conjunto de dados de 2 milhões de pacientes em 14 vezes, usando o TornadoVM.

Outro exemplo de adoção inicial do TornadoVM na indústria é a NEUROCOM de Luxemburgo, que está usando o TornadoVM para GPUs para acelerar alguns cálculos importantes usados no processamento de linguagem natural em 10x e 28x (especificamente, a distância de Levenshtein e a classificação hierárquica usando algoritmos métricos de similaridade de cosseno, respectivamente) .

Resumo

O TornadoVM é um plugin para OpenJDK e GraalVM que permite aos desenvolvedores colocar aplicações JVM offline em hardware heterogêneo, incluindo CPUs multi-core, GPUs e FPGAs. Além disso, o TornadoVM realiza a migração de tarefas em tempo de execução entre dispositivos para maximizar o desempenho de todas as aplicações. Este artigo explorou a funcionalidade do TornadoVM por meio de um exemplo. Nós exploramos como o TornadoVM é executado e descobrimos como o é código gerado.

Este artigo apenas mostra a superfície do que é o TornadoVM e o que ele pode fazer. Existem muitos tópicos importantes que nós não poderíamos cobrir neste artigo introdutório. Por exemplo, a descrição das especializações do compilador por arquitetura, como executar computações de redução de forma eficiente, o pipeline de compilação FPGA e as migrações de tarefas em tempo de execução. Podemos encontrar mais informações sobre alguns desses tópicos, entrando nos links abaixo:

Referências

Agradecimentos

O desenvolvimento do TornadoVM tem suporte parcial da European Union's Horizon 2020 E2Data 780245.

Sobre o Autor

Juan Fumero é pós-doutorando na University of Manchester. Seus tópicos de pesquisa são Máquinas Virtuais Heterogêneas de Linguagens de Alto Nível, GPGPUs e computação distribuída. Atualmente, está trabalhando como parte dos projetos europeus TornadoVM e E2Data para trazer a compilação e execução automática de GPU e FPGA JIT para programas Java. Recebeu o título de Ph.D. da Universidade de Edimburgo em Aceleração de Linguagens de Programação Interpretadas em GPUs para Java, R e Ruby. Além disso, trabalhou como estagiário no Oracle Labs e no CERN, implementando compiladores e avaliando técnicas de paralelismo para sistemas multi-core.

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

BT