Accueil Informatique Créer un module Node.js natif pour MPI


Créer un module Node.js natif pour MPI


De nos jours, le moindre PC possède plusieurs coeurs de calcul. Dans le cadre de la modélisation météo, particulièrement gourmande en calculs, le calcul sur plusieurs processeurs en parallèle est indispensable pour obtenir des résultats plus rapidement.

La programmation parallèle peut être implémentée de plusieurs manière. Tout d’abord, il est possible d’écrire des boucles exploitant plusieurs threads au sein du même processus. Cette solution reste toutefois limitée à une seule et même machine. Elle nécessite de plus une bonne gestion des verrous mémoire, ce qui peut introduire une certaine complexité.

Il est également possible de répartir les calculs entre plusieurs instances du même programme, qui peuvent être lancées sur plusieurs machines distinctes constituant un cluster. Mais cette fois, il faut gérer une communication entre les processus pour les synchroniser.

Node.js est par nature mono-thread, mais rien n’empêche d’en lancer plusieurs instances. La solution du calcul en cluster est donc tout naturellement retenue. Nous utiliserons l’API standard de l’industrie pour le calcul parallèle : MPI (Message Passing Interface).

MPI est une API en langage C, qui n’est donc pas accessible en Javascript. Qu’à cela ne tienne : Node.js permet d’implémenter des modules natif. Nous pouvons créer un module qui ne fera que rendre utilisable les fonctions MPI dont nous aurons besoin. C’est tout l’objet de cet article : comment implémenter un module wrapper pour rendre MPI utilisable dans nos programmes node.js.

Création du module natif de base

Les modules natifs ne sont pas très compliqués à coder, cela se fait en C++. Il faut donc commencer par installer le compilateur et le package MPI si ce n’est déjà fait. Voici la commande pour une distribution linux basée sur debian / ubuntu :

# sudo apt-get install g++ mpi

On suppose également que le projet Javascript est géré avec npm. Il faut installer les modules GYP et la N-API à votre projet existant:

# npm install node-gyp --save-dev 
# npm install node-addon-api 

Configurons maintenant le projet. Vous devrez ajouter des lignes à votre fichier package.json pour ajouter les commandes de script de compilation du module natif. Ajoutez au besoin la section script à votre fichier :

 {
  "name": "pifo",
  "version": "1.0.0",
  "description": "",
  "main": "pifo.js",
// ...........
  "scripts": {
    "build": "node-gyp rebuild",
    "clean": "node-gyp clean"
  },
// ...........
}

Nous devons maintenant indiquer comment compiler notre module en modifiant le binding.gyp :

 {
    "targets": [{
        "target_name": "nodempi",
        "cflags!": [ "-fno-exceptions" ],
        "cflags_cc!": [ "-fno-exceptions"  ],
        "ldflags": [
            
        ],
        "sources": [
            "nodempi.cpp"
        ],
        'include_dirs': [
            "<!@(node -p \"require('node-addon-api').include\")",
            "/usr/lib/x86_64-linux-gnu/openmpi/include"
        ],
        'libraries': ["-lmpi", "-lmpi_cxx"],
        'dependencies': [
            "<!(node -p \"require('node-addon-api').gyp\")"
        ],
        'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ]
    }]
} 

C’est en quelque sorte le Makefile façon GYP. Nous définissons ainsi une cible de module appelée “nodempi”, donc la liste des sources contient le fichier src/nodempi.cpp. On retrouve ensuite les flags de compilation classiques pour le compilateur C++, notamment la liste des includes, ainsi que les librairies à lier.

Il nous faut maintenant passer au codage du module. Nous aurons besoin de deux fichier. Tout d’abord nodempi.h :

#include <napi.h>

namespace nodempi {
    void Init(const Napi::CallbackInfo& info);
    void Finalize(const Napi::CallbackInfo& info);    

    Napi::Object InitModule(Napi::Env env, Napi::Object exports);
} 

Puis nodempi.cpp :

#include <mpi.h>
#include "nodempi.h"

void nodempi::Init(const Napi::CallbackInfo& info)
{    
    Napi::Env env = info.Env();
    int ret = MPI_Init(NULL, NULL);
    if (ret!=MPI_SUCCESS) {
        Napi::Error::New(env, "MPI error : "+ret).ThrowAsJavaScriptException();
        return;
    } 
}

void nodempi::Finalize(const Napi::CallbackInfo& info)
{    
    MPI_Finalize();
}

Napi::Object InitModule(Napi::Env env, Napi::Object exports) 
{
    exports.Set("Init", Napi::Function::New(env, nodempi::Init));  
    exports.Set("Finalize", Napi::Function::New(env, nodempi::Finalize));
   return exports;
}
NODE_API_MODULE(nodempi,  InitModule)

