Skip to content
Fancy PixelFancy PixelBlog
Torna al blog

Quando Node.js non basta: una libreria Rust per ottimizzare un radar nautico

Come abbiamo profilato un'app Electron per radar nautico, trovato le funzioni più pesanti con l'aiuto dell'AI e spostate su Rust/WebAssembly, senza rinunciare all'interfaccia.

Giovanni Foiani18 min di lettura
Illustrazione di uno scope radar circolare in blu e cyan su sfondo avorio, con fascio di scansione luminoso, anelli concentrici e griglia, alcuni target luminosi, un badge WebAssembly 'WA' e un piccolo granchio Rust tra gli anelli

Radar Console è un’applicazione desktop che fa da interfaccia a un radar nautico. L’abbiamo riscritta da zero, partendo da una vecchia base in Qt che mostrava i suoi anni, per ricostruirla sullo stack che usiamo oggi. È un caso interessante perché ha dei requisiti scomodi: da un lato un’interfaccia ricca e interattiva, dall’altro un flusso di dati che arriva in tempo reale e senza feedback di risposta. Questo articolo racconta come abbiamo affrontato un problema di performance reale, non teorico: la strategia che abbiamo scelto e, soprattutto, come abbiamo messo in piedi l’integrazione di una libreria Rust (compilata in WebAssembly) pensata per diventare la base su cui spostare, un pezzo alla volta, le parti più pesanti dell’elaborazione, senza rinunciare allo stack che ci aveva fatto comodo fin dall’inizio. Spoiler: il primo passo è stato costruire e validare quell’integrazione; il guadagno grosso arriverà come sviluppo futuro con la parte più corposa, il clustering, che è anche la più difficile da portare.

Cos’è Radar Console

Radar Console è un’app Electron + React + TypeScript che riceve i pacchetti grezzi da un’antenna radar via UDP, li decodifica, li elabora e li disegna su uno scope PPI (l’immagine circolare che chiunque associa a un radar). Tutto il codice, sia il processo Main sia l’interfaccia, è scritto in TypeScript, su runtime Node.js per il processo Main. Segue l’architettura Main/Renderer tipica di Electron, ma con un’attenzione particolare al data handling ad alte prestazioni:

  • Il processo Main (Node.js) ospita i moduli di logica: DataHandler riceve i pacchetti UDP, li processa (estrazione I/Q, calcolo di potenza, clustering dei target) e li distribuisce; Player riproduce le sessioni registrate; StatusHandler tiene lo stato del radar. I compiti CPU-intensive girano in worker separati per non bloccare il loop principale.
  • Il processo Renderer (React) disegna la visualizzazione, le mappe e i controlli, comunicando col Main via IPC.

Il pezzo che ci interessa qui è il percorso che ogni singolo pacchetto compie da quando arriva dalla socket UDP a quando diventa un punto luminoso sullo schermo. A basso livello, i dati del radar sono impacchettati dentro a delle Word a 16 bit, e un modulo di utility li spacchetta. La pipeline live, semplificando, è:

socket UDP → reassambleDataMessage (header) → parseDataMessage (I/Q 12/14-bit) → DataHandler → frontend

Il problema: un budget che si dimezza

Oggi il radar ci invia un blocco dati ogni 5 ms. La roadmap dell’hardware prevede di scendere a 2,5 ms. Detto in un altro modo: il tempo che abbiamo per ricevere un pacchetto, riassemblare lo sweep, estrarre le componenti I e Q, calcolare la potenza, convertirla in dB e passare il risultato al rendering, tutto questo, si sta per dimezzare.

Perché Electron e Node.js, allora? Per la ragione per cui li sceglie quasi chiunque: è lo strumento più produttivo che conosciamo per costruire interfacce desktop evolute e multipiattaforma. React per la UI, un unico linguaggio dal frontend al backend, un ecosistema enorme, hot reload, distribuzione su Windows/macOS/Linux quasi gratis. Per il 90% dell’applicazione è la scelta giusta e la rifaremmo.

