Java Tutorial - Parte 1 0.1
Un tutorial per esempi
Caricamento in corso...
Ricerca in corso...
Nessun risultato
Foreign Function and Memory

Introduzione a FFM

La "AI Overview" di Google™ recita così:

La API Foreign Function and Memory (FFM) consente ai programmi Java di interagire con codice e dati esterni al runtime Java. Questa API consente ai programmi Java di richiamare librerie native ed elaborare dati nativi senza la fragilità e i pericoli di JNI. La API richiama funzioni esterne, codice esterno alla JVM, e accede in modo sicuro alla memoria esterna, ovvero alla memoria non gestita dalla JVM.

Non so proprio da quale universo la A.Ii abbia potuto prendere il testo "senza la fragilità e i pericoli di JNI" perchè, come vedremo in seguito, il codice nativo è intrinsecamente fragile, non c'è nulla da fare a tal proposito, vedi Il crash della applicazione

Il package FFM

Il package javatutor.primes.crivello.ffm contiene i sorgenti in linguaggio "C" e Java per interfacciare le classi Java coi metodi nativi e scritti in linguaggio "C" usando la API FFM. Useremo il linguaggio "C" per allocare ed elaborare il setaccio in modo da avere più velocità (si spera) e praticamente nessuna restrizione sulle sue dimensioni salvo, ovviamente, la quantità di memoria libera a disposizione sul computer.

Contenuto del package

Di eguito l'elenco dei files che compongono il package FFM:

Nome del file Descrizione
setaccioffm.h file header della libreria nativa
setaccioffm.c file sorgente della libreria nativa
makefile.txt file per il build della libreria nativa
README.txt istruzioni per il build della libreria nativa
CrivelloFFM.java La prima implementazione del crivello in FFM
CrivelloFFMby.java Estrazione a livello byte dal setaccio nativo
CrivelloFFMar.java Estrazione con segmento di memoria dal setaccio nativo

I problemi di JNI

A partire da Java versione 22 è stata introdotta una nuova API (Application Program Interface) per migliorare il collegamento tra i metodi Java e le funzioni scritte in linguaggio nativo.
Come abbiamo potuto osservare in Java Native Interface, la JNI soffre di alcuni problemi piuttosto irritanti, a parte il discorso delle prestazioni deludenti. I problemi maggiori con JNI possono essere riassunti nei seguenti:

  • l'interfaccia è piuttosto macchiavellica
  • la JNI è davvero poco performante
  • costringe il programmatore Java a disporre dei sorgenti della libreria nativa
  • costringe il programmatore "C" ad essere consapevole di Java

JNI è macchiavellica

Uno dei principali problemi di JNI è la sua macchinosità che costringe ad usare nomi di funzioni e tipi di dato specifici oltre alla necessità di fornire al programmatore "C" gli header Java per la particolare piattaforma. Prendiamo ad esempio uno dei prototipi delle funzioni "C" native generati automaticamente dal compilatore Java:

JNIEXPORT jlong JNICALL Java_javatutor_primes_crivello_jni_CrivelloJNI_setacciaJNI
(JNIEnv *, jobject, jlong);

Benchè il programmatore "C" potrebbe semplicemente ignorare le macro JNIEXPORT e JNICALL poichè risolte dai files header che il programmatore Java fornisce, un prototipo come questo introduce una macchinosità inutile nella maggior parte delle librerie native.
Anche i due parametri iniziali alle funzioni native non hanno molto senso di esistere dal momento che non vengono quasi mai usati: il progrmmatore "C" non ha alcuna conoscenza di essi e, di fatto, finirà per non usarli mai.

JNI ed i sorgenti "C"

Poichè la libreria nativa deve essere compilata con i nomi di funzione e numero e tipo di argomenti dichiarati nel prototipo della funzione, è necessario ricompilare i sorgenti della libreria nativa e questo ci costringe ad avere accesso ai files sorgente.

Poco male se la libreria nativa viene scritta da noi stessi o da un nostro amico ma, nel mondo reale, le librerie native vengono di solito fornite in binario: solo il file header viene fornito a corredo della libreria in modo da poterla usare.
Non solo: è prassi comune per i programmatori "C" inserire la documentazione delle funzioni nel file header e non nel sorgente che implementa le funzioni. Tuttavia, questo non è possibile con JNI dal momento che il file header è generato automaticamente e quindi sovrascritto ad ogni compilazione Java.

Per intenderci, questo è uno spezzone del file header della libreria nativa setaccioffm in cui viene dichiarata la funzione nativa setaccia:

/*
* Function: setacciaFFM
* Description: alloca il setaccio su base bit ed esegue la operazione
* di setacciamento per tutti i multipli fino a 'limit'.
* Solo i numeri dispari vengono setacciati.
* Il setaccio ha sempre un limite minimo di 8.192 poichè
* vengono allocati minimo 512 bytes che contengono 8 numeri dispari.
* Param: 'limit' il numero più grande da setacciare
* Return: - se maggiore di ZERO, il limite del setaccio che non può essere minore
* del limite fornito come argomento
* - se minore di ZERO indica un codice di errore:
* -1 = memoria esaurita
* -2 = 'size_t' è a 32 bits, inutile nel contesto del crivello nativo
*/
long long setacciaFFM ( long long limit );

Questo è il tipico contenuto di un file header in linguaggio "C", molto diverso da quello ottenuto automaticamente da Java.

JNI ed i tipi di dato specifici

