Ejecutar herramientas en el navegador es el nuevo estándar. Librerías como epanet-js han ganado bastante popularidad llevando las simulaciones hidráulicas de Epanet al cliente. Pero más allá de hacer un simple 'npm install', entender cómo ocurre esta conversión te da la libertad de portar cualquier librería escrita en C o C++ a la web.
En este artículo vamos a centrarnos precisamente en eso: el proceso de compilación con Emscripten. Usaremos el código fuente original en C de EPANET 2.2 para mostrar cómo compilar un proyecto a WebAssembly (Wasm) y ejecutar una simulación en un Web Worker sin bloquear el main thread.
Además, añadiremos una función en C de epanet 2.3 para extraer las presiones de todos los nodos en una sola llamada. Así resolvemos un importante cuello de botella de rendimiento que muchas guías introductorias pasan por alto: el overhead del puente JS-Wasm (Context Switching)
Paso 1: Compilando EPANET 2.2 con Emscripten
EPANET está escrito en C estándar, lo que facilita enormemente su portabilidad. Para compilarlo a WebAssembly necesitamos instalar Emscripten (`emcc`):
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh
cd ..
git clone https://github.com/USEPA/EPANET2.2
cd EPANET2.2/SRC_engines
mkdir build && cd build
emcmake cmake .. && emmake make -j$(nproc) CMAKE_BUILD_TYPE=Release
emcc src/solver/libepanet2.a -o epanet.js \
-O3 \
-s EXPORTED_FUNCTIONS='["_EN_createproject", "_EN_open", "_EN_solveH", "_EN_getnodevalue", "_malloc"]' \
-s EXPORTED_RUNTIME_METHODS='["FS", "ccall", "getValue"]' \
-s ENVIRONMENT=worker \
-s ALLOW_MEMORY_GROWTH=1 Entendiendo el proceso de Build
El script anterior automatiza todo el flujo de trabajo. Esto es lo que está ocurriendo:
- 1. Instalación de Emscripten (emsdk): Clonamos el repositorio, instalamos la última versión del compilador y cargamos las variables de entorno necesarias en la sesión actual de la terminal.
- 2. Construcción de la librería de EPANET: Descargamos el código fuente de la versión 2.2. En lugar de compilar archivo por archivo manualmente, utilizamos los wrappers de Emscripten (
emcmakeyemmake) sobre el sistema CMake de EPANET. Desactivamos las librerías compartidas y generamos la librería estáticalibepanet2.a. - 3. Enlazado final a WebAssembly: Tomamos la librería y usamos
emccpara crear el binario final, indicándole qué métodos del Toolkit de EPANET queremos exponer a JavaScript.
Tras ejecutar este script encontrarás dos archivos dentro del directorio EPANET2.2/SRC_engines/build: epanet.js (el código JavaScript de inicialización) y epanet.wasm (el binario).
Paso 2: Ejecutando la simulación
Con nuestros archivos generados, vamos a orquestar la simulación en un archivo epanet.worker.js. Para este ejemplo, descargaremos la red real de Exeter y dividiremos el código en tres bloques funcionales. Nota: Para simplificar la lectura hemos usado any para el módulo EPANET con JSDoc, pero fuera de una prueba de concepto, deberías anotar las firmas de cada función exportada en tus módulos de WebAssembly.
1. El punto de entrada: Descargamos la red y arrancamos el motor WebAssembly de forma asíncrona.
// epanet.worker.js
/// <reference lib="webworker" />
import initEpanetModule from "./epanet.js";
/** @typedef {any} EpanetModule */
self.onmessage = async () => {
try {
const inpPromise = fetch(
"https://raw.githubusercontent.com/OpenWaterAnalytics/epanet-example-networks/master/epanet-tests/exeter/exnet-3.inp",
);
const epanet = await initEpanetModule();
const inpRaw = await inpPromise;
const inpString = await inpRaw.text();
runSimulation(epanet, inpString);
} finally {
self.close();
}
};2. La configuración: Instanciamos el proyecto en la memoria de C, escribimos el texto del archivo INP en el sistema de archivos virtual de Emscripten (FS) y abrimos el archivo.
function runSimulation(
/** @type {EpanetModule} */ epanet,
/** @type {string} */ inpString,
) {
const phPtr = epanet._malloc(4);
epanet._EN_createproject(phPtr);
const ph = epanet.getValue(phPtr, "i32");
const filePath = "inp.inp";
epanet.FS.writeFile(filePath, inpString);
epanet.ccall(
"EN_open",
"number",
["number", "string", "string", "string"],
[ph, filePath, "", ""],
);
const results = getNodePressures(epanet, ph);
self.postMessage({ results });
}3. La extracción de resultados: Este es el enfoque estándar para leer los datos, pidiendo a C la cantidad de nodos e iterando para leer la presión de cada uno.
const EN_NODECOUNT = 0;
const EN_PRESSURE = 11;
function getNodePressures(
/** @type {EpanetModule} */ epanet,
/** @type {number} */ ph,
) {
const nnodesPtr = epanet._malloc(4);
epanet._EN_getcount(ph, EN_NODECOUNT, nnodesPtr);
const nnodes = epanet.getValue(nnodesPtr, "i32");
const doubleType = "double";
const doublePtr = epanet._malloc(8);
const pressures = new Float64Array(nnodes);
for (let i = 0; i < nnodes; i++) {
epanet._EN_getnodevalue(ph, i + 1, EN_PRESSURE, doublePtr);
pressures[i] = epanet.getValue(doublePtr, doubleType);
}
return pressures;
}EN_close, EN_deleteproject, ni liberando los punteros con free? En este ejemplo hemos tomado un atajo: destruimos el Worker entero usando self.close() al finalizar. Esto purga toda la memoria (y el Heap de Wasm) automáticamente por el propio navegador. Sin embargo, en una aplicación real donde el Worker se reutiliza para ejecutar múltiples simulaciones a máximo rendimiento, lo correcto (y obligatorio para evitar fugas de memoria) es gestionar esa liberación de memoria de forma manual tras cada ejecución.Profiling en el Navegador
Si abrimos la pestaña de Performance de las DevTools del navegador y analizamos el Flame Graph de runSimulation, nos encontraremos con un resultado algo contraintuitivo inicialmente.
Nuestra intuición de ingenieros nos diría que el procesamiento pesado de EN_solveH (donde se resuelve el sistema de ecuaciones no lineales de la red) debería ser el gran cuello de botella. Pero los números dicen otra cosa:
EN_open(carga y parseo): ~60% del tiempo de ejecución.EN_solveH(cálculo hidráulico): ~15% del tiempo.- Bucle
_EN_getnodevalue: Apenas un ~1% del tiempo.
¿Por qué ocurre esto? La función EN_open realiza un intenso procesamiento de texto e inicializa gran parte de las estructuras de datos de la red. El parser interno de EPANET tiene que leer las líneas desde el sistema de archivos virtual (FS) de Emscripten, tokenizar cadenas, convertir texto a números de coma flotante y construir las estructuras de datos en memoria. Todo este proceso es bastante ineficiente.
La solución extrema: Evitar el parser
La forma definitiva de reducir drásticamente este tiempo de inicialización pasaría por saltarnos el parser de texto por completo. En lugar de obligar a C a leer y parsear un archivo INP, probablemente ya tenemos la topología de la red en el frontend y pasaríamos los TypedArrays directamente a la memoria. Sin embargo, esta implementación tiene una complejidad técnica mayor y nos obligaría a reescribir y modificar profundamente la gestión interna de estructuras en el código fuente de EPANET.
Para ilustrar una técnica de optimización usaremos un ejemplo mucho más limitado. Aunque en nuestro análisis estático inicial ese bucle solo suponga el 1% del tiempo, en un escenario real de Simulación en Período Extendido (EPS) la situación varía. En simulaciones prolongadas ejecutaremos este bucle repetidas veces en cada paso temporal, no solo para extraer la presión, sino para cada propiedad de los nodos y de los enlaces (caudales, velocidades, calidad...). En este escenario, el overhead de las llamadas JS-Wasm se convierte en un problema de rendimiento evitable.
El cuello de botella oculto: El puente JS-Wasm
Cada vez que JavaScript invoca una función de WebAssembly, se produce un cambio de contexto (Context Switch). El motor debe cruzar la frontera entre la máquina virtual de JS y el entorno aislado de Wasm, validando tipos y serializando datos. Una sola llamada es indetectable, pero si realizas 50.000 llamadas secuenciales dentro de un bucle for , ese overhead pequeño se acumula hasta convertirse en un cuello de botella
Para evitarlo, debemos asegurar que nuestras funciones de wasm realicen suficiente trabajo: el bucle debe ejecutarse a velocidad nativa dentro de C. (Nota: El equipo de EPANET introdujo funciones bulk en la posterior versión 2.3, pero parchear nosotros mismos la 2.2 ilustra cómo aplicar este patrón a cualquier otra librería que queramos llevar a la web).
Modificando el código fuente (C y Header)
Vamos a inyectar una nueva función que acepte el código de la propiedad que queremos leer y un puntero de memoria donde volcará todos los resultados de golpe. Añadimos esto al final de epanet.c:
int DLLEXPORT EN_getnodevalues(EN_Project p, int property, double *values)
/*----------------------------------------------------------------
** Input: property = node property code (see EN_NodeProperty)
** Output: values = array of node property values
** Returns: error code
** Purpose: retrieves an array of node property values
**----------------------------------------------------------------
*/
{
int errcode = 0, i = 0;
for (i = 1; i <= p->network.Nnodes; i++)
{
errcode = EN_getnodevalue(p, i, property, &values[i - 1]);
if (errcode != 0) { return errcode; }
}
return 0;
}Y su correspondiente firma en la cabecera pública epanet2_2.h para que el compilador y nuestro script de Emscripten la reconozcan:
/**
@brief Retrieves an array of property values for all nodes.
@param ph an EPANET project handle.
@param property the property to retrieve (see @ref EN_NodeProperty).
@param[out] out_values an array of values for all nodes.
@return an error code.
Values are returned in units that depend on the units used for flow rate
(see @ref Units).
*/
int DLLEXPORT EN_getnodevalues(EN_Project ph, int property, double *out_values);Extracción en JavaScript con Copia Cero
Con este cambio, nuestro antiguo e ineficiente bucle de extracción se transforma en esto. Alojamos la memoria necesaria (8 bytes por cada double), pedimos a C que la llene y mapeamos una vista Float64Array sobre esa misma dirección de memoria:
function getNodePressures(
/** @type {EpanetModule} */ epanet,
/** @type {number} */ ph,
) {
const nnodesPtr = epanet._malloc(4);
epanet._EN_getcount(ph, EN_NODECOUNT, nnodesPtr);
const nnodes = epanet.getValue(nnodesPtr, "i32");
const bytesPerElement = 8;
const bufferBufferPtr = epanet._malloc(nnodes * bytesPerElement);
epanet._EN_getnodevalues(ph, EN_PRESSURE, bufferBufferPtr);
const pressures = new Float64Array(
epanet.HEAPF64.buffer,
bufferBufferPtr,
nnodes,
);
return pressures;
}_EN_getnodevalues en las funciones exportadas, y reemplazar nuestros archivos.Rendimiento y Transferencia
El resultado de este cambio es notable: nuestra nueva función se ejecuta entre 6 y 10 veces más rápido que el bucle original de JavaScript.
[...pressures] o Array.from() para "trabajar más cómodo". ¡No lo hagas! Esto es precisamente lo que hace internamente el wrapper epanet-js para devolver un number[], y esa simple conversión destruye más de la mitad de las ganancias de rendimiento que acabamos de conseguir. Si necesitas copiar los datos copialos a otro Float64Array.Además, mantener la estructura nativa del Float64Array nos otorga un poder de rendimiento superior en un web worker. . A diferencia de un Array de JS estándar, el ArrayBuffer subyacente de un TypedArray se puede transferir al hilo principal usando self.postMessage(results, [results.buffer]). Esto mueve la referencia de memoria instantáneamente sin coste de copia (Zero-Copy), lo cual es vital para lograr latencia cero en la interfaz.
Conclusión
Llevar software clásico al navegador sin perder rendimiento requiere evitar muchas abstracciones de JavaScript y pensar en cómo se mueven los datos en la memoria.
Si tu producto está sufriendo cuellos de botella, necesitas exprimir cada ciclo de CPU en tu frontend, o buscas implementar arquitecturas Wasm de alto rendimiento, puedo ayudarte
(Y si por otro lado perteneces al sector del agua y directamente buscas una plataforma integral que ya resuelva todo esto, combinando simulaciones hidráulicas en tiempo real, integraciones IoT e IA predictiva, échale un vistazo a waterways-ai.com).