Il restante 10% è il problema. JavaScript su V8 è veloce, ma ha due caratteristiche che mal si sposano con i requisiti del progetto:

  1. Il garbage collector (GC). Ogni sweep alloca array di numeri (I, Q, powerData, powerDataDB…). V8 li raccoglie quando vuole lui, non quando fa comodo a noi. Una pausa di GC di pochi millisecondi nel posto sbagliato è esattamente il jitter che non ti puoi permettere quando il treno passa ogni 2,5 ms.
  2. I number sono float a 64 bit. Parte di questo codice fa manipolazione di bit su interi (& 0x3FFF, << 7, >> 8). In JavaScript ogni operazione bitwise comporta una conversione implicita float→int32→float. Funziona, ma non è il tipo di lavoro per cui è ottimizzato.

Avevamo già spremuto JavaScript dove potevamo. Se guardate il codice:

export function parseDataMessage(packetData: Partial<IRadarRawDataPacket>, wordVector: Uint16Array): IDataMessage {
  const { samplesNumber = constants.totalBins } = Configs.configs.debugParams;
  const { acp = -1, spDataExponent = 0, sweep = -1 } = packetData;

  // OTTIMIZZAZIONE: Math.pow una sola volta per pacchetto invece di N volte.
  const multiplier = 2.0 ** spDataExponent;

  const numSamples = Math.floor(wordVector.length / 3);
  const I = new Array<number>(numSamples);
  const Q = new Array<number>(numSamples);

  let targetIndex = 0;
  for (let index = 0; index < wordVector.length; index += 3) {
    if (targetIndex >= numSamples) break;
    I[targetIndex] = getIcomponent(wordVector, index, multiplier);
    Q[targetIndex] = getQcomponent(wordVector, index, multiplier);
    targetIndex += 1;
  }

  return { acp, sweep, rawData: { I: I.slice(0, samplesNumber), Q: Q.slice(0, samplesNumber) }, exponent: spDataExponent };
}

Pre-allocazione, niente slice nel loop, moltiplicatore cachato. È JavaScript scritto bene. Eppure il margine residuo non bastava per il salto a 2,5 ms, e continuare a spremere il linguaggio oltre questo punto significava scrivere codice sempre più illeggibile per guadagni sempre più piccoli. È il momento in cui conviene cambiare strumento, non affilare ancora lo stesso.

Perché Rust, e perché WebAssembly

La domanda non era “qual è il linguaggio più veloce in assoluto”, ma “qual è il modo meno invasivo di riscrivere le funzioni più utilizzate”. Questo insieme di funzioni è definito hot path (letteralmente “percorso caldo”): il tratto di codice eseguito più spesso, quello dove il programma passa la maggior parte del suo tempo. Nel nostro caso è la catena che elabora ogni singolo sweep del radar, centinaia di volte per scansione e migliaia di volte al secondo. È l’unico punto in cui ottimizzare ripaga davvero. E le risposte sono in realtà due: quale linguaggio scrivere, e come farlo dialogare con Node.

Sul linguaggio, Rust ha vinto su tre fronti.

Prestazioni prevedibili, senza GC. È il punto centrale. Rust non ha garbage collector: la memoria è gestita a compile-time tramite ownership. Niente pause impreviste. Per un budget real-time stringente come il nostro, la prevedibilità conta più del picco di velocità: meglio un tempo costante che un tempo ottimo che ogni tanto schizza perché è partito il GC.

Il dominio è fatto apposta. Questo codice è aritmetica su array numerici e qualche manipolazione di bit: potenza I² + Q² bin per bin, conversioni in dB, mapping di livelli di colore, clustering su matrici. Rust ha tipi numerici nativi, operazioni a costo zero e iteratori che il compilatore vettorizza. È letteralmente il tipo di lavoro per cui il linguaggio è stato pensato.

Sicurezza senza runtime. Il borrow checker ci protegge da intere classi di bug (use-after-free, data race, buffer overflow) che in un software scritto in linguaggio nativo sarebbero il primo sospettato quando l’app crasha.