Nous pouvons maintenant compiler notre module :

# npm run build

Si la compilation s’est bien passée, alors nous pouvons tester ce module dans un fichier js. Créons un script nommé index.js :

const MPI = require('./build/Release/nodempi');
MPI.Init();
console.log("MPI initialized.");
MPI.Finalize();

Remarquez que nous importons notre module natif avec une instruction require classique. Le module est compilé dans build/Release. Puis exécutons-le :

# mpirun node index.js
MPI initialized.

A ce stade, nous avons un programme MPI minimal écrit en javascript. Celui-ci initialise MPI, puis termine la “session” MPI. Si une erreur survient, une exception Javascript sera générée. Autrement un simple message est affiché par chaque processus. Par défaut, la commande “mpirun” lance autant de processus node que de coeurs sur la machine.

Les bases de la N-API

Il existe plusieurs API pour créer un module natif node.js. Nous utiliserons la N-API, qui est basée sur des classes C++ fournissant un certain degré d’abstraction sur les internes de Node. Elle est donc assez simple à utiliser.

Nous déclarons trois fonctions dans le namespace nodempi. Le module en lui-même est déclaré par la macro NODE_API_MODULE, qui prend en paramètre le nom du module et la fonction InitModule que déclarée préalablement.

La fonction InitModule sert à définir les exports. Ici, nous créeons les fonctions Init et Finalize de notre wrapper.

La fonction Init appelle la fonction native MPI_Init, et teste le résultat. L’objet CallbackInfo stocke toutes les informations relatives à l’appel de notre fonction. Nous verrons plus tard qu’il permet d’obtenir les paramètres, mais pour le moment il nous permet d’obtenir l’instance de l’objet Env représentant l’environnement d’exécution Node.

Cet objet Env est central, il sert de référence à de nombreuses fonction et objets de la N-API. On le retrouve notamment dans le code de notre fonction InitModule. Dans la fonction Init, nous nous en servons pour générer une exception Node.js en cas d’erreur à l’initialisation de MPI.

La fonction Finalize est plus simple, elle se contente d’appeler la fonction MPI_Finalize.

Retourner des valeurs depuis des fonctions

Passons maintenant à quelque chose de plus utile. Nous allons afficher la taille de notre “communicateur” MPI, ainsi que notre rang dans celui-ci. Deux fonctions MPI sont prévues pour cela : MPI_Comm_Size et MPI_Comm_Rank. Nous allons mapper ces deux fonctions, qui retourneront chacune un entier. Commençons par les déclarer dans notre fichier nodempi.h :

Napi::Number CommSize(const Napi::CallbackInfo& info);
Napi::Number CommRank(const Napi::CallbackInfo& info);

Puis le code de ces fonctions dans nodempi.cpp :

Napi::Number nodempi::CommSize(const Napi::CallbackInfo& info)
{    
    Napi::Env env = info.Env();
    int world_size;
    int ret = MPI_Comm_size(MPI_COMM_WORLD, &world_size);   
    if (ret!=MPI_SUCCESS) { 
        Napi::Error::New(env, "MPI error : "+ret).ThrowAsJavaScriptException(); 
        return env.Null().ToNumber(); 
    }    
    return Napi::Number::New(env, world_size);
}

Napi::Number nodempi::CommRank(const Napi::CallbackInfo& info)
{    
    Napi::Env env = info.Env();
    int world_rank;
    int ret = MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);
    if (ret!=MPI_SUCCESS) { 
        Napi::Error::New(env, "MPI error : "+ret).ThrowAsJavaScriptException(); 
        return env.Null().ToNumber(); 
    }
    return Napi::Number::New(env, world_rank);
}

Javascript encapsule toute valeur numérique dans un objet Number. Pour renvoyer la variable world_size renseignée par l’appel à la fonction MPI_Comm_Size, nous devons donc créer un objet Napi::Number. Notez également le traitement d’erreur. Les exceptions sont “flaggées” au niveau de l’environnement, mais la fonction n’est pas interrompue. Il faut que la fonction retourne, et de par sa définition, elle doit renvoyer un Napi::Number : nous créons donc une valeur null.

Le fonctionnement est similaire pour la fonction CommRank. Ajoutons également les instructions pour exposer ces deux nouvelles fonctions dans notre InitModule :

Napi::Object InitModule(Napi::Env env, Napi::Object exports) 
{
    exports.Set("Init", Napi::Function::New(env, nodempi::Init));  
    exports.Set("Finalize", Napi::Function::New(env, nodempi::Finalize));
    exports.Set("CommSize", Napi::Function::New(env, nodempi::CommSize));  
    exports.Set("CommRank", Napi::Function::New(env, nodempi::CommRank));

   	return exports;
}

