mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 23/39

23. Approfondimento: Vector API, dati elaborati per gruppi

Java 25 · Guida completa · Bozza in revisione

Questa guida conserva lo stato di revisione del libro. La verifica editoriale e le prove di comprensione con lettori indipendenti sono ancora da completare.

Cerca in tutta la guida →

L'API vettoriale di questo capitolo affronta il calcolo su più elementi alla volta. Il nome «vettore» e l'idea di parallelismo possono ricordare i thread, ma il problema è diverso da quello del capitolo 20A: una somma per corsie non rende atomico un aggiornamento condiviso fra thread. Prima di scegliere questo strumento chiediamo se il lavoro riguarda dati numerici indipendenti, non una decisione concorrente su un unico stato.

Questo è un approfondimento autonomo fra il capitolo sui classloader e quello sulla reflection. Se vuoi seguire subito il filo «caricamento di un tipo → ispezione del tipo», puoi passare al capitolo 24 e tornare qui in seguito: i vettori non sono un prerequisito della reflection.

Versione Java – API incubator. La Vector API appartiene al modulo jdk.incubator.vector in Java 25: richiede --add-modules jdk.incubator.vector e può cambiare o essere rimossa in una versione futura. Non è una normale API stabile di Java SE 25. Gli esempi di questo approfondimento seguono la documentazione del modulo nel JDK 25.

Perché parlare di vettori

In molti problemi scientifici, grafici o di elaborazione dei segnali si esegue la stessa operazione su molti numeri indipendenti. Per sommare due array float[], un ciclo ordinario legge una coppia di elementi alla volta, somma e scrive il risultato. Una CPU può disporre di istruzioni SIMD, capaci di applicare una stessa operazione a più corsie di dati in una istruzione. L'ottimizzazione automatica del compilatore può talvolta sfruttarle, ma non tutti i cicli hanno una forma facilmente riconoscibile. La Vector API offre un modo esplicito per descrivere operazioni su gruppi di valori, lasciando alla JVM il compito di adattarle all'hardware disponibile.

Definizione – Vettore e corsia. Un vettore in questa API è un gruppo di valori dello stesso tipo primitivo, per esempio più float. Ogni posizione del gruppo è una corsia. La somma di due vettori somma le corsie corrispondenti: non calcola la somma di tutti gli elementi di un solo vettore. Questa distinzione evita di confondere un'operazione elemento per elemento con una riduzione.

Nel programma completo, FloatVector.SPECIES_PREFERRED sceglie una specie adatta alla piattaforma per valori float. La specie descrive, fra l'altro, quante corsie contiene il vettore. Il numero stampato da SPECIE.length() può cambiare fra macchine e JVM; perciò l'esempio non promette una larghezza fissa. Il risultato numerico, invece, è prevedibile per i piccoli valori usati: [11.0, 22.0, 33.0, 44.0, 55.0].

FloatVector a = FloatVector.fromArray(SPECIE, sinistra, i);
FloatVector b = FloatVector.fromArray(SPECIE, destra, i);
a.add(b).intoArray(risultato, i);

Le prime due righe caricano gruppi di numeri dagli array, a partire dall'indice i. La terza somma corsia per corsia e scrive il gruppo nel risultato. Il ciclo avanza di SPECIE.length() elementi. Se la lunghezza dell'array non è multipla delle corsie, resta una coda: il programma la elabora con un normale ciclo scalare. SPECIE.loopBound(sinistra.length) calcola il limite fino al quale i gruppi completi possono essere letti senza superare l'array. Per questo l'esempio funziona anche quando la macchina usa più corsie dei cinque elementi disponibili: in quel caso il ciclo vettoriale può non eseguire alcuna iterazione e la coda scalare fa tutto il lavoro.

Somma per corsie e coda scalare
Figura 23.1 – Esempio illustrato con quattro corsie. La larghezza effettiva di SPECIES_PREFERRED dipende dalla piattaforma.

Prima di iniziare, il metodo controlla che i due array abbiano la stessa lunghezza. Senza quel controllo, un accesso oltre i limiti potrebbe produrre un errore a metà elaborazione e lasciare un risultato parziale. La validazione stabilisce chiaramente il contratto: una somma elemento per elemento richiede due sequenze confrontabili. Non occorre introdurre un comportamento implicito per array di lunghezze diverse.

Compilare e leggere la prova