Sul come, invece, la scelta è caduta su WebAssembly tramite wasm-bindgen e wasm-pack, e non su un addon nativo N-API (napi-rs, Neon, C++). La ragione sta in una parola: portabilità. Un addon nativo va ricompilato per ogni sistema operativo e per ogni ABI di Node/Electron: a ogni aggiornamento di Electron il binario può rompersi e tocca rifare il giro di node-gyp/electron-rebuild, moltiplicato per Windows, macOS e Linux. Un modulo WebAssembly, invece, è un unico artefatto .wasm che gira identico ovunque, su qualsiasi piattaforma e qualsiasi versione di Node/Electron, senza ricompilazioni e senza toolchain native sulla macchina di chi builda l’app. wasm-pack genera anche il codice JavaScript di collegamento tra Node e il modulo Wasm, il cosiddetto glue code (in CommonJS, con --target nodejs), e i tipi TypeScript.

Il prezzo c’è e vale la pena spiegarlo perché tocca un limite concreto di WebAssembly: il confine JS↔WASM. JavaScript e WebAssembly vivono in due memorie separate: il modulo WASM lavora su una propria area di memoria e non può leggere direttamente gli array di JavaScript. Per passargli i dati in ingresso, e per riprendere i risultati in uscita, i valori vanno copiati avanti e indietro attraverso quel confine. Sui nostri array da centinaia di campioni significa una copia in più a ogni chiamata: piccola, ma non gratis.

Detto questo, per loop numerici stretti il nativo ha comunque dalla sua un vantaggio strutturale: niente garbage collector, quindi niente pause impreviste. Ma non è la bacchetta magica a costo zero che a volte viene raccontata: la copia al confine c’è e va messa in conto, e quanto pesa rispetto al guadagno dipende da quanto lavoro fai dentro WASM tra un attraversamento e l’altro. Nel nostro caso accettavamo lo scambio soprattutto per due motivi: prestazioni più prevedibili senza GC e, non meno importante, zero grattacapi di distribuzione (un solo .wasm valido per tutte le piattaforme).

C’è anche un vantaggio che non abbiamo ancora sfruttato ma che ci teniamo nel cassetto: a differenza di un addon nativo, che nel browser non potrebbe nemmeno girare, lo stesso modulo WASM può un domani essere riusato anche nel renderer React, ricompilandolo con wasm-pack --target bundler invece di --target nodejs, per spostare nella UI eventuali calcoli pesanti senza riscrivere le utility. Per ora vive solo nel processo Main, ma la porta resta aperta gratis.

E soprattutto: non riscriviamo l’applicazione. L’idea non è migrare Radar Console a Rust, sarebbe folle e butterebbe via tutto il valore di Electron/React. L’idea è molto circoscritta: una piccola libreria, compilata in WASM ed esposta a Node, che rimpiazza solo le funzioni più utilizzate e dispendiose mantenendone identica la firma lato JavaScript. Il resto dell’app non si accorge di nulla.

Trovare le funzioni giuste (con l’aiuto dell’AI)

Riscrivere la funzione sbagliata è il modo migliore per aggiungere complessità a zero beneficio. Prima di iniziare a utilizzare Rust serviva la risposta a una domanda: dove va davvero il tempo?

Il punto di partenza è sempre il profiler, non l’intuizione. Abbiamo fatto girare il processo Main con il profiler V8 su una sessione registrata, usando il Player per riprodurre uno stream reale a velocità di acquisizione:

node --prof -r ts-node/register simulator.ts
node --prof-process isolate-*.log > profile.txt

Il .txt che ne esce è difficile da leggere: migliaia di righe di tick raggruppati per funzione, simboli di V8, stack che si intrecciano. Ne viene fuori qualcosa del genere (estratto semplificato con numeri a scopo illustrativo):

 [Summary]:
   ticks  total  nonlib   name
   5739   75.6%           JavaScript
    298    3.9%           GC
   1846   24.3%           Shared libraries

 [JavaScript]:
   ticks  total  nonlib   name
   1419   18.7%   27.9%   LazyCompile: *getQcomponent
   1402   18.5%   27.6%   LazyCompile: *getIcomponent
    986   13.0%   19.4%   LazyCompile: *parseDataMessage
    611    8.1%   12.0%   LazyCompile: *bufferToUint16Array
    540    7.1%   10.6%   LazyCompile: *computeRawPower
    388    5.1%    7.6%   LazyCompile: *convertTodBByHandlingZeroValue
    274    3.6%    5.4%   LazyCompile: *getTargetColor
    119    1.6%    2.3%   LazyCompile: *metersByBin