Infine, il fatto che vengano definiti tipi di dato specifici per interfacciare Java e le funzioni "C" potrebbe mettere a disagio il programmatore "C" che non è abituato a simili definizioni e non solo; potrebbe essere fonte di grossolani errori logici.
Prendiamo ad esempio il tipo di dato jlong che definisce un intero a 64 bits e che corrisponde al tipo di dato java.lang.Long ed al tipo primitivo long. In linguaggio "C" il tipo long esiste ma nella maggior parte delle piattaforme esso ha una ampiezza di 32 bits e non 64 bits. Per il linguaggio "C" un intero a 64 bits viene indicato con il tipo di dato long long (due volte long) e pertanto è piuttosto facile scambiare una variabile jlong con una long, per esempio in un ciclo for, ma sarebbe un grossolano errore.

I segmenti di memoria

A differenza di JNI, la nuova API FFM consente al programmatore Java di eseguire le funzioni native in uno spazio di memoria nativo, non controllato dalla JVM e, quindi, non soggetto al garbage collector. Vi sono due tipi di allocazione dei segmenti di memoria nativi:

  • la memoria on-heap
  • la memoria off-heap

Memoria On-heap e Off-heap

La memoria on-heap è la memoria dinamica assegnata a Java nel momento in cui la JVM viene eseguita: essa viene allocata quando si creano nuove istanze di una classe attraverso l'operatore new e liberata automaticamente dal garbage collector. Lo heap può aumentare o diminuire durante l'esecuzione dell'applicazione. Quando l'heap si riempie, viene eseguita la garbage collection: la JVM identifica gli oggetti che non vengono più utilizzati (oggetti non raggiungibili) e ne ricicla la memoria, liberando spazio per nuove allocazioni.

La memoria off-heap è la memoria esterna allo heap di Java. Per invocare una funzione o un metodo da un linguaggio diverso, come il "C", da un'applicazione Java, i suoi argomenti devono trovarsi nella memoria off-heap. A differenza della memoria on-heap, la memoria off-heap non è soggetta alla garbage collection. È però possibile controllare come e quando la memoria off-heap viene deallocata.
La interazione con la memoria off-heap avviene tramite un oggetto MemorySegment. A un oggetto MemorySegment viene associata una Arena, che consente di specificare quando la memoria off-heap viene deallocata.

La memoria e la Arena

È possibile accedere alla memoria off-heap e/o on-heap con l'API FFM tramite l'interfaccia MemorySegment che rappresenta un segmento di memoria ed è associato, o per meglio dire "supportato" (in inglese si dice backed by), da una regione di memoria contigua. Esistono due tipi di segmenti di memoria:

  • segmento heap: si tratta di un segmento di memoria supportato da una regione di memoria all'interno dell'heap Java, come per esempio una array Java; questo segmento di memoria viene gestito dal garbage collector
  • segmento nativo: si tratta di un segmento di memoria supportato da una regione di memoria esterna, una regione off-heap che viene allocata in modo nativo e passata a Java come un indirizzo di memoria (in linguaggio "C" si dice, un puntatore).

La interfaccia Arena controlla il ciclo di vita dei segmenti di memoria nativi. Per creare una arena, utilizzare uno dei metodi della interfaccia stessa, come per esempio:

Arena arena = Arena.ofConfined();

il metodo ofConfined crea una arena confinata in cui tutti i segmenti di memoria ad essa associati hanno un periodo di vita limitato e deterministico. Il suo ambito è attivo dal momento della creazione fino alla sua chiusura. Una arena confinata ha anche un thread proprietario che è, in genere, il thread che l'ha creata. Solo il thread proprietario può accedere ai segmenti di memoria allocati in un'arena confinata. Si otterrà un'eccezione se si tenta di chiudere un'arena confinata con un thread diverso da quello proprietario.

Le funzioni native e gli handles

Per richiamare una funzione nativa la API FFM utilizza un approccio completamente diverso rispetto a JNI: non c'è alcun bisogno di dichiarare il metodo nativo nella classe Java poichè in FFM si usa il linker stesso per creare un oggetto Java che contiene nei suoi membri:

  • l'indirizzo (address) di memoria della funzione nativa
  • la firma (signature) della funzione nativa cioè i suoi argomenti e valore di ritorno
  • i metodi per eseguire la funzione nativa ed ottenerne il risultato

L'oggetto che si deve creare per richiamare una funzione nativa è il MethodHandle il quale contiene nei suoi membri dati tutte le tre informazioni di cui sopra.

Il linker nativo

La interfaccia Java Linker è responsabile di provvedere all'accesso delle funzioni native da parte di Java. Le funzioni native devono essere inserite in una libreria dinamica (una DLL in Windows, uno SharedObject in Linux) compilata secondo le convenzioni di chiamata specifiche per la piattaforma per la quale il linker è stato scritto.

Prima di otterere un MethodHandle da usare per richiamare la funzione nativa è necessario ottenere il linker e caricare la libreria dinamica in memoria:

// crea una arena confinata: viene gestita dalla JVM
arena = Arena.ofConfined();
// ottiene il linker nativo
Linker linker = Linker.nativeLinker();
// carica la tabella dei simboli della libreria nativa
SymbolLookup lib = SymbolLookup.libraryLookup( "setaccioffm", arena);

Il caricamento della tabella dei simboli della libreria nativa è necessario per risolvere l'indirizzo di memoria di ogni singola funzione presente nella libreria nativa: il collegamento avviene a runtime, in modo dinamico.

L'indirizzo della funzione

Prendiamo come esempio la funzione nativa setacciaFFM il cui prototipo è il seguente:

long long setacciaFFM( long long limit );

la funzione accetta un argomento di tipo java.lang.Long che è il numero più grande da setacciare e restituisce il limite del setaccio allocato che è grande almeno quanto il limite fornito come argomento. Anche il valore di ritorno è di tipo java.lang.Long che, è bene rammentarlo, non corrisponde affatto al tipo di dato long in "C" bensì al tipo long long (= 64 bits).
Dopo aver creato il linker e caricato la tabella dei simboli, possiamo ottenere l'indirizzo di memoria della funzione setacciaFFM in questo modo:

MemorySegment address = lib.find( "setacciaFFM" ).get();

La firma della funzione

Che altro serve, oltre all'indirizzo di memoria, per richiamare una funzione nativa? Ebbene, non servirebbe niente altro ma ... Java è un linguaggio sicuro, fortemente tipizzato, e ci consente di avere un forte controllo dei richiami nativi in modo da non commettere errori critici.

Cosa succederebbe se da un metodo Java richiamassi la funzione nativa passandovi come argomento una variabile di tipo java.lang.Integer anzichè un java.lang.Long? Forse nulla, forse otterrei solo risultati errati ma forse potrei anche rischiare che la JVM vada in crash!
Per questo motivo, Java vuole essere informato sulla firma della funzione nativa e cioè quanti argomenti e di quale tipo dovranno essere forniti al momento del richiamo. Se per errore forniamo argomenti errati verrà sollevata una eccezione.

La firma viene descritta attraverso i due metodi statici della interfaccia FunctionDescriptor

  • FunctionDescriptor.of(...) per le funzioni native che restituiscono un valore
  • FunctionDescriptor.ofVoid(...) per le funzioni native che restituiscono void, che corrisponde al null di Java
FunctionDescriptor signature = FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG );

Il primo argomento al metodo FunctionDescriptor.of(...) è il tipo del valore di ritorno; nel caso della funzione setacciaFFM è un long long mentre tutti gli altri argomenti descrivono il tipo degli argomenti da passare alla funzione nativa.
Tutti i tipi dei linguaggi nativi vengono mappati in costanti mnemoniche Java che derivano dalla interfaccia ValueLayout. Nella tabella seguente trovate la associazione tra i tipi di dato nativo ed la corrispondente costante mnemonica ValueLayout da indicare nei metodi of e ofVoid:

tipo dato "C" costante Java NOTE
bool ValueLayout.JAVA_BOOLEAN
char ValueLayout.JAVA_BYTE
short ValueLayout.JAVA_SHORT
int ValueLayout.JAVA_INT
long ValueLayout.JAVA_INT / LONG dipende dalla piattaforma
long long ValueLayout.JAVA_LONG
size_t ValueLayout.ADDRESS dipende dalla piattaforma

Vi sembrerà strano ma in linguaggio "C" alcuni tipi di dato non hanno una ampiezza predefinita ma, piuttosto, dipendente dalla piattaforma hardware/software su cui i programmi sono compilati. In particola modo, il tipo di dato long, in "C", potrebbe avere una ampiezza di 32 oppure di 64 bits dipendente dalla piattaforma. Un caso particolare di tipo di dato in "C" è il size_t che rappresenta un indirizzo oppure una dimensione di una zona di memoria, espressa in numero di bytes: size_t ha una ampiezza di 64 bits su piattaforme a 64-bits mentre sulle piattaforme a 32-bits esso è ampio 32 bits.
Se la ampiezza di questi tipi di dato è una informazione critica per la applicazione Java è possibile ottenerne la dimensione in bytes col metodo byteSize:

int width = ValueLayout.ADDRESS.byteSize();

Il richiamo della funzione nativa

Dopo aver ottenuto l'indirizzo di memoria e la descrizione della firma della funzione nativa è possibile ottenerne un handle: un oggetto per mezzo del quale possiamo in seguito richiamare la funzione nativa:

// ottengo il handle
MethodHandle setacciaHdl = linker.downcallHandle( address, signature );
// richiamo la funzione nativa per setacciare i numeri fino a un-milione
long limit = (long) setacciaHdl.invokeExact( 1000000L );

Il metodo invokeExact richiama la funzione nativa, il cui indirizzo e firma sono memorizzati nell'oggetto setacciaHdl, verificando che gli argomenti passati ad essa corrispondano esattamente, per numero e per tipo, a quelli descritti nel FunctionDescriptor (vedi La firma della funzione). In caso contrario verrà sollevata una eccezione.
Vi sono altre modalità di richiamo nella classe MethodHandle; in alcune di queste modalità, come per esempio, invoke è possibile passare argomenti di tipo diverso a quelli descritti nella firma ma, ovviamente, questo avviene a discapito della sicurezza (programmatore avvisato ... mezzo salvato!).

La libreria nativa

Passiamo ora ad analizzare la libreria nativa ma non preoccupatevi: questo non è un tutorial sul linguaggio "C" e pertanto nessun codice in quel linguaggio sarà discusso. Il lettore verrà istruito soltanto sull'aspetto dell'interfacciamento tra il programmatore Java ed il programmatore "C" e noterà (il lettore) che l'approccio FFM è molto più semplice rispetto a JNI.

Aprite i files sorgente della libreria nativa, che trovate nella cartella del package javatutor.primes.crivello.ffm.

Il file header

Innanzitutto possiamo notare che il file header della libreria nativa è un normalissimo file header in stile "C" a cui i programmatori "C" sono abituati: esso è scritto a mano e non generato automaticamente dal compilatore Java. Il suo nome è setaccioffm.h e si trova nel package javatutor.primes.crivello.ffm.
Tralasciando i commenti alle funzioni native possiamo notare che il file header è piuttosto semplice e contiene solo i prototipi delle funzioni. Poichè la sintassi del "C" è davvero molto simile a quella di Java, non dovrebbe essere difficile per il programmatore Java fornire questo file ad un amico per scrivere la implementazione delle funzioni:

// sono gli stessi metodi astrastti Java
long long setacciaFFM ( long long limit );
bool isCancelledFFM (long long num);
void closeFFM();
long long countFFM( long long start, long long end );
// metodi per l'estrazione dei numeri primi
long long get_byte( long long index );
long long* extract_memseg(long long start, long long end);
long long primes_capacity();