Il file si compila con javac --release 25 --add-modules jdk.incubator.vector SommaVector.java e si esegue con java --add-modules jdk.incubator.vector SommaVector. Il JDK emette un avviso sull'uso del modulo incubator: è atteso e conferma la natura sperimentale dell'API. Se si omette l'opzione, il modulo potrebbe non essere disponibile alla compilazione o all'esecuzione. Gli esempi dei capitoli stabili continuano a compilare senza questa opzione.

La presenza di un ciclo vettoriale non prova da sola un miglioramento di prestazioni. La misura dipende da dimensione dei dati, hardware, configurazione della JVM, riscaldamento del compilatore JIT e costo del trasferimento dei dati. Per cinque numeri il programma è una dimostrazione di semantica, non un benchmark. Anche un confronto con un ciclo scalare richiede prove ripetute e un metodo di misura adatto; cronometrare una singola esecuzione da main confonderebbe avvio della JVM, compilazione e lavoro vero.

Un esercizio utile è cambiare la lunghezza degli array in modo che restino zero, uno o molti elementi dopo l'ultimo vettore completo. Il lettore può verificare che il risultato non perda mai la coda. Solo dopo aver capito questo confine ha senso esplorare le maschere della Vector API, che permettono di esprimere operazioni su corsie selezionate. Restano però parte della stessa API incubator: ogni esempio aggiuntivo dovrà essere verificato sulla versione di JDK usata dal volume.

Quando la lunghezza non coincide con le corsie

Il primo programma usa due cicli: uno per i gruppi completi, uno per il resto. È una soluzione leggibile e utile per iniziare, perché il ciclo scalare rende evidente che nessun elemento deve essere perso. La Vector API offre anche le maschere. Una maschera ha una condizione booleana per ciascuna corsia: indica quali corsie sono valide in una certa operazione. Nel secondo programma, SPECIE.indexInRange(i, sinistra.length) costruisce la maschera delle posizioni che ricadono ancora nell'array. Le forme mascherate di fromArray e intoArray leggono e scrivono soltanto le corsie selezionate.

VectorMask<Float> valide = SPECIE.indexInRange(i, sinistra.length);
FloatVector a = FloatVector.fromArray(SPECIE, sinistra, i, valide);
FloatVector b = FloatVector.fromArray(SPECIE, destra, i, valide);
a.add(b).intoArray(risultato, i, valide);

Per capire queste quattro righe, prendiamo la figura con quattro corsie e cinque numeri. Il primo giro usa gli indici da zero a tre: tutte le corsie sono valide. Il secondo giro parte dall'indice quattro: solo la prima corsia corrisponde al quinto elemento; le altre tre cadrebbero oltre la fine dell'array e la maschera le esclude. Il risultato scritto contiene comunque cinque valori, non otto. Se la piattaforma preferisce una larghezza diversa, lo stesso metodo calcola la maschera in base alla lunghezza effettiva della specie. È questo il motivo per cui il programma non codifica il numero quattro come limite del ciclo.

La versione mascherata non è automaticamente più veloce di quella con il ciclo scalare finale. La forma scelta può influire sulla generazione del codice macchina e il risultato dipende dai dati e dall'hardware. Qui la maschera insegna un meccanismo dell'API: permette di esprimere «opera solo su queste corsie» e di gestire in modo uniforme il gruppo parziale. Il primo programma, con la coda scalare, può rimanere preferibile come spiegazione iniziale. Entrambi vanno verificati con array vuoti, più corti di una specie, lunghi esattamente una specie e lunghi una specie più un elemento.

Approfondimento – Maschera e condizione Java. Una VectorMask<Float> non è un singolo boolean che decide se eseguire tutto il ciclo. Contiene una scelta per ciascuna corsia. Se il gruppo ha quattro corsie e soltanto la prima è ancora nell'array, la maschera rappresenta concettualmente vero, falso, falso, falso. La forma esatta dell'oggetto e il numero di corsie dipendono dalla specie. Questa distinzione evita di pensare alla maschera come a un semplice if attorno al caricamento dell'intero vettore.

Specie, tipo degli elementi e portabilità

