Capítulo 4. Memoria de WebAssembly
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Quizá algún día también esto sea agradable de recordar.
Virgil
Si WebAssembly va a comportarse como un entorno de ejecución normal, necesita una forma de asignar y liberar memoria para sus actividades de manejo de datos. En este capítulo, te presentaremos cómo emula este comportamiento para que sea eficiente, pero sin el riesgo de los típicos problemas de manipulación de memoria que se ven con lenguajes como C y C++ (aunque sea eso lo que estamos ejecutando). Como potencialmente estamos descargando código arbitrario a través de Internet, ésta es una importante consideración de seguridad.
Todo el concepto de computación suele implicar alguna forma de procesamiento de datos. Tanto si estamos corrigiendo la ortografía de un documento, manipulando una imagen, haciendo aprendizaje automático, secuenciando proteínas, jugando a videojuegos, viendo películas o simplemente haciendo números en una hoja de cálculo, generalmente estamos interactuando con bloques arbitrarios de datos. Una de las consideraciones de rendimiento más cruciales en estos sistemas es cómo llevar los datos a donde deben estar para interrogarlos o transformarlos de algún modo.
Las Unidades Centrales de Proceso (CPU) funcionan más rápido cuando los datos están disponibles en un registro o en una caché en chip.1 Obviamente, se trata de contenedores muy pequeños, por lo que los grandes conjuntos de datos nunca se van a cargar en la CPU en su totalidad. Tenemos que dedicar cierto esfuerzo a mover los datos dentro y fuera de la memoria. El coste de esperar a que los datos se carguen en una de estas ubicaciones es una eternidad en tiempo de reloj de la CPU. Ésta es una de las razones por las que se han vuelto tan complejos. Los chips modernos disponen de todo tipo de multipipeline, bifurcación predictiva y reescritura de instrucciones para mantener ocupado al chip mientras leemos de una red a la memoria principal, de ahí a las cachés multinivel y, finalmente, a donde hay que utilizarla.
Los programas tradicionales han tenido normalmente memoria de pila para gestionar variables a corto plazo de tamaños pequeños o fijos. Utilizan memoria basada en el montón para bloques de datos a más largo plazo y de tamaño arbitrario. En general, no son más que diferentes áreas de la memoria asignada a un programa que se tratan de forma diferente. La memoria de pila se sobrescribe con frecuencia por el flujo y reflujo de las funciones que se llaman durante la ejecución. La memoria de montón se utiliza y se limpia cuando ya no se necesita. Si un programa se queda sin memoria, puede pedir más, pero debe ser razonablemente juicioso sobre cómo la utiliza.2 Hoy en día, los sistemas de paginación virtual y el abaratamiento de la memoria hacen totalmente probable que un ordenador típico pueda tener decenas de gigabytes de memoria. Ser capaz de acceder rápida y eficazmente a bytes individuales de conjuntos de datos potencialmente grandes es una clave importante para un rendimiento decente del software en tiempo de ejecución.
Los programas WebAssembly necesitan una forma de simular estos bloques de memoria sin dar realmente acceso sin restricciones a la privacidad de la memoria de nuestro ordenador. Afortunadamente, aquí hay una buena historia que equilibra comodidad, velocidad y seguridad. Comienza haciendo posible que JavaScript acceda a bytes individuales de la memoria, pero se expandirá más allá de JavaScript para ser una forma genérica de compartir memoria entre entornos anfitriones y módulos WebAssembly.
TypedArrays
Tradicionalmente, JavaScript no ha sido capaz de proporcionar un acceso cómodo a bytes individuales de la memoria. Por eso, la funcionalidad sensible al tiempo y de bajo nivel suele proporcionarla el navegador o algún tipo de complemento. Incluso las aplicaciones Node.js a menudo tienen que implementar algunas funcionalidades en un lenguaje que maneja la manipulación de la memoria mejor de lo que puede hacerlo JavaScript. Esto complica la situación, ya que JavaScript es un lenguaje interpretado y necesitarías un mecanismo eficiente para cambiar el flujo de control de un lado a otro entre el código interpretado, portable, y el código compilado rápido. Esto también complica las Implementaciones, porque una parte de la aplicación es intrínsecamente portátil y otra necesita soporte de bibliotecas nativas en distintos sistemas operativos.
Suele haber un compromiso en el desarrollo de software: los lenguajes o son rápidos o son seguros. Cuando necesitas velocidad bruta, puedes elegir C o C++, ya que proporcionan muy pocas comprobaciones en tiempo de ejecución en el uso y manipulación de datos en memoria. En consecuencia, son muy rápidos. Cuando quieras seguridad, puedes elegir un lenguaje con comprobaciones de límites en tiempo de ejecución en las referencias a matrices. El inconveniente de la compensación por la velocidad es que las cosas son lentas o la carga de la gestión de la memoria recae en elprogramador. Desgraciadamente, es muy fácil meter la pata olvidando asignar espacio, reutilizando la memoria liberada o no liberando el espacio cuando has terminado. Ésta es una de las razones por las que las aplicaciones escritas en estos lenguajes rápidos suelen tener fallos, se bloquean con facilidad y son fuente de muchas vulnerabilidades de seguridad.3
Los lenguajes con recolección de basura, como Java y JavaScript, liberan a los desarrolladores de muchas de las cargas de gestionar la memoria, pero a menudo incurren en una carga de rendimiento en tiempo de ejecución como contrapartida. Una parte del tiempo de ejecución debe buscar constantemente la memoria no utilizada y liberarla. La sobrecarga de rendimiento hace que muchas de estas aplicaciones sean impredecibles y, por tanto, inadecuadas para aplicaciones integradas, sistemas financieros u otros casos de uso sensibles al tiempo.
Asignar memoria no es un gran problema, siempre que la que se cree tenga un tamaño adecuado para lo que quieras poner en ella. Lo complicado es saber cuándo liberar. Obviamente, liberar memoria antes de que un programa haya terminado de utilizarla es malo, pero no hacerlo cuando ya no se necesita es ineficaz y podrías quedarte sin memoria. Lenguajes como Rust consiguen un buen equilibrio entre comodidad y seguridad. El compilador te obliga a comunicar tus intenciones más claramente, pero cuando lo haces, puede ser más eficaz a la hora de limpiar después de ti.
Cómo se gestiona todo esto en tiempo de ejecución suele ser una de las características que definen un lenguaje y su tiempo de ejecución. Como tal, no todos los lenguajes requieren el mismo nivel de soporte. Ésta es una de las razones por las que los diseñadores de WebAssembly no sobreespecificaron características como la recogida de basura en el MVP.
JavaScript es un lenguaje dinámico flexible y , pero históricamente no ha facilitado ni hecho eficiente el tratamiento de bytes individuales de grandes conjuntos de datos. Esto complica el uso de bibliotecas de bajo nivel, ya que los datos tienen que copiarse dentro y fuera de formatos nativos de JavaScript, lo que resulta ineficaz. La clase Array
almacena objetos JavaScript, lo que significa que tiene que estar preparada para tratar con tipos arbitrarios. Muchos de los contenedores flexibles de Python también son igualmente flexibles e hinchados.4 El rápido recorrido y manipulación de la memoria mediante punteros es producto de la uniformidad de los tipos de datos en bloques contiguos. Los bytes son la unidad mínima direccionable, sobre todo cuando se trata de imágenes, vídeos y archivos de sonido.
Los datos numéricos requieren más esfuerzo. Un entero de 16 bits ocupa dos bytes. Un entero de 32 bits, cuatro. La posición 0 de una matriz de bytes puede representar el primer número de este tipo en una matriz de datos, pero el segundo empezará en la posición 4.
JavaScript añadió las interfaces TypedArray para abordar estos problemas, inicialmente en el contexto de la mejora del rendimiento de WebGL. Se trata de porciones de memoria disponibles a través de instancias ArrayBuffer
que pueden tratarse como bloques homogéneos de tipos de datos concretos. La memoria disponible se limita a la instancia ArrayBuffer
, pero puede almacenarse internamente en un formato que resulte conveniente pasar a las bibliotecas nativas.
En el Ejemplo 4-1, vemos la funcionalidad básica de crear una matriz tipada de enteros sin signo de 32 bits.
Ejemplo 4-1. Diez enteros de 32 bits creados en un Uint32Array
var
u32arr
=
new
Uint32Array
(
10
);
u32arr
[
0
]
=
257
;
console
.
log
(
u32arr
);
console
.
log
(
"u32arr length: "
+
u32arr
.
length
);
La salida de la invocación debe tener este aspecto:
Uint32Array(10) [ 257, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] u32arr length: 10
Como puedes ver, funciona como cabría esperar de una matriz de enteros. Ten en cuenta que se trata de enteros de 4 bytes (de ahí el 32 en el nombre del tipo). En el Ejemplo 4-2, recuperamos elArrayBuffer
subyacente del Uint32Array
y lo imprimimos. Esto nos muestra que su longitud es 40. A continuación, envolvemos el búfer con un Uint8Array
que representa una matriz de bytes sin signo e imprimimos su contenido y longitud.
Ejemplo 4-2. Acceder a los enteros de 32 bits como un buffer de bytes de 8 bits
var
u32buf
=
u32arr
.
buffer
;
var
u8arr
=
new
Uint8Array
(
u32buf
);
console
.
log
(
u8arr
);
console
.
log
(
"u8arr length: "
+
u8arr
.
length
);
El código produce el siguiente resultado:
ArrayBuffer { byteLength: 40 } Uint8Array(40) [ 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, … ] u8arr length: 40
El ArrayBuffer
representa los bytes subyacentes en bruto. El TypedArray
es una vista interpretada de esos bytes basada en el tamaño de tipo especificado. Así, cuando inicializamos el Uint32Array
con una longitud de 10, eso significaba diez enteros de 32 bits, que requieren 40 bytes para representarse. El búfer separado se configura para que sea así de grande, de modo que pueda contener los 10 enteros. El Uint8Array
trata cada byte como un elemento individual debido a sudefinición de tamaño.
Si echas un vistazo a la Figura 4-1, es de esperar que veas lo que está pasando. El primer elemento (posición 0) del Uint32Array
es simplemente el valor 257. Ésta es una vista interpretada de los bytessubyacentes en el ArrayBuffer
. El Uint8Array
refleja directamente los bytessubyacentes del buffer. Los patrones de bits de la parte inferior del diagrama reflejan los bits por byte de los dos primeros bytes.
Puede sorprenderte que en haya 1s en los dos primeros bytes. Esto se debe a una confusa noción llamada endianidad que aparece cuando almacenamos números en la memoria.5 En este caso, un sistema little endian almacena primero los bytes menos significativos (los 1s). Un sistemabig endian almacenaría primero los 0. En el gran esquema de las cosas, no importa cómo se almacenen, pero diferentes sistemas y protocolos elegirán uno u otro. Sólo tienes que fijarte en qué formato estás viendo.
Como ya se ha indicado, las clases TypedArray
se introdujeron inicialmente para WebGL, pero desde entonces han sido adoptadas por otras API, como Canvas2D, XMLHttpRequest2, File, Binary WebSockets, etc. Fíjate en que todas ellas son API de E/S y visualización de bajo nivel y orientadas al rendimiento que tienen que interactuar con bibliotecas nativas. La representación de memoria subyacente puede pasarse entre estas capas de forma eficiente. Por estas razones, también son útiles para las instancias de WebAssembly Memory
.
Instancias de memoria de WebAssembly
Un WebAssembly Memory
es un ArrayBuffer
subyacente (oSharedArrayBuffer
, como veremos más adelante) asociado a un módulo. Por el momento, el MVP limita un módulo a tener una sola instancia, pero es probable que esto cambie dentro de poco. Un módulo puede crear su propia instancia Memory
, o puede recibir una de su entorno anfitrión. Estas instancias pueden importarse o exportarse como hemos hecho hasta ahora con las funciones. También hay una sección Memory
asociada en la estructura del módulo que nos saltamos en el Capítulo 3 porque aún no habíamos tratado el concepto. Ahora subsanaremos esa omisión.
En el Ejemplo 4-3, tenemos un archivo Wat que define una instancia Memory
y la exporta con el nombre "memory"
. Esto representa un bloque contiguo de memoria limitado a una instancia ArrayBuffer
concreta. Es el principio de nuestra capacidad para emular matrices homogéneas de bytes en memoria similares a las de C/C++. Cada instancia está formada por uno o varios bloques de 64 kilobytes de páginas de memoria. En el ejemplo, lo inicializamos con una sola página, pero permitimos que crezca hasta 10 páginas para un total de 640 kilobytes, que debería ser suficiente para cualquiera.6 Verás cómo aumentar la memoria disponible momentáneamente. Por ahora, sólo vamos a escribir los bytes 1, 1, 0 y 0 al principio de la memoria intermedia. La instrucción i32.const
carga un valor constante en la pila. Queremos escribir al principio de nuestro búfer, así que utilizamos el valor 0x0
. La instrucción data
es útil para inicializar partes de nuestra instancia Memory
.
Ejemplo 4-3. Creación y exportación de una instancia de Memory
en un módulo WebAssembly
(
module
(
memory
(
export
"memory"
)
1
10
)
(
data
(
i32.const
0x0
)
"\01\01\00\00"
)
)
Si compilamos este archivo a su representación binaria con wat2wasm
y luego invocamos wasm-objdump
, veremos algunos detalles nuevos que aún no habíamos encontrado:
brian@tweezer ~/g/w/s/ch04> wasm-objdump -x memory.wasm memory.wasm: file format wasm 0x1 Section Details: Memory[1]: - memory[0] pages: initial=1 max=10 Export[1]: - memory[0] -> "memory" Data[1]: - segment[0] memory=0 size=4 - init i32=0 - 0000000: 0101 0000
Hay una instancia Memory
configurada en la sección Memory
que refleja nuestro tamaño inicial de una página y un tamaño máximo de 10 páginas. Vemos que se exporta como "memory"
en la sección Export
. También vemos que la sección Data
ha inicializado nuestra instancia de memoria con los cuatro bytes que escribimos en ella.
Ahora podemos utilizar nuestra memoria exportada importándola en algún JavaScript del navegador. En este ejemplo, cargaremos el módulo y obtendremos la instancia Memory
. A continuación, mostraremos el tamaño del búfer en bytes, el número de páginas y lo que hay actualmente en el búfer de memoria.
La estructura básica de nuestro archivo HTML se muestra enel Ejemplo 4-4. Tenemos una serie de elementos <span>
que se rellenarán con los detalles mediante una función llamada showDetails()
, que tomará una referencia a nuestra instancia de memoria.
Ejemplo 4-4. Mostrar detalles de Memory
en el navegador
<!doctype html>
<html
lang=
"en"
>
<head>
<meta
charset=
"utf-8"
>
<link
rel=
"stylesheet"
href=
"bootstrap.min.css"
>
<title>
Memory</title>
<script
src=
"utils.js"
></script>
</head>
<body>
<div
class=
"container"
>
<h1>
Memory</h1>
<div>
Your memory instance is<span
id=
"mem"
></span>
bytes.</div>
<div>
It has this many pages:<span
id=
"pages"
></span>
.</div>
<div>
Uint32Buffer[0] =<span
id=
"firstint"
></span>
.</div>
<div>
Uint8Buffer[0-4] =<span
id=
"firstbytes"
></span>
.</div>
</div>
<button
id=
"expand"
>
Expand</button>
<script>
<!--
Shown
below
-->
</script>
</body>
</html>
En el Ejemplo 4-5, vemos el JavaScript para nuestro elemento <script>
. Fíjate primero en la llamada a fetchAndInstantiate()
. Se comporta de la misma forma que hemos visto antes en cuanto a la carga del módulo. Aquí obtenemos una referencia a la instancia Memory
a través de la secciónexports
. Adjuntamos una función onClick()
para nuestro botón que abordaremos momentáneamente.
Ejemplo 4-5. El código JavaScript de nuestro ejemplo
function
showDetails
(
mem
)
{
var
buf
=
mem
.
buffer
;
var
memEl
=
document
.
getElementById
(
'mem'
);
var
pagesEl
=
document
.
getElementById
(
'pages'
);
var
firstIntEl
=
document
.
getElementById
(
'firstint'
);
var
firstBytesEl
=
document
.
getElementById
(
'firstbytes'
);
memEl
.
innerText
=
buf
.
byteLength
;
pagesEl
.
innerText
=
buf
.
byteLength
/
65536
;
var
i32
=
new
Uint32Array
(
buf
);
var
u8
=
new
Uint8Array
(
buf
);
firstIntEl
.
innerText
=
i32
[
0
];
firstBytesEl
.
innerText
=
"["
+
u8
[
0
]
+
","
+
u8
[
1
]
+
","
+
u8
[
2
]
+
","
+
u8
[
3
]
+
"]"
;
};
fetchAndInstantiate
(
'memory.wasm'
).
then
(
function
(
instance
)
{
var
mem
=
instance
.
exports
.
memory
;
var
button
=
document
.
getElementById
(
"expand"
);
button
.
onclick
=
function
()
{
try
{
mem
.
grow
(
1
);
showDetails
(
mem
);
}
catch
(
re
)
{
alert
(
"You cannot grow the Memory any more!"
);
};
};
showDetails
(
mem
);
});
Por último, llamamos a la función showDetails()
y pasamos nuestra variable mem
. Esta función recuperará el ArrayBuffer
subyacente y las referencias a nuestros distintos elementos <span>
para mostrar los detalles. La longitud del búfer se almacena en el campo innerText
de nuestro primer<span>
. El número de páginas es esta longitud dividida por 64 KB para indicar el número de páginas. A continuación, envolvemos el ArrayBuffer
con unUint32Array
, que nos permite obtener nuestros valores de memoria como enteros de 4 bytes. El primer elemento de esto se muestra en el siguiente <span>
. También envolvemos nuestro ArrayBuffer
en Uint8Array
y mostramos los cuatro primeros bytes. Después de nuestra discusión anterior, los detalles mostrados enla Figura 4-2 no deberían sorprenderte.
La función onClick()
llama a un método de la instancia Memory
para aumentar el tamaño asignado en una página de memoria. Esto hace que el ArrayBuffer
original se desprenda de la instancia, y los datos existentes se copian encima. Si tenemos éxito, volvemos a invocar la funciónshowDetails()
y extraemos el nuevo ArrayBuffer
. Si se pulsa el botón una vez, deberías ver que la instancia representa ahora dos páginas de memoria que representan 128 KB de memoria. Los datos del principio no deberían haber cambiado.
Si pulsas el botón demasiadas veces, el número de páginas asignadas superará la cantidad máxima especificada en de 10 páginas. En ese momento, ya no será posible ampliar la memoria y se lanzará un mensaje RangeError
. Nuestro ejemplo mostrará una ventana de alerta cuando esto ocurra.
Uso de la API de memoria de WebAssembly
El método grow()
que hemos utilizado en el ejemplo anterior forma parte de la API JavaScript de WebAssembly que el MVP espera que proporcionen todos los entornos de host. Podemos ampliar nuestro uso de esta API e ir en la otra dirección. es decir, podemos crear unaMemory
instancia en JavaScript y ponerla a disposición de un módulo. Ten en cuenta el límite actual de una instancia por módulo.
En capítulos posteriores, veremos usos más elaborados de la memoria, pero tendremos que utilizar un lenguaje de nivel superior al Wat para hacer algo serio. Por ahora, mantendremos nuestro ejemplo en el lado más sencillo, pero intentaremos ampliarlo más allá de lo que hemos visto.
Empezaremos con el HTML para que puedas ver todo el flujo de trabajo, y luego nos sumergiremos en los detalles del nuevo módulo. Enel Ejemplo 4-6, puedes ver que estamos utilizando una estructura HTML similar a la que hemos utilizado hasta ahora. Hay un elemento <div>
con el ID de container
en el que colocaremos una serie de números de Fibonacci. Si no estás familiarizado con estos números, son muy importantes en muchos sistemas naturales, y te animamos a que los investigues por tu cuenta. Los dos primeros números se definen como 0
y 1
. Los números siguientes se definen como la suma de los dos anteriores. Así, el tercer número será 1
(0 + 1). El cuarto número será "2"
(1 + 1). El quinto número será 3
(2 + 1), etc.
Ejemplo 4-6. Crear un Memory
en JavaScript e importarlo al módulo
<!doctype html>
<html
lang=
"en"
>
<head>
<meta
charset=
"utf-8"
>
<link
rel=
"stylesheet"
href=
"bootstrap.min.css"
>
<title>
Fibonacci</title>
<script
src=
"utils.js"
></script>
</head>
<body>
<div
id=
"container"
></div>
<script>
var
memory
=
new
WebAssembly
.
Memory
({
initial
:
10
,
maximum
:
100
});
var
importObject
=
{
js
:
{
mem
:
memory
}
};
fetchAndInstantiate
(
'memory2.wasm'
,
importObject
).
then
(
function
(
instance
)
{
var
fibNum
=
20
;
instance
.
exports
.
fibonacci
(
fibNum
);
var
i32
=
new
Uint32Array
(
memory
.
buffer
);
var
container
=
document
.
getElementById
(
'container'
);
for
(
var
i
=
0
;
i
<
fibNum
;
i
++
)
{
container
.
innerText
+=
`
Fib
[
$
{
i
}]
:
$
{
i32
[
i
]}
\
n
`
;
}
});
</script>
</body>
</html>
El cálculo real está escrito en Wat y se muestra enel Ejemplo 4-7, pero antes de llegar a él, vemos la creación de la instancia Memory
en la primera línea del elemento <script>
. Estamos utilizando la API de JavaScript, pero la intención es la misma que la de nuestro uso del elemento (memory)
en el Ejemplo 4-3. Creamos un tamaño inicial de una página de memoria y un tamaño máximo de 10 páginas. En este caso, nunca necesitaremos más de una página, pero ahora verás cómo hacerlo. La instancia Memory
se pone a disposición del módulo a través deimportObject
. Como verás enseguida, la función del módulo Wasm tomará un parámetro que indica cuántos números de Fibonacci escribir en el búfer Memory
. En este ejemplo, pasaremos un parámetro de 20.
Una vez instanciado nuestro módulo, llamamos a su función exportada fibonacci()
. Tenemos acceso a la variable memory
desde arriba, así que podemos recuperar el ArrayBuffer
subyacente directamente una vez finalizada la invocación a la función. Como los números de Fibonacci son enteros, envolvemos el búfer en una instancia de Uint32Array
para poder iterar sobre los elementos individuales. Al recuperar los números, no tenemos que preocuparnos de que sean enteros de 4 bytes. Al leer cada valor, ampliamos el innerText
de nuestro elemento container
con una versión en cadena del número.
El cálculo que se muestra en el Ejemplo 4-7 va a ser bastante más complicado que cualquier Wat que hayamos visto hasta ahora, pero abordándolo por partes deberías ser capaz de resolverlo.
Ejemplo 4-7. Cálculos de Fibonacci expresados en Wat
(
module
(
memory
(
import
"js"
"mem"
)
1
)
(
func
(
export
"fibonacci"
)
(
param
$n
i32
)
(
local
$index
i32
)
(
local
$ptr
i32
)
(
i32.store
(
i32.const
0
)
(
i32.const
0
)
)
(
i32.store
(
i32.const
4
)
(
i32.const
1
)
)
(
set_local
$index
(
i32.const
2
)
)
(
set_local
$ptr
(
i32.const
8
)
)
(
block
$break
(
loop
$top
(
br_if
$break
(
i32.eq
(
get_local
$n
)
(
get_local
$index
)
)
)
(
i32.store
(
get_local
$ptr
)
(
i32.add
(
i32.load
(
i32.sub
(
get_local
$ptr
)
(
i32.const
4
)
)
)
(
i32.load
(
i32.sub
(
get_local
$ptr
)
(
i32.const
8
)
)
)
)
)
(
set_local
$ptr
(
i32.add
(
get_local
$ptr
)
(
i32.const
4
)
)
)
(
set_local
$index
(
i32.add
(
get_local
$index
)
(
i32.const
1
)
)
)
(
br
$top
)
)
)
)
)
El
Memory
se importa desde el entorno anfitrión.La función
fibonacci
está definida y exportada.$index
es nuestro contador de números.$ptr
es nuestra posición actual en la instanciaMemory
.La función
i32.store
escribe un valor en la ubicación especificada del búfer.La variable
$index
avanza a 2 y la$ptr
se ajusta a 8.Definimos un bloque con nombre al que volver en nuestros bucles.
Definimos un bucle con nombre en nuestro bloque.
Salimos de nuestro bucle cuando la variable
$index
es igual al parámetro$n
.Escribimos la suma de los dos elementos anteriores en la ubicación actual de
$ptr
.Avanzamos la variable
$ptr
en 4 y la variable$index
en 1.Rompemos al principio de nuestro bucle.
Esperemos que las notas numéricas adjuntas al Ejemplo 4-7 tengan sentido, pero dada su complejidad, merece una rápida discusión. Se trata de una máquina virtual basada en la pila, por lo que todas las instrucciones implican manipular la parte superior de la pila. En la primera llamada, importamos la memoria definida en el JavaScript. Representa la asignación por defecto de una página, que debería ser suficiente por ahora. Aunque ésta es una implementación correcta, no es una implementación demasiado segura. Las entradas erróneas podrían estropear el flujo, pero eso nos preocupará más cuando introduzcamos la compatibilidad con lenguajes de alto nivel, donde es más fácil manejar esos detalles.
La función exportada está definida para tomar un parámetro $n
que representa el número de números de Fibonacci que hay que calcular.7 Utilizamos dos variables locales definidas en la tercera y cuarta llamadas. La primera representa el número con el que estamos trabajando y por defecto es 0. La segunda actuará como puntero en memoria. Servirá como índice dentro delMemory
memoria intermedia. Recuerda que los valores de datos de i32
representan 4 bytes, por lo que cada avance de$index
implicará avanzar $ptr
en 4. No tenemos la ventaja de TypedArrays
en este lado de la interacción, así que tenemos que manejar estos detalles nosotros mismos. De nuevo, los lenguajes de alto nivel nos protegerán de muchos de estos detalles.
Por definición, los dos primeros números de Fibonacci son 0 y 1, así que los escribimos en el búfer. i32.store
escribe un valor entero en una ubicación. Espera encontrar esos valores en la parte superior de la pila, así que las dos partes siguientes de la sentencia invocan la instrucción i32.const
, que empuja los valores especificados a la parte superior de la pila. En primer lugar, un desplazamiento de 0 indica que queremos escribir al principio de la memoria intermedia. La segunda empuja el número 0 a la pila para indicar el valor que queremos escribir en la posición 0. La línea siguiente repite el proceso para el siguiente número de Fibonacci. El i32
de la línea anterior ocupa 4 bytes, así que escribimos el valor 1 en la posición 4.
El siguiente paso es iterar sobre los números restantes, que se definen cada uno como la suma de los dos anteriores. Por eso necesitamos iniciar el proceso con los dos que acabamos de escribir. Avanzamos nuestra variable $index
hasta 2, por lo que necesitaremos $n
- 2 iteraciones del bucle. Hemos escrito dos enteros i32
, así que avanzamos nuestra $ptr
a 8.
Wat hace referencia a varias instrucciones de WebAssembly que conocerás a lo largo del libro. Aquí puedes ver algunas de las construcciones de bucle. Definimos un bloque en la séptima llamada y le damos la etiqueta $break
. El siguiente paso introduce un bucle con un punto de entrada llamado $top
. La primera instrucción del bucle comprueba si $n
y $index
son iguales, lo que indica que hemos manejado todos nuestros números. Si es así, sale del bucle. Si no, continúa.
La instrucción i32.store
de la 10ª llamada escribe en la ubicación $ptr
. Los valores de las variables se empujan a la parte superior de la pila con get_local
. El valor que se escribe allí es la suma de los valores de los dos números anteriores. i32.add
espera encontrar también sus dos sumandos en la parte superior de la pila. Así que cargamos la posición del número entero que es cuatro menos que $ptr
. Esto representa $n
- 1. A continuación, cargamos el entero almacenado en la ubicación de $ptr
menos 8, que representa $n
- 2. i32.add
saca estos sumandos de la parte superior de la pila y escribe su suma de nuevo en la parte superior. La pila contiene ahora este valor en la parte superior y la ubicación del valor actual $ptr
, que es lo que espera i32.store
.
El siguiente paso avanza $ptr
en cuatro, ya que ahora hemos escrito otro número de Fibonacci en la memoria intermedia. Avanzamos $n
en uno y, a continuación, volvemos al principio del bucle y repetimos el proceso. Una vez que hemos escrito $n
números en la memoria, la función vuelve. No es necesario que devuelva nada, ya que el entorno anfitrión tiene acceso al búferMemory
y puede leer los resultados directamente conTypedArrays
, como hemos visto antes.
El resultado de cargar nuestro HTML en el navegador y mostrar los 20 primeros números de Fibonacci se muestra en la Figura 4-3.
Este nivel de detalle sería molesto de tratar regularmente, pero afortunadamente no tendrás que hacerlo. Sin embargo, es importante entender cómo funcionan las cosas a este nivel, y cómo podemos emular bloques continuos de memoria lineal para un procesamiento eficiente.
¡Por fin cuerdas!
Una última discusión antes de seguir adelante es sobre cómo podemos añadir por fin cadenas a nuestro repertorio. En encontrarás muchas más herramientas que te facilitarán aún más las cosas en capítulos posteriores del libro, pero podemos aprovechar algunas ventajas de Wat para escribir cadenas en el búfer Memory
y leerlas en JavaScript.
En el Ejemplo 4-8, puedes ver un módulo muy sencillo que exporta una instancia de Memory
de una página. A continuación, utiliza una instrucción data
para escribir una secuencia de bytes en una ubicación de la memoria del módulo. Comienza en la ubicación 0 y escribe los bytes en la cadena subsiguiente. Es conveniente no tener que convertir las cadenas multibyte en los bytes que las componen, aunque puedes hacerlo si quieres. Esta cadena tiene una frase en japonés y luego su traducción al inglés.8
Ejemplo 4-8. Un uso sencillo de las cadenas en Wat
(
module
(
memory
(
export
"memory"
)
1
)
(
data
(
i32.const
0x0
)
"私は横浜に住んでいました。I used to live in Yokohama."
)
)
Una vez que compilemos el Wat a Wasm, veremos que tenemos una nueva sección poblada en nuestro módulo. Puedes verlo con el comando wasm-objdump
:
brian@tweezer ~/g/w/s/ch04> wasm-objdump -x strings.wasm strings.wasm: file format wasm 0x1 Section Details: Memory[1]: - memory[0] pages: initial=1 Export[1]: - memory[0] -> "memory" Data[1]: - segment[0] memory=0 size=66 - init i32=0 - 0000000: e7a7 81e3 81af e6a8 aae6 b59c e381 abe4 ................ - 0000010: bd8f e382 93e3 81a7 e381 84e3 81be e381 ................ - 0000020: 97e3 819f e380 8249 2075 7365 6420 746f .......I used to - 0000030: 206c 6976 6520 696e 2059 6f6b 6f68 616d live in Yokoham - 0000040: 612e
Las secciones Memory
, Export
, y Data
se rellenan con los detalles de nuestras cadenas escritas en memoria. La instancia se inicializa de esta forma para que cuando un entorno anfitrión lea del búfer, las cadenas ya estén allí.
En el Ejemplo 4-9, ves que tenemos un <span>
para nuestra frase en japonés y otro para nuestra frase en inglés. Para extraer los bytes individuales, podemos envolver un Uint8Array
alrededor del búfer de instancia Memory
que hemos importado del módulo. Observa que sólo envolvemos los 39 primeros bytes. Estos bytes se descodifican a una cadena UTF-8 mediante una instancia TextDecoder
, y luego fijamos el innerText
del <span>
designado para la frase japonesa. A continuación, envolvemos con otro Uint8Array
la parte del búfer que comienza en la posición 39 e incluye los 26 bytes siguientes.
Ejemplo 4-9. Lectura de cadenas de una instancia importada de Memory
<!doctype html>
<html
lang=
"en"
>
<head>
<meta
charset=
"utf-8"
>
<link
rel=
"stylesheet"
href=
"bootstrap.min.css"
>
<title>
Reading Strings From Memory</title>
<script
src=
"utils.js"
></script>
</head>
<body>
<div>
<div>
Japanese:<span
id=
"japanese"
></span></div>
<div>
English:<span
id=
"english"
></span></div>
</div>
<script>
fetchAndInstantiate
(
'strings.wasm'
).
then
(
function
(
instance
)
{
var
mem
=
instance
.
exports
.
memory
;
var
bytes
=
new
Uint8Array
(
mem
.
buffer
,
0
,
39
);
var
string
=
new
TextDecoder
(
'utf8'
).
decode
(
bytes
);
var
japanese
=
document
.
getElementById
(
'japanese'
);
japanese
.
innerText
=
string
;
bytes
=
new
Uint8Array
(
mem
.
buffer
,
39
,
26
);
string
=
new
TextDecoder
(
'utf8'
).
decode
(
bytes
);
var
english
=
document
.
getElementById
(
'english'
);
english
.
innerText
=
string
;
});
</script>
</body>
</html>
En la Figura 4-4, vemos los resultados satisfactorios de leer los bytes del búfer y renderizarlos como cadenas UTF-8.
Por geniales que sean estos resultados, ¿cómo supimos cuántos bytes envolver y en qué lugar buscar las cadenas? Un poco de trabajo detectivesco puede ayudar. La letra "I" mayúscula se representa como 49 en hexadecimal. La salida de wasm-objdump
nos da el desplazamiento en el segmento Data
para cada byte. Vemos el valor 49 por primera vez en la fila que comienza con 0000020:
. El 49 representa el séptimo byte, por lo que la segunda frase comienza en la posición 27, que es 2 × 16 + 7 en decimal, es decir, 39. La cadena japonesa representa los bytes entre 0 y 39. La cadena inglesa comienza en la posición 39.
Pero, ¡un momento! Resulta que hemos contado mal la frase en inglés y nos hemos equivocado por una. Esto parece un esfuerzo molesto y propenso a errores para sacar cadenas de un módulo WebAssembly. Incluso haciendo las cosas por las malas a este bajo nivel se puede hacer mejor. Escribiremos primero las ubicaciones de las cadenas para no tener que averiguarlo por nuestra cuenta.
Mira el Ejemplo 4-10 para ver cómo podemos ser más sofisticados. Ahora tenemos dos segmentos data
. El primero escribe la posición inicial y la longitud de la primera cadena, seguida de la misma información para la segunda. Como estamos utilizando el mismo búfer para los índices y las cadenas, tenemos que tener cuidado con las ubicaciones.
Como nuestras cadenas no son muy largas, podemos utilizar bytes sueltos como desplazamientos y longitudes. Probablemente no sea una buena estrategia en general, pero mostrará cierta flexibilidad adicional. Así, escribimos el valor 4 y el valor 27. Esto representa un desplazamiento de 4 bytes y una longitud de 39. El desplazamiento es de 4 porque tenemos estos cuatro números (como bytes individuales) al principio del búfer y tendremos que saltarlos para llegar a las cadenas. Como ya sabes, 27 es el hexadecimal de 39, la longitud de la cadena japonesa. La frase en inglés comenzará en el índice 4 + 39 = 43, que es 2b en hexadecimal (2 × 16 + 11) y tiene 27 bytes de longitud, que es 1b en hexadecimal (1 × 16 + 11).
El segundo segmento de data
empieza en la posición 0x4
porque necesitamos saltarnos esos desplazamientos y longitudes.
Ejemplo 4-10. Un uso más sofisticado de las cadenas en Wat
(
module
(
memory
(
export
"memory"
)
1
)
(
data
(
i32.const
0x0
)
"\04\27\2b\1b"
)
(
data
(
i32.const
0x4
)
"私は横浜に住んでいました。I used to live in Yokohama."
)
)
En el Ejemplo 4-11, vemos la otra cara de la lectura de las cadenas. Ciertamente, ahora es más complicado, pero también es menos manual, ya que el módulo nos dice exactamente dónde buscar. Otra opción al utilizar TypedArrays
es DataView
, que te permite extraer tipos de datos arbitrarios del búfer Memory
. No es necesario que sean homogéneos como el TypedArrays
normal (por ejemplo, Uint32Array
).
Ejemplo 4-11. Lectura de nuestras cadenas indexadas desde el búfer Memory
<!doctype html>
<html
lang=
"en"
>
<head>
<meta
charset=
"utf-8"
>
<link
rel=
"stylesheet"
href=
"bootstrap.min.css"
>
<title>
Reading Strings From Memory</title>
<script
src=
"utils.js"
></script>
</head>
<body>
<div>
<div>
Japanese:<span
id=
"japanese"
></span></div>
<div>
English:<span
id=
"english"
></span></div>
</div>
<script>
fetchAndInstantiate
(
'strings2.wasm'
).
then
(
function
(
instance
)
{
var
mem
=
instance
.
exports
.
memory
;
var
dv
=
new
DataView
(
mem
.
buffer
);
var
start
=
dv
.
getUint8
(
0
);
var
end
=
dv
.
getUint8
(
1
);
var
bytes
=
new
Uint8Array
(
mem
.
buffer
,
start
,
end
);
var
string
=
new
TextDecoder
(
'utf8'
).
decode
(
bytes
);
var
japanese
=
document
.
getElementById
(
'japanese'
);
japanese
.
innerText
=
string
;
start
=
dv
.
getUint8
(
2
);
end
=
dv
.
getUint8
(
3
);
bytes
=
new
Uint8Array
(
mem
.
buffer
,
start
,
end
);
string
=
new
TextDecoder
(
'utf8'
).
decode
(
bytes
);
var
english
=
document
.
getElementById
(
'english'
);
english
.
innerText
=
string
;
});
</script>
</body>
</html>
Por tanto, envolvemos el búfer exportado Memory
con una instancia DataView
y leemos los dos primeros bytes llamando a la función getUint8()
una vez en la posición 0 y otra en la posición 1. Éstos representan la posición y el desplazamiento en el búfer de la cadena japonesa. Aparte de dejar de utilizar números codificados, el resto de nuestro código anterior es el mismo. A continuación leemos los dos bytes de las posiciones 2 y 3, que representan la posición y la longitud de la frase en inglés. Esto también se convierte en una cadena UTF-8 y se actualiza correctamente esta vez, como se ve en la Figura 4-5.
Como tarea para casa, intenta crear un enfoque aún más flexible que te diga cuántas cadenas hay que leer y cuáles son sus ubicaciones y longitudes. El JavaScript para leerlo puede convertirse en un bucle, y todo el proceso debería ser más flexible.
Hay más cosas que saber sobre las instancias de Memory
, como verás más adelante, pero por ahora, hemos cubierto suficientes aspectos básicos de WebAssembly como para que intentar hacer algo más sofisticado a mano en Wat resulte demasiado doloroso. Por tanto, ¡ha llegado el momento de utilizar un lenguaje de alto nivel como C!
1 Un registro es una posición de memoria en el chip que suele proporcionar a una instrucción lo que necesita para ejecutarse.
2 Mi primer ordenador, un Atari 800, empezó con sólo 16 kilobytes de memoria. ¡Fue todo un acontecimiento el día que mi padre llegó a casa con una tarjeta de expansión de 32 kilobytes!
3 Ryan Levick destaca este punto en su debate sobre el interés de Microsoft por Rust.
4 La biblioteca NumPy ayuda a resolver esto reimplementando el almacenamiento homogéneo en matrices C y disponiendo de formas compiladas de las funciones matemáticas para ejecutarse en esas estructuras.
5 Es una referencia a Los viajes de Gulliver, de Jonathan Swift.
6 ¡Buen intento, pero no, Bill Gates nunca lo dijo!
7 Como ejercicio de reflexión, ¿a qué podría llegar $n
antes de que se desbordara nuestro tipo de datos i32
? ¿Cómo podrías solucionarlo?
8 ¡Es verdad!
Get WebAssembly: La Guía Definitiva now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.