Nous pouvons maintenant compiler et tester dans un bout de code javascript.

const MPI = require('./build/Release/nodempi');
MPI.Init();
var size = MPI.CommSize();
var rank = MPI.CommRank();
console.log(`MPI intitialized. Rank : ${rank}/${size}`);
MPI.Finalize();

Chaque processus affiche maintenant son rang dans le “monde” MPI :

# mpirun node index.js
MPI initialized. Rank 1 of 4
MPI initialized. Rank 0 of 4 
MPI initialized. Rank 2 of 4 
MPI initialized. Rank 3 of 4 

Passage de paramètres

Les paramètres passés depuis le code Javascript aux fonctions natives sont encapsulés dans l’objet info. Le nombre de paramètres passés est accessible au travers de la méthode info(), qu’il est conseillé de tester. Javascript ne vérifiera pas pour vous que tous les paramètres sont bien passés. Voici comment obtenir ce nombre :

info.length()

On accède aux paramètres en indexant l’objet info, le premier paramètre ayant l’indice 0. Là encore, il est conseillé de vérifier le typage à l’aide de l’une des méthodes suivantes :

info[0].IsNumber()
info[0].IsString()
info[0].IsBoolean()
info[0].IsObject()
info[0].isBuffer()
info[0].IsArray()
info[0].IsTypedArray()
// Liste non exhaustive

Enfin, une fois le paramètre validé, on peut obtenir la valeur en transtypant vers un objet N-API du type attendu grâce à la méthode template As<type>(). Une fois les objets obtenus, on a accès à un certain nombre de méthodes pour accéder à la valeur. Voici quelques exemples qui parlerons mieux qu’un long discours :

// Obtenir un entier à partir d'un Number
int blocklength = info[0].As<Napi::Number>().Int32Value();

// Obtenir tableau typé d'entier 32 bits
Napi::Int32Array send_counts = info[1].As<Napi::Int32Array>();
int* displs = senc_counts.Data();

Nous allons nous servir de ce concept pour ajouter un paramètre à nos fonctions CommSize et CommRank. Nous avions codé en dur le communicateur MPI_COMM_WORLD, qui permet d’obtenir les informations pour l’ensemble des processus du cluster. MPI permet de diviser le cluster en sous-groupes appelés communicateurs. Si nous voulons implémenter des algorithmes plus complexes, nous devrons pouvoir créer des communicateurs et les passer en paramètre des fonctions MPI. Cet exemple nous permettra de manipuler des données non triviales, autres que des nombres ou des chaînes de caractères.

La déclaration de nos fonctions ne change pas : en effet, les fonctions natives ont toujours un seul paramètre de type CallbackInfo&. Nous allons modifier l’implémentation et ajouter une gestion d’un paramètre. Les deux fonctions C MPI_Comm_Size et MPI_Comm_Rank prennent comme paramètre un MPI_Comm. Pas de chance, ce n’est pas un type de données basique reconnu par Javascript… Heureusement, Node reconnaît le type Buffer, qui est un objet capable d’encapsuler des données brutes de n’importe quelle taille. Voyons comment nos deux fonctions sont modifiées :

Napi::Number nodempi::CommSize(const Napi::CallbackInfo& info)
{    
    Napi::Env env = info.Env();
    if (info.Length() < 1) { 
        Napi::Error::New(env, "1 parameter expected").ThrowAsJavaScriptException(); 
        return env.Null().ToNumber(); 
    }
    if (!info[0].IsBuffer()) { 
        Napi::TypeError::New(env, "Param 1 : Buffer expected").ThrowAsJavaScriptException(); 
        return env.Null().ToNumber(); 
    }
    
    Napi::Buffer<MPI_Comm> comm = info[0].As<Napi::Buffer<MPI_Comm>>();
    int world_size;
    
    int ret = MPI_Comm_size(*comm.Data(), &world_size);
    
    if (ret!=MPI_SUCCESS) { 
        Napi::Error::New(env, "MPI error : "+ret).ThrowAsJavaScriptException(); 
        return env.Null().ToNumber(); 
    }
    
    return Napi::Number::New(env, world_size);
}

