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.
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.
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 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:
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:
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.