VectorSpecies<Float> descrive la combinazione di tipo degli elementi e forma del vettore. Float nel parametro generico identifica il tipo delle corsie nell'API, mentre i dati degli array restano float primitivi. Il metodo length() restituisce il numero delle corsie; loopBound(length) calcola il maggiore multiplo di quel numero che non supera la lunghezza passata. Se una specie ha quattro corsie e l'array ne contiene undici, il limite è otto: i gruppi completi partono dagli indici zero e quattro; tre elementi rimangono per il ciclo scalare. Se la specie ne ha otto, il limite resta otto, ma c'è un solo gruppo completo. La formula è semplice, ma va collegata all'indice reale del ciclo per evitare accessi fuori limite.

La specie preferred lascia alla piattaforma una scelta. Questo favorisce la portabilità del sorgente, non l'identità del comportamento prestazionale. Il programma non deve promettere che SPECIE.length() valga un numero specifico. Nei test si controlla il risultato dell'operazione e, se si vuole, si stampa la larghezza come informazione diagnostica. Una figura didattica può disegnare quattro corsie per rendere visibile il ragionamento, purché la didascalia dica che è un esempio illustrato e non il vincolo dell'API.

Un algoritmo vettoriale deve anche considerare il tipo di dato. La somma di float segue le regole dei numeri in virgola mobile: valori molto grandi, valori molto piccoli, infiniti e NaN non si comportano come interi matematici esatti. Nel nostro esempio usiamo numeri piccoli che hanno un risultato intuitivo. Se si trasformasse il programma in una riduzione, per esempio la somma di tutti gli elementi, l'ordine delle addizioni potrebbe cambiare e con esso alcuni risultati arrotondati. Prima di promettere equivalenza bit per bit con un ciclo scalare, occorrerebbe stabilire il contratto numerico richiesto e controllare l'operazione concreta.

Dall'esempio al problema reale

La somma elemento per elemento è un primo mattone. Un'applicazione reale potrebbe elaborare campioni audio, trasformare canali di un'immagine o applicare una funzione a grandi blocchi di dati numerici. Il vantaggio potenziale nasce quando lo stesso calcolo semplice si ripete su abbastanza elementi da rendere importante il lavoro sulle corsie. Se ogni elemento richiede una decisione complessa o l'accesso irregolare a oggetti sparsi, il codice vettoriale può perdere chiarezza senza offrire un beneficio. Prima si individua un ciclo che pesa davvero nel tempo totale, poi si prova una forma vettoriale e la si misura.

La misura deve confrontare risultati corretti e condizioni comparabili. Un benchmark che include nel tempo la creazione degli array per una variante e la esclude dall'altra non confronta lo stesso lavoro. Un'esecuzione singola della JVM include avvio e attività del compilatore JIT. Dimensione degli array, riscaldamento, memoria, architettura e versione del JDK vanno registrati. Se i dati sono già in cache in una prova e non nell'altra, la differenza può dipendere dalla memoria anziché dalle istruzioni SIMD. La Vector API dà un linguaggio per esprimere il calcolo; la prova di vantaggio appartiene a un esperimento misurato.

Questo approfondimento permette di sperimentare SIMD e maschere sapendo quale dipendenza si introduce. Se il modulo cambierà in una versione successiva, occorrerà verificare di nuovo esempi e comandi prima di riusarli. Per ora il punto da portare con sé è il metodo: partire da un calcolo concreto, capire che cosa fa ogni corsia, controllare anche la coda dell'array e misurare soltanto dopo avere verificato la correttezza del risultato.

Per verificare

Esegui i due programmi con array di lunghezza zero, uno, cinque e dieci. Per ogni lunghezza indica quanti gruppi completi elabora il primo programma sulla macchina usata e quale maschera vale nell'ultimo giro del secondo. Aggiungi un test con array di lunghezze diverse e verifica che entrambi lancino IllegalArgumentException prima di produrre un risultato. Distingui l'output numerico, che deve essere uguale nei due casi di prova, dal numero di corsie e dal tempo di esecuzione, che non sono risultati universali del libro. Se trasferisci il metodo a campioni audio o pixel, stabilisci prima quale risultato numerico deve restare invariato.

Prova gli esempi

Per eseguire i programmi serve JDK 25. Puoi scaricare i singoli file Java collegati nel capitolo oppure il progetto completo, che contiene istruzioni e uno script di avvio. Le spiegazioni confrontano anche l’output atteso: prevedilo prima di eseguire il programma.

Massimiliano Tarquini · CC BY-NC 4.0

Torna all’inizio ↑