Napi::Number nodempi::CommRank(const Napi::CallbackInfo& info)
{    
    Napi::Env env = info.Env();
    if (info.Length() < 1) { 
        Napi::Error::New(env, "1 parameter expected").ThrowAsJavaScriptException(); 
        return env.Null().ToNumber(); 
    }
    if (!info[0].IsBuffer()) { 
        Napi::TypeError::New(env, "Param 1 : Buffer expected").ThrowAsJavaScriptException(); 
        env.Null().ToNumber(); 
    
    }
    
    Napi::Buffer<MPI_Comm> comm = info[0].As<Napi::Buffer<MPI_Comm>>();
    int world_rank;
    
    int ret = MPI_Comm_rank(*comm.Data(), &world_rank);
    
    if (ret!=MPI_SUCCESS) { 
        Napi::Error::New(env, "MPI error : "+ret).ThrowAsJavaScriptException(); 
        return env.Null().ToNumber(); 
    }
    
    return Napi::Number::New(env, world_rank);
}

Vous remarquerez que nous avons ajouté un test de l’existence d’un paramètre, et vérifié que son type est bien Buffer. Ensuite, nous récupérons simplement sa valeur sous forme d’un Napi::Buffer<MPI_Comm>. Le Buffer est en effet un template : il suffit de préciser le type de données C++ qui est contenu dedans. Le transtypage avec As() est forcément un peu plus lourd du fait de cette utilisation des templates, mais fonctionne rigoureusement de la même manière. On obtient un pointeur vers la donnée du buffer avec la méthode data(), la donnée étant déréférencée avec l’opérateur de pointeur “*”. Dit comme ça, c’est simple, mais il faut avouer que je m’y suis perdu un moment : l’arithmétique des pointeurs mêlée à celle des templates C++ donne des messages d’erreur absolument abominables qu’il est difficile d’interpréter. Ce n’est donc pas inutile d’en faire un article !

Nous devons maintenant rendre accessible la constante MPI_COMM_WORLD. Nous allons pour cela l’encapsuler dans un Napi::Buffer avec notre fonction InitModule :

Napi::Object InitModule(Napi::Env env, Napi::Object exports) 
{
    exports.Set("Init", Napi::Function::New(env, nodempi::Init));  
    exports.Set("Finalize", Napi::Function::New(env, nodempi::Finalize));
    exports.Set("CommSize", Napi::Function::New(env, nodempi::CommSize));  
    exports.Set("CommRank", Napi::Function::New(env, nodempi::CommRank));

    MPI_Comm* comm = new MPI_Comm;
    *comm = MPI_COMM_WORLD;
    exports.Set("MPI_COMM_WORLD", Napi::Buffer<MPI_Comm>::New(env, comm, 1, MPICommFinalizer));

   	return exports;
}

Nous créons une variable MPI_Comm dynamique, que nous affectons avec la valeur MPI_COMM_WORLD. Nous exposons ensuite un Buffer que nous créons avec la fonction New qui prend 3 paramètres : un pointeur vers les données, le nombre de données, et une fonction finaliseur. Une petite explication s’impose.

Javascript nous décharge de la gestion mémoire. Il s’occupe lui-même de libérer la mémoire quand un objet n’est plus utilisé. En C++, la responsabilité incombe au développeur. La N-API prévoit donc un callback qui sera appelé quand Node s’aperçoit que le Buffer n’est plus utilisé, et qu’il doit être libéré. Cette fonction callback sera chargée de libérer la mémoire que nous avons allouée à sa création. Il nous manque l’implémentation de cette fonction :

void MPICommFinalizer(Napi::Env env, MPI_Comm* c)
{
    delete c;
}

On compile tout ça, et on teste avec un bout de javascript. On passe maintenant en paramètre la constante MPI_COMM_WORLD à nos deux fonctions :

const MPI = require('./build/Release/nodempi');
MPI.Init();
var size = MPI.CommSize(MPI.MPI_COMM_WORLD);
var rank = MPI.CommRank(MPI.MPI_COMM_WORLD);
console.log(`MPI intitialized. Rank : ${rank}/${size}`);
MPI.Finalize();

Le résultat à l’exécution sera le même, mais nous avons ajouté la possibilité de créer et passer en paramètre des “MPI_Comm”.

Conclusion

Dans ce tutoriel, nous avons vu comment nous pouvions implémenter un wrapper de librairie natif dans un programme Node.js. Nous avons fait un tour d’horizon un peu plus complet que les tutoriels habituels en montrant comment utiliser des types de données non triviaux. Bien évidemment, nous n’avons fait qu’effleurer la librairie MPI. Nous avons juste montré comment rendre accessible des objets MPI_Comm, mais nous n’avons pas montré comment les créer avec les fonctions MPI. On laisse cela en exercice au lecteur intéressé, qui pourra se plonger dans la documentation MPI et implémenter le wrapper de fonction adéquat. Il reste ensuite un grand nombre de fonctions à implémenter pour créer des types de données, synchroniser et distribuer des données entre les processus… bref faire des choses utiles. Mais vous avez maintenant les bases pour vous y atteler !