Qui l’AI ci ha fatto risparmiare ore. Invece di leggere a mano il profilo, lo abbiamo dato in pasto al modello con una richiesta precisa:

“Questo è l’output di --prof-process di un’app Electron che processa pacchetti radar. Raggruppa i tick per funzione del nostro codice (ignora gli internals di Node.js e le librerie), stima per ciascuna la quota di tempo e ordinale per costo. Per quelle in cima alla lista, spiega perché sono molto utilizzate e quanto sono adatte a una riscrittura in nativo.”

Poi abbiamo incrociato la classifica con una seconda domanda, stavolta sull’intero repository: data la pipeline parseDataMessage → computeRawPower → convertTodB → computeColorLevels, quali funzioni vengono chiamate per ogni bin (non per sweep)? Sono quelle dove un microsecondo si moltiplica per centinaia di bin per migliaia di sweep al secondo.

Le due analisi convergevano sugli stessi sospettati. Il valore dell’AI qui non è stato “scoprire” qualcosa di magico, un profiler letto con pazienza arriva alle stesse conclusioni, ma comprimere il lavoro: leggere il grafo, correlarlo al codice sorgente reale, e produrre una lista ordinata con una motivazione per ciascuna voce. Quello che ci avrebbe richiesto un pomeriggio è diventato una conversazione di pochi minuti, e abbiamo comunque verificato ogni candidato sui numeri grezzi del profiler prima di crederci.

La classifica, ripulita:

  1. getIcomponent / getQcomponent, chiamate due volte per campione, ~300 bin per sweep. Pura manipolazione di bit con un ramo sul segno: le più calde in assoluto.
  2. parseDataMessage, il loop che le orchestra e alloca gli array I/Q a ogni sweep.
  3. bufferToUint16Array, lo swap di endianness, eseguito su ogni pacchetto in arrivo.
  4. computeRawPower, I² + Q² bin per bin, su ogni sweep.
  5. convertTodBByHandlingZeroValue, un log10 per bin più la gestione dei valori nulli.
  6. getTargetColor, il mapping valore→livello di colore, chiamato per ogni bin in fase di rendering dei target.
  7. metersByBin, helper geometrico puro, piccolo ma chiamato spessissimo.

Funzioni tutte concentrate nella pipeline numerica, dall’estrazione delle componenti I/Q al disegno dei target, tutte loop stretti su centinaia di bin per migliaia di sweep al secondo. Un bersaglio perfetto.

La libreria radar-console-utils

radar-console-utils è un crate a sé (un repository separato, accanto a quello del radar), compilato in WebAssembly con wasm-bindgen e impacchettato da wasm-pack. Il codice è pubblico su GitHub: github.com/FancyPixel/radar-console-utils, gli snippet che seguono sono estratti da lì. Le funzioni vengono raggruppate in struct che fanno da namespace lato JavaScript, MathUtils per i calcoli su potenza/colore, DataUtils per il parsing dei pacchetti, così da Node si chiamano come metodi statici (MathUtils.computeRawPowerFast(...)). Il Cargo.toml è il file di manifesto del crate, l’equivalente Rust di un package.json: dichiara nome e versione del pacchetto, le dipendenze e come dev’essere compilato. Nella sua versione essenziale:

[package]
name = "radar-console-utils"
version = "0.1.1"
edition = "2024"

[lib]
# cdylib: artefatto WebAssembly per wasm-pack; rlib: test nativi e uso come dipendenza Rust
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"

# Profilo ottimizzato per ridurre la dimensione del binario WebAssembly
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true

Vediamo la traduzione di una di queste funzioni, computeRawPower, la più lineare da mostrare; le funzioni di parsing I/Q in DataUtils (parseDataMessage, getIcomponent/getQcomponent) seguono esattamente lo stesso schema. La versione JavaScript originale:

export function computeRawPower(rawData: { I: number[]; Q: number[] }, out?: number[]): number[] {
  const { I, Q } = rawData;
  const { length } = I;
  const powerData = out && out.length >= length ? out : new Array<number>(length);
  for (let i = 0; i < length; i += 1) {
    powerData[i] = I[i] ** 2 + Q[i] ** 2;
  }
  return powerData;
}

E la sua versione Rust, esposta a JS con wasm-bindgen. Il loop I² + Q² su slice è esattamente lo schema che il compilatore auto-vettorizza, e la pre-allocazione evita i ridimensionamenti dinamici:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct MathUtils; // struct che fa da namespace

#[wasm_bindgen]
impl MathUtils {
    #[wasm_bindgen(js_name = "computeRawPowerFast")]
    pub fn compute_raw_power_fast(i_data: &[f64], q_data: &[f64]) -> Vec<f64> {
        let len = i_data.len().min(q_data.len());
        let mut power_data = Vec::with_capacity(len);
        for idx in 0..len {
            power_data.push(i_data[idx].powi(2) + q_data[idx].powi(2));
        }
        power_data
    }
}

L’attributo #[wasm_bindgen(js_name = "...")] ci permette di mantenere il naming camelCase idiomatico lato JS pur scrivendo snake_case in Rust. Sotto lo stesso namespace MathUtils vivono anche linear2dB (la conversione in dB) e getTargetColor (il mapping valore→colore), tradotte con lo stesso schema. L’API esposta al momento è:

// pkg/radar_console_utils.d.ts (generato da wasm-pack)
export class MathUtils {
  static computeRawPowerFast(i_data: Float64Array, q_data: Float64Array): Float64Array;
  static convertTodBByHandlingZeroValue(power_data: Float64Array): Float64Array;
  static linear2dB(power: number): number;
  static metersByBin(bin: number, miles: number): number;
  static getTargetColor(levels: Float64Array, value: number, min_visible_color_level: number): number;
}
export class DataUtils {
  static parseDataMessage(word_vector: Uint16Array, sp_data_exponent: number, samples_number: number): ParsedData;
  static getIcomponent(data: Uint16Array, offset: number, multiplier: number): number;
  static getQcomponent(data: Uint16Array, offset: number, multiplier: number): number;
  static bufferToUint16Array(bytes: Uint8Array): Uint16Array;
}
export class ParsedData { i: Float64Array; q: Float64Array; }
export function getApiVersion(): string;

Da notare un dettaglio che ha un impatto a runtime: i parametri &[u16]/&[f64] lato Rust diventano Uint16Array/Float64Array lato JS. Passare un normale number[] non funziona, i dati vanno convertiti in un typed array, ed è proprio quella la copia al confine di cui parlavamo sopra. Le funzioni di parsing dei pacchetti (parseDataMessage, getIcomponent/getQcomponent, bufferToUint16Array) vivono nel namespace DataUtils; quelle di calcolo su potenza/colore in MathUtils, rispettando la suddivisione originale tra parsing dei pacchetti e calcoli sul segnale.

Per il build usiamo wasm-pack, che compila il crate verso il target wasm32-unknown-unknown, ottimizza il binario e genera il pacchetto utilizzabile da Node. Il nome del target dice già la sua funzione: wasm32 è l’architettura WebAssembly a 32 bit, mentre i due unknown stanno per vendor e sistema operativo ignoti, cioè nessuno. È esattamente ciò che ci serve: un binario che non fa alcuna assunzione sull’ambiente ospite (né OS né librerie di sistema) e proprio per questo gira identico ovunque, dentro Node come nel browser. In pratica evitiamo un target legato a una piattaforma specifica in favore del WebAssembly “puro”, che è quello che ci dà la portabilità. I prerequisiti una tantum:

# Target di compilazione WebAssembly
rustup target add wasm32-unknown-unknown

# wasm-pack
cargo install wasm-pack

Il flag chiave è --target nodejs, che genera glue code basato su require/module.exports (CommonJS) invece che per bundler o browser:

# Build di produzione (ottimizzata)
wasm-pack build --target nodejs --release

# In alternativa, build di sviluppo (più veloce, non ottimizzata)
wasm-pack build --target nodejs --dev