Non dovendo sottostare al rispetto dei nomi di funzione stabiliti da JNI, il programmatore "C" sceglie i nomi più consoni, che permettono comunque di capire al volo la operazione da eseguire ma che non sono lunghi un kilometro.
Il lettore avrà anche notato che le operazioni da svolgere sono praticamente le stesse già viste nel crivello realizzato in Java ed in JNI:

  • si alloca il setaccio e si cancellano i multipli dei numeri primi ( funzione setaccia)
  • si contano o si estraggono i numeri primi se il relativo indice non è stato cancellato nel setaccio (funzione isCancelledFFM)

Le funzioni native altro non eseguono se non la effettiva operazione eseguita dai metodi astratti della classe CrivelloNative (vedi CrivelloNative: metodi astratti).

Anche per l'approccio FFM, abbiamo previsto modalità differenti per il conteggio e per la estrazione dei numeri primi poichè, come abbiamo imparato in Misuriamo la velocità della JNI, le performances della JNI nel caso di estrazione sono notevolmente peggiorate rispetto al solo conteggio.

Un altro aspetto molto diverso di FFM rispetto a JNI è la mancanza di una funzione nativa per l'estrazione dei numeri primi attraverso il metodo Java callback (vedi Estrazione con metodo callback). Non è una dimenticanza ma una precisa strategia: abbiamo già verificato che, di tutti i metodi di estrazione testati, esso è il peggiore ed è inutile sperare di migliorarne le performances usando la API FFM.
Invece, la API FFM ci concede un approccio completamente nuovo: come già accennato in Memoria On-heap e Off-heap , la funzione nativa può restituire un segmento di memoria nativo, cosidetto off-heap, a cui possiamo accedere da Java. Questo ci consente di estrarre tutti i numeri primi dal setaccio nella funzione nativa, una volta per tutte, senza dover continuamente passare da Java al codice nativo; questo dovrebbe, in teoria, migliorare molto le performances.
Le due funzioni native extract_memseg e primes_capacity sono state scritte proprio per questo motivo e saranno analizzate in dettaglio in Estrazione in un segmento di memoria.

Il file sorgente

Il file setaccioffm.c che si trova nel package javatutor.primes.crivello.ffm contiene il sorgente "C" della implementazione delle funzioni descritte nel file header. Non c'è davvero molto da dire su questo aspetto anche perchè il linguaggio "C" non è lo scopo di questo tutorial.
Non solo, ben difficilmente il lettore avrà accesso ai sorgenti delle librerie di terze parti ma questo non significa che non possono essere usate attraverso FFM: quello che conta avere a disposizione è la documentazione ed i prototipi delle funzioni native ma queste informazioni accompagnano sempre una libreria anche se rilasciata in formato binario.

Il vantaggio per il programmatore Java è evidente: non c'è alcun bisogno di avere a che fare con il build, con i makefile e altre diavolerie a cui i programmatori "C" sono purtroppo avezzi.

Il build della libreria

Anche questo aspetto può essere completamente trascurato dal programmatore Java: colui che rilascia la libreria in formato binario si preoccupa del build della libreria nativa usando gli strumenti ai quali è ben abituato che, normalmente, è la utility make di cui potete vederne il makefile sempre nella cartella del package sopracitato. Usando FFM la libreria nativa non deve essere nè scritta nè compilata in modo specifico per l'interfacciamento con Java.
Se la libreria nativa vi è stata fornita in sorgente è abitudine del programmatore "C" scrivere un file di puro testo di nome INSTALL oppure README che contengono le istruzioni per ottenere il file binario. A questo proposito, nella cartella del package già citato troverete proprio uno di questi due files di testo.

La classe principale

La classe CrivelloFFM, derivata da CrivelloNative, è piuttosto semplice:

public class CrivelloFFM extends CrivelloNative
{
protected MethodHandle setacciaHdl, isCancelledHdl, closeHdl, countHdl;
protected Arena arena;
static{
System.loadLibrary( "setaccioffm" );
}

Nei suoi membri dati, la classe dichiara i MethodHandle usati per invocare il richiamo delle funzioni native (vedi Il richiamo della funzione nativa). Questi saranno ricavati nel costruttore della classe.
In un altro membro dati memorizzeremo la Arena (vedi La memoria e la Arena) che controllerà il lifetime (=ciclo di vita) dei segmenti off-heap (vedi Memoria On-heap e Off-heap). Infine, usiamo uno static initializer per caricare la libreria nativa nella JVM (vedi Il caricamento della libreria).

CrivelloFFM: il costruttore

I compiti del costruttore sono quelli di:

CrivelloFFM: il conteggio

Il conteggio dei numeri primi nel setaccio è praticamente sempre lo stesso fin dalla prima implementazione del crivello in Java, a parte alcune differenze in Crivello3 poichè quest'ultimo gestisce solo i numeri dispari:

long counter = 0;
for ( long i = start; i <= end; i++ ) {
if ( !isCancelled(i) ) {
counter++;
}
}

E' il metodo isCancelled che viene sovrascritto nelle classi derivate da Crivello e scritte in Java in modo da indirizzare i singoli numeri naturali nel setaccio.

Per le classi derivate da CrivelloNative abbiamo usato un approccio diverso: per evitare di dover continuamente richiamare la funzione nativa dal codice Java la classe base definisce il metodo astratto nativeCount che provvedere al richiamo della funzione nativa.
Poichè il richiamo è diverso tra JNI e FFM il metodo astratto nativeCount è implementato diversamente in CrivelloFFM rispetto alla controparte JNI:

@Override
protected long nativeCount( long start, long end )
{
long c = -1;
try {
c = (long) countHdl.invokeExact( start, end );
}
catch ( Throwable ex )
{
throw new RuntimeException( "Internal error in CrivelloFFM.nativeCount(): " + ex.getMessage());
}
return c;
}

Mentre in JNI il richiamo della funzione nativa si è risolto con il semplice richiamo del metodo "nativo" countJNI, in FFM questa operazione è più complessa. Innanzitutto osserviamo che il metodo invokeExact può sollevare eccezioni checked: questo ci costringe ad intercettarle e gestirle oppure a dichiararle nella firma del metodo e gestirle nella gerarchia dei metodi chiamanti.

La eccezione di tipo Throwable viene sollevata quando la JVM non è in grado di richiamare il codice nativo per una serie di problemi non meglio specificati: la causa di questi problemi è riportata nella descrizione dell'errore.
In questa piccola applicazione, l'autore ha scelto di non gestire simili problemi e, quindi, essendo comunque costretto a gestire una eccezione checked si è scelto la strada di risollevare una eccezione ma di tipo unchecked che è rappresentata dalla classe RuntimeException. Per un ripasso sulle eccezioni vedi Le eccezioni.

I risultati del test di conteggio

Compilate i sorgenti Java del package:

>javac javatutor\primes\crivello\ffm\*.java

Per poter eseguire il programma, la JVM deve conoscere la ubicazione della libreria dinamica. Questo deve essere specificato con la opzione -D del comando java; questa opzione imposta una proprietà Java nella JVM. Per maggiori informazioni sulle opzioni che possono essere specificate nel comando java vedi: the Java application launcher

Per mezzo della opzione -D impostiamo la proprietà java.library.path che rappresenta il percorso dove la JVM cercherà le librerie dinamiche. Per eseguire il programma di estrazione dei numeri primi da 2 a cento-milioni con algoritmo CrivelloJNI dobbiamo pertanto digitare il seguente comando:

>java -ea -Djava.library.path=./javatutor/primes/crivello javatutor.primes.Performance FFM 100000000

Per maggiori informazioni sulle proprietà della JVM impostabili con la opzione -D vedi System.getProperties()

Riprendiamo la tabella dei test di velocità vista in precedenza (vedi Misuriamo la velocità della JNI) e inseriamo una nuova colonna a destra dove inseriremo i tempi della FFM relativi al conteggio dei numeri primi (M=un-milione, G=un-miliardo):

Limite Count Crivello3 exec JNI FFM
200M 11.078.937 0.9 1.6 0.8
400M 21.336.326 1.9 3.3 1.6
1G 50.847.534 5.3 4.0 8.5 4.2
5G 234.954.233 29.5 23.8 45.4 24.3
10G 455.052.511 62.4 50.3 94.6 49.9
20G 882.206.716 132.5 108.0 197.0 108.8
40G 1.711.955.433 Overflow 235.2 410.8 221.5

Come possiamo notare, FFM è davvero molto veloce arrivando ad eguagliare ed in alcuni casi perfino superare le performances della applicazione nativa (ma su questo ho alcuni dubbi ...). Non vi è alcun dubbio però sul fatto che interfacciando Java con FFM possiamo ottenere entrambi i vantaggi dei due linguaggi:

  • la potenza e flessibilità di Java per il design della applicazione
  • la velocità dei linguaggi nativi quando le circostanze lo impongono e solo per quei piccoli spezzoni di codice che ci servono

Estrazione dei numeri primi

Abbiamo notato in I risultati dei test che usando la JNI la estrazione dei numeri primi dal setaccio è particolarmente lenta, un problema dovuto al forte overhead (=sovraprezzo) da pagare per il continuo andirivieni dal bytecode Java al codice nativo.
Abbiamo anche cercato soluzioni alternative per limitare questo overhead ma senza successo (vedi Interfacciarsi con la JNI).
Ma come se la cava FFM? Ebbene, proviamo le soluzioni:

Estrazione con elaborazione a singolo bit

Questa elaborazione avviene nella classe base CrivelloNative e quindi è condivisa con quella JNI:

@Override
protected void extract( ArrayList<Long> primes,long start, long end )
{
... omissis ...
for ( long i = start; i <= end; i += 2 ) {
if ( !nativeIsCancelled(i) ) {
primes.add( i );
}
}
}

Il metodo astratto nativeIsCancelled è quello che provvede, a seconda della tecnica usata (JNI o FFM) al richiamo effettivo della funzione nativa.
Riprendiamo la tabella dei risultati visti per JNI e ne riportiamo i tempi ottenuti con FFM. Per il confronto, usiamo solo il migliore risultato di JNI quello ottenuto con la estrazione a livello byte:

Limite Count Crivello3 JNIby FFM FFMby FFMar
100M 5.761.455 0.5 0.7 1.2
200M 11.078.937 1.2 1.6 2.6
400M 21.336.326 2.5 3.5 5.0
1G 50.847.534 6.7 9.4 12.7

Estrazione con elaborazione a byte

In modo analogo a quanto avvenuto con JNI, deriviamo la classe CrivelloFFMby da CrivelloFFM e ne sovrascriviamo il metodo extract dal momento che, con questa tecnica, non sarà più richiamato il metodo nativeIsCancelled ma la funzione nativa get_byte che restituisce un intero byte dal setaccio nativo e ci sonsente di elaborare in Java otto numeri dispari:

@Override
protected void extract( ArrayList<Long> primes, long start, long end )
{
... omissis ...
for ( long idx = start >> 4; idx < length; idx++ ) {
long num = (idx << 4)+1; // il numero di partenza del byte
long value = (long) get_byteHdl.invokeExact( idx );
... omissis ...
}

Notate il richiamo della funzione nativa get_byte attraverso l'oggetto get_byteHdl che ne rappresenta il MethodHandle (verdi Il richiamo della funzione nativa).

Riportiamo in tabella i risultati ottenuti con questa nuova tecnica di estrazione:

Limite Count Crivello3 JNIby FFM FFMby FFMar
100M 5.761.455 0.5 0.7 1.2 0.7
200M 11.078.937 1.2 1.6 2.6 1.4
400M 21.336.326 2.5 3.5 5.0 2.7
1G 50.847.534 6.7 9.4 12.7 7.0

I risultati sono praticamente in linea con quelli ottenuti con Java, solo leggermente peggiori ma comunque molto migliori di JNI e questo conferma ancora una volta la efficienza di FFM rispetto a JNI.

Estrazione con elaborazione callback

Se ricordate bene, questa tecnica di estrazione dei numeri primi è quella che ha ottenuto i peggiori risultati in assoluto (vedi I risultati dei test), trascurando quelli ottenuti con il comando esterno (vedi Il processo esterno); ma il comando esterno non si può certo inserire nelle tecniche di interfacciamento tra Java ed i linguaggi nativi dal momento che in questo caso vengono eseguiti sulla macchina due processi distinti.

Un altro evidente svantaggio nell'utilizzo della tecnica callback è che il programmatore "C" deve essere consapevole di Java per poter richiamare un metodo Java dal codice nativo. La tecnica del callback si basa sui cosidetti function's pointers (=puntatori a funzione) molto usati nei linguaggi nativi come "C" e "C++" nei quali il concetto di puntatore è pane quotidiano.

Non credo che usando questa tecnica in FFM potremmo ottenere risultati migliori di quelli avuti con Java, probabilmente guadagneremo qualcosa rispetto a JNI ma... perchè dobbiamo preoccuparci di guadagnare qualche punto percentuale quando abbiamo a disposizione ben altri strumenti. Quello che imparerete nelle prossima sezione.

Estrazione in un segmento di memoria

Uno degli aspetti più interessanti della Foreign Function and Memory API è sicuramente quello in cui una funzione nativa può restituire ad un metodo Java un segmento di memoria nativo, cioè al di fuori del controllo della JVM, attraverso il suo indirizzo in memoria: il cosidetto puntatore, in linguaggio "C".
I puntatori sono la croce e la delizia dei programmatori "C": sono ostici da usare, è difficile tenerne traccia e, se usati in modo errato, sono i responsabili dei tristemente famosi errori conosciuti col nome di segmentation fault e memory leak.

Considerate una array Java:

byte[] array = new byte[10];

essa non è altro che una zona di memoria contigua che si estende dal basso (da indirizzi di memoria più bassi) all'alto (ad indirizzi di memoria più alti). Ogni elemento della array viene acceduto attraverso un indice che parte da ZERO fino alla lunghezza della array meno uno:

indice =>     0  1  2  3  4  5  6  7  8  9
              __ __ __ __ __ __ __ __ __ __
elemento     |__|__|__|__|__|__|__|__|__|__|

Possiamo pertanto immaginare che l'elemento ZERO della array avrà un certo indirizzo di memoria che porremo pari ad X e ne consegue che il secondo elemento della array avrà indirizzo X+1. Se io ottengo l'indirizzo di memoria X del primo elemento e lo dereferenzio (operazione contraria all'ottenimento dell'indirizzo) posso pertanto accedere alla cella di memoria "puntata" da quel particolare indirizzo. Se aggiungo uno all'indirizzo X otterrò che il puntatore "punta" il secondo elemento della array e così via fino a X+9.
In linguaggio "C" array e puntatori sono in effetti sinonimi e possono essere usati indifferentemente anche scambiandoli tra di loro, almeno in certe situazioni.

Il metodo extract

Sovrascriviamo il metodo extract della classe CrivelloNative poichè la tecnica usata nella classe CrivelloFFMar è totalmente diversa da quanto visto finora. Essa si basa sulla seguente elaborazione:

  • viene richiamata la funzione nativa extract_memseg che restituisce un puntatore ad un segmento di memoria nativo, allocato nella libreria nativa
  • associamo il segmento nativo ad una Arena confinata in modo che possa essere gestito da Java e dal suo garbage collector
  • informiamo Java della dimensione e dei tipi di dato contenuti nel segmento di memoria in modo da potervi accedere direttamente
  • eseguiamo l'accesso ai dati del segmento (la array di numeri primi) ed inseriamo ognuno di essi nella java.util.List fornita come argomento al metodo extract.

Ognuna di queste azioni sarà descritta in dettaglio nelle prossime sottosezioni.

L'indirizzo del segmento

Il segmento di memoria nativo che conterrà la lista dei numeri primi estratti dal setaccio viene allocato nella funzione nativa extract_memseg; questa funzione richiede uno spazio di memoria libera direttamente al sistema operativo. Il sistema operativo ritorna un indirizzo di memoria che è la cella di memoria iniziale del segmento e si estende ad indirizzi contigui fino alla dimensione desiderata.
La funzione nativa ritorna l'indirizzo come un puntatore che deve essere memorizzato in un oggetto MemorySegment:

MemorySegment memseg = (MemorySegment)extractHdl.invokeExact( start, end );

è possibile conoscere questo indirizzo col metodo MemorySegment.address() come per esempio:

System.out.println( "CrivelloFFMar: address of memory segment: " + memseg.address());
// output:
CrivelloFFMar: address of memory segment: 2046551044160

La dimensione del segmento

La funzione nativa extract_memseg calcola la dimensione del segmento sulla base del conteggio presunto di numeri primi esistenti tra i due valori di start ed end cioè il numero più piccolo ed il numero più grande da estrarre; questo viene eseguito con la nota formula approssimativa:

P(n) = end / log(end) - start / log(start)

al cui risultato aggiungiamo un 10% di sicurezza dal momento che il numero esatto di primi estratti sarà noto solo quando li avremo effettivamente estratti.

Vi è da osservare che la dimensione del segmento di memoria allocato nativamente NON viene restituita dalla funzione nativa extract_memseg dal momento che il suo valore di ritorno è l'indirizzo del segmento e poichè può essere restituito un solo valore, richiameremo una seconda funzione nativa per conoscere la dimensione effettiva: questa funzione nativa è la primes_capacity che richiamiamo nel modo consueto:

long memSize = (long)capacityHdl.invokeExact();

Ora la variabile memSize contiene la dimensione in numero di elementi di una array in stile C i cui elementi sono del tipo long long che corrisponde a java.lang.Long.
Rammento al lettore che memSize NON contiene il conteggio degli elementi della array che era sconosciuto nel momento della allocazione: contiene la dimensione del segmento di memoria che però è maggiore del numero di elementi effettivamente inseriti dalla funzione nativa.

Reinterpretare il segmento nativo

Per poter accedere da Java al segmento di memoria nativo dobbiamo associare il segmento ad una Arena ed informare Java sulla sua dimensione. Quando la funzione nativa extract_memseg ha ritornato l'indirizzo di memoria a Java, quest'ultimo ne ha memorizzato l'indirizzo in un oggetto MemorySegment ma la sua dimensione è sconosciuta a Java: esso lo considera uno zero-length segment (=un segmento di lunghezza zero) a cui sarebbe impossibile accedere da Java.

Per informare Java sulla dimensione effettiva della memoria nativa usiamo il metodo MemorySegment.reinterpret il quale ritorna un nuovo oggetto di tipo MemorySegment, supportato da quello nativo, ma al quale possiamo accedere da Java in modo sicuro (sempre che la dimensione del segmento sia quella corretta, intendo):

MemorySegment primesArray = memseg.reinterpret(
memSize * ValueLayout.JAVA_LONG.byteSize(), arena, /*Consumer*/ null);

Al metodo reinterpret vanno forniti i seguenti argomenti:

  • la dimensione in bytes del segmento: questo si calcola moltiplicando la dimensione della C-array in numero di elementi (che abbiamo in memSize) per il numero di bytes che forma un tipo long long del "C" espresso da ValueLayout.LONG.byteSize()
  • la Arena che gestirà il ciclo di vita del segmento di memoria, quella che abbiamo creato nel costruttore di CrivelloFFM
  • il consumer (vedi la interfaccia java.util.function.Consumer) che dovrà provvedere al cleanup (=pulizia, deallocazione) del segmento di memoria nativo: poichè il segmento di memoria viene associato ad una Arena "confinata" il consumer può essere null

Dopo questa operazione otterremo in primesArray un segmento di memoria "reinterpretato" secondo i dettami di Java: il suo indirizzo e la sua dimensione in bytes.

Accedere ai dati del segmento

Rimane un problema da affrontare: quanti numeri primi saranno stati inseriti nella C-array dalla funzione nativa extract_memseg? Vi sono diverse soluzioni a questo problema:

  • scrivere una funzione nativa da richiamare che restituisca il valore, per esempio la funzione primes_count
  • allocare il segmento di memoria azzerandone completamente il contenuto: poichè un numero primo non può essere ZERO, quando incontreremo un elemento a ZERO nella array significa che i numeri primi estratti sono finiti
  • memorizzare il conteggio effettivo dei numeri primi in uno spazio conosciuto come per esempio il primo elemento della array: la lista dei numeri primi parte, pertanto, dal secondo elemento

Io ho scelto la terza soluzione. La funzione nativa extract_memseg comincia a memorizzare i numeri primi estratti dal secondo elemento della array e li conta: alla fine del ciclo, il conteggio finale viene inserito nel primo elemento.

Facciamo un esempio concreto: vogliamo la lista dei numeri primi da 200 a 300:

>java -ea -Djava.library.path=./javatutor/primes/crivello javatutor.primes.Performance -t FFMar 200 300

Dopo la elaborazione del setaccio, la funzione nativa extract_memseg:

  • calcola il numero approssimato di elementi da allocare (16)
  • alloca un segmento di memoria libera in grado di contenere 16 elementi di tipo long long
  • estrae i numeri primi dal setaccio e li memorizza a partire dal secondo elemento della C-array puntata dal segmento di memoria e li conta (16)
 ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- -----
|     | 211 | 223 | 227 | 229 | 233 | 239 | 241 | 251 | 257 | 263 | 269 | 271 | 277 | 281 | 283 | 293 |
 ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- -----
  • alla fine del ciclo di estrazione memorizza il conteggio nel primo elemento:
 ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- -----
|  16 | 211 | 223 | 227 | 229 | 233 | 239 | 241 | 251 | 257 | 263 | 269 | 271 | 277 | 281 | 283 | 293 |
 ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- -----

Pertanto, per inserire la lista dei numeri primi nella java.util.List prima leggiamo il numero di elementi effettivamente presenti in primesArray:

long count = primesArray.getAtIndex( ValueLayout.JAVA_LONG, 0 );

e poi iteriamo dal secondo elemento del segmento fino a count:

for (int i = 0; i < count; i++ ) {
long a = primesArray.getAtIndex( ValueLayout.JAVA_LONG, i + 1 );
primes.add( a );
}

L'accesso al segmento di memoria avviene attraverso il metodo getAtIndex che restituisce il dato il cui tipo è specificato nel suo primo argomento (nel nostro caso ValueLayout.JAVA_LONG) ed all'indice specificato nel secondo argomento che non è un offset in bytes ma in numero di elementi del tipo desiderato, cioè un Java long.
E' comunque possibile accedere al segmento di memoria anche un byte alla volta semplicemente indicando alla getAtIndex il tipo di dato desiderato:

// ottiene il primo byte del segmento di memoria
byte b = primesArray.getAtIndex( ValueLayout.JAVA_BYTE, 0 );

I risultati finali

Nella tabella seguente riporto in ultima colonna le perofrmances ottenute con il metodo di estrazione del segmento di memoria. Come potete osservare, i risultati sono di tutto rispetto, in linea con quelli ottenuti da Java ma, all'aumentare del limite, le performances della libreria nativa mostrano un netto miglioramento, arrivando a migliorare quelle di Java.

Limite Count Crivello3 JNIby FFM FFMby FFMar
100M 5.761.455 0.5 0.7 1.2 0.7 0.7
200M 11.078.937 1.2 1.6 2.6 1.4 1.3
400M 21.336.326 2.5 3.5 5.0 2.7 2.7
1G 50.847.534 6.7 9.4 12.7 7.0 6.4

I metodi ristretti

Nell'eseguire il test Performance avrete sicuramente notato i messaggi di WARNING (=avvertimento) emessi dalla JVM:

WARNING: A restricted method in java.lang.foreign.SymbolLookup has been called
WARNING: java.lang.foreign.SymbolLookup::libraryLookup has been called by javatutor.primes.crivello.ffm.CrivelloFFM in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

L'avvertimento viene emesso perchè abbiamo richiamato alcuni metodi che sono considerati da Java restricted methods: vengono chiamati così perchè essi sono potenzialmente molto pericolosi: il richiamo di codice nativo è comunque pericoloso ma sopratutto se esso riguarda librerie native di terze parti oppure scritte da noi stessi.
Il messaggio ci avverte anche che in una futura release di Java i metodi ristretti potrebbero essere disabilitati a meno di eseguire la JVM con una opzione specifica:

--enable-native-access=ALL-UNNAMED

Al seguente link troverete l'elenco dei metodi ristretti che riguardano la API FFM: Restricted Methods.

Il crash della applicazione

Un application crash (arresto anomalo dell'applicazione) si verifica quando una applicazione, durante la sua esecuzione, smette di funzionare in modo inaspettato, spesso causando la chiusura improvvisa dell'applicazione o persino il blocco del sistema (cit: Google AI Overview).
Nei moderni sistemi operativi il crash di una applicazione ben difficilmente può causare il blocco completo del sistema poichè la piattaforma hardware/software separa nettamente i processi e possiede meccanismi di protezione della memoria in grado di impedire ad un qualsiasi processo applicativo di interferire con la memoria assegnata ad un altro processo.
Ma ci sono delle eccezioni: il kernel (=nucleo del sistema operativo) e alcuni spezzoni di codice dei device driver (=i programmi pilota delle periferiche) devono essere eseguiti con privilegi speciali ed un bug in uno di questi speciali software può portare al blocco totale del computer.
Ma non è il nostro caso: solo la nostra applicazione Java và in crash; il resto del sistema rimane integro.

Premetto che il crash della applicazione Performance è facilmente risolvibile ma non lo ho voluto correggere per farvi toccare con mano la intrinseca pericolosità del codice nativo: ebbene si, il bug riguarda la libreria nativa setaccioffm.dll ed in particolar modo la estrazione dei numeri primi con il metodo del segmento di memoria (vedi Estrazione in un segmento di memoria).

Eseguiamo il programma Performance estraendo la lista dei numeri primi da 2 a 40.000 (quarantamila) usando l'algoritmo "FFMar":

>java -ea -Djava.library.path=./javatutor/primes/crivello javatutor.primes.Performance -e FFMar 40000

Ecco l'output ottenuto:

Using implementation: Crivello FFMar (memory segment)
DLL DEBUG: setacciaFFM() - limit=40000
DLL DEBUG: setacciaFFM() - size_t size=8
DLL DEBUG: setacciaFFM() - long long size=8
DLL DEBUG: setacciaFFM() size of setaccio is: 2500 bytes
DLL DEBUG: closeFFM()
DLL DEBUG: Allocating 2504 bytes for setaccio
DLL DEBUG: Limit of setaccio: limit=40063, size=2504
DLL DEBUG: extract_memseg() lim1=263.2 lim2=3774.7 adjust=1.1
DLL DEBUG: extract_memseg() aproximated capacity: 3862
DLL DEBUG: extract_memseg() allocating new memory segment: 3862
DLL DEBUG: extract_memseg() primes effective count: 3900
CrivelloFFMar: address of memory segment: 2865911671824

Noterete che il programma NON termina regolarmente: non vengono visualizzati i messaggi relativi al conteggio totale dei numeri primi estratti nè al tempo impiegato per eseguire la elaborazione. Il programma è terminato in modo inaspettato e improvviso senza dare alcun riscontro, non una eccezione, non un messaggio di errore.

La causa del crash è presto individuata grazie ai messaggi "DLL DEBUG" (di più su questo argomento in Debug vs Release):

DLL DEBUG: extract_memseg() aproximated capacity: 3862
DLL DEBUG: extract_memseg() allocating new memory segment: 3862
DLL DEBUG: extract_memseg() primes effective count: 3900

Appare chiaro che la funzione nativa extract_memseg calcola in modo errato la dimensione approssimata della array di elementi: 3862 ed alloca spazio in memoria per essi. Tuttavia, quando avviene la estrazione, il numero effettivo di elementi inseriti nel blocco di memoria è più alto: 3.900
Nel metodo extract_memseg mi sono dimenticato di inserire un controllo nel indice che indirizza gli elementi della array di numeri primi: se esso diventa maggiore/uguale alla dimensione allocata, non posso proseguire.

Java non permette di superare i limiti di una array allocata: se ci tentassimo otterremo una IndexOutOfBoundsException. Ma il linguaggio "C" non è come Java: non interessa la sicurezza intrinseca del linguaggio ma la maggiore velocità possibile e consente al programmatore di fare quello che vuole, anche eccedere negli indici delle array.
Ma il risultato di ciò è che l'indice della array sfora la dimensione del blocco di memoria assegnato alla applicazione, tentando di accedere a zone di memoria che non le appartengono: il sistema operativo la chiude immediatamente, stroncandola brutalmente.

Argomento precedente - Argomento successivo - Indice Generale