L’output finisce nella cartella pkg/: il binario radar_console_utils_bg.wasm, il glue radar_console_utils.js, i tipi radar_console_utils.d.ts e un package.json già pronto. Un solo .wasm, valido per tutte le piattaforme: niente build separate per Windows/macOS/Linux, che è esattamente il motivo che ci ha fatto preferire WASM a un addon nativo.

Perché il processo Main risolva l’import, la libreria va dichiarata tra le dipendenze del progetto radar. wasm-pack genera dentro pkg/ un package.json con main (radar_console_utils.js) e types già configurati: quindi basta puntare esattamente lì. Essendo radar-console-utils un repository separato accanto a quello del radar, usiamo una dipendenza locale con il prefisso file: verso la cartella pkg/ generata:

{
  "dependencies": {
    "radar-console-utils": "file:../radar-console-utils/pkg"
  }
}

Dopo un yarn install (o npm install) la libreria viene linkata in node_modules/radar-console-utils e require('radar-console-utils') la trova come qualsiasi altro pacchetto, con un unico .wasm al seguito, senza le optionalDependencies platform-specific che servirebbero a un addon nativo. Se in futuro la pubblicassimo su un registry npm (anche privato), l’unica modifica sarebbe sostituire il path locale con nome e versione:

{
  "dependencies": {
    "radar-console-utils": "^1.0.0"
  }
}

Il codice non cambia: l’import/require resta identico, cambia solo da dove npm/yarn risolve il pacchetto.

E le prestazioni? Qui bisogna dire la verità: su queste funzioni, prese una a una, non abbiamo ancora un guadagno netto convincente, e in parte ce lo aspettavamo. Sono loop piccoli e regolari, proprio il tipo di codice che il JIT di V8 ottimizza già molto bene; e il vantaggio del nativo viene eroso dal confine, perché la copia degli array all’andata e l’Array.from al ritorno aggiungono lavoro e allocazioni proprio dove volevamo toglierne. Il calcolo in sé è più veloce, ma su scansioni da poche centinaia di bin il costo del trasferimento può mangiarsi il margine. Quello che questa prima fase ci ha dato non è un numero da sbandierare: è un’integrazione Rust→WASM che gira end-to-end e produce risultati identici al JavaScript (lo verifichiamo con un test di equivalenza, più sotto). Ed è esattamente la base che ci serviva per il passo successivo.

Sostituire le chiamate

Con la libreria linkata, la sostituzione nel codice è sorprendentemente semplice: si importa MathUtils da radar-console-utils e si rimpiazzano le chiamate alle funzioni JS con i metodi statici corrispondenti. Dove prima si importava computeRawPower dalle utility del segnale, ora entra MathUtils:

- import { computeRawPower, convertTodBByHandlingZeroValue, /* … */ } from './signal';
+ import { convertTodBByHandlingZeroValue, /* … */ } from './signal';
+ import { MathUtils } from 'radar-console-utils';

Nel punto dove viene chiamata la funzione si cambia solo l’invocazione, e qui si paga l’overhead dell’utilizzo del WASM: gli array vanno convertiti in Float64Array in ingresso, e il Float64Array di ritorno va riportato a number[] con Array.from dove il resto del codice si aspetta un array normale:

- const realRawData = computeRawPower(rawData);
+ const realRawData = Array.from(MathUtils.computeRawPowerFast(dataI, dataQ));

Lo stesso vale per le altre funzioni spostate:

const dB = MathUtils.linear2dB(value);
const meters = MathUtils.metersByBin(i, miles);
const newLevel = MathUtils.getTargetColor(new Float64Array(rangeLevels), dBValue, minVisibleColorLevel);

Nessuna modifica al resto della pipeline, e, vale la pena sottolinearlo, nessuna modifica alla configurazione di webpack: il pacchetto generato da wasm-pack --target nodejs è CommonJS standard, e webpack 5 (target electron-main) lo risolve come qualsiasi altra dipendenza in node_modules, .wasm incluso. L’unico costo a runtime è la copia dei dati al confine.

Questa è l’integrazione “diretta” con cui abbiamo validato correttezza e velocità. Prima di portarla in produzione, il passo di hardening naturale è incapsularla dietro un piccolo wrapper con fallback automatico al JavaScript e un flag, così da poter spegnere il percorso WASM a runtime se in mare emergesse un comportamento inatteso:

// wrapper con fallback automatico (hardening successivo)
import * as jsImpl from './signal';
import Configs from './config';

let wasm: typeof import('radar-console-utils') | null = null;
try { wasm = require('radar-console-utils'); } catch { /* fallback a JS */ }

export function computeRawPower(rawData: { I: number[]; Q: number[] }): number[] {
  if (wasm && Configs.configs.debugParams.useNativeMath) {
    return Array.from(wasm.MathUtils.computeRawPowerFast(
      Float64Array.from(rawData.I), Float64Array.from(rawData.Q),
    ));
  }
  return jsImpl.computeRawPower(rawData);
}

L’ultimo tassello è il confronto di correttezza, non solo di velocità: un test che fa girare entrambe le implementazioni sullo stesso input e verifica che i risultati coincidano. Una riscrittura veloce ma sbagliata è molto peggio di un JavaScript lento e corretto.

it('WASM e JS producono lo stesso output', () => {
  const rawData = { I: [1, 2, 3], Q: [4, 5, 6] };
  const js = jsImpl.computeRawPower(rawData);
  const rs = Array.from(MathUtils.computeRawPowerFast(
    Float64Array.from(rawData.I), Float64Array.from(rawData.Q),
  ));
  expect(rs).toEqual(js); // [17, 29, 45]
});

Cosa abbiamo imparato

Il punto che ci portiamo a casa non è “Rust batte JavaScript”, sarebbe un titolo da buzzword e, nel nostro caso, nemmeno dimostrato. Il punto è che scegliere lo strumento giusto è una decisione locale, non globale. Electron, React e TypeScript restano la scelta corretta per il 90% di Radar Console: la UI, i controlli, la distribuzione multipiattaforma. Per il 10% che vive in un loop stretto, codice compilato senza GC può essere lo strumento adatto, e farlo girare come WebAssembly ci permette di provarlo senza ereditare il peso di distribuire un binario nativo diverso per ogni piattaforma e versione di Electron.

Il risultato concreto di questa prima fase, quindi, non è un grafico di benchmark: è aver costruito e validato l’integrazione. Oggi abbiamo una libreria Rust compilata in WebAssembly, agganciata all’app senza toccare webpack, con un confine chiaro e un test che garantisce che il nativo produca gli stessi numeri del JavaScript. Sulle funzioni piccole il guadagno è marginale e tutto da misurare, e va bene così: erano il banco di prova per la pipeline, non il premio finale.

Un paio di considerazioni finali. Aggiungere Rust e WASM al progetto ha un costo: una toolchain in più nella pipeline di build, il confine JS↔WASM con le sue copie da tenere d’occhio, e una competenza che non tutto il team padroneggia ancora allo stesso livello. È un costo che ha senso pagare solo quando il profiler, non l’entusiasmo, dice che ne vale la pena. E l’AI, in tutto questo, è stata un acceleratore di analisi, non un oracolo: ci ha aiutato a leggere i profili e a tradurre il primo abbozzo di codice, ma la decisione su cosa riscrivere e la verifica che il risultato fosse corretto sono rimaste, giustamente, lavoro umano.

Il premio vero è il passo successivo, ed è anche il più difficile: portare il clustering, cioè l’anti-clutter (la soppressione del clutter). È di gran lunga la parte più corposa e pesante dell’elaborazione, e non l’abbiamo ancora portato: è in cima alla nostra TODO list. Non è più semplice aritmetica su array, ma matrici, strutture dati e algoritmi con molte allocazioni e diramazioni. Ed è proprio quel terreno, non i piccoli loop numerici, dove un linguaggio senza GC e con memoria controllata può fare una differenza vera, anche perché si attraversa il confine una volta sola invece che a ogni bin. È lì che questa integrazione, oggi solo validata, dovrà dimostrare di valere i numeri. Lo racconteremo, benchmark alla mano, quando ci saremo arrivati. Stay tuned.

Ringraziamenti

Ringrazio Eleonora Torrisi per il continuo supporto e per le caramelle alla frutta.