Spaces:
Running
Running
Download js/engine/model.js from UtopiaIA/elffuss-code: direct link, hf CLI and curl.
- Browser
- Download file 74.1 kB
-
https://huggingface.co/spaces/UtopiaIA/elffuss-code/resolve/main/js/engine/model.js
- Command line
-
hf download hf://spaces/UtopiaIA/elffuss-code/js/engine/model.js
-
curl -L -o model.js https://huggingface.co/spaces/UtopiaIA/elffuss-code/resolve/main/js/engine/model.js
74.1 kB
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 UtopiaIA
// Elffuss Runtime · el modelo: carga de pesos y forward de qwen35.
// ─────────────────────────────────────────────────────────────────────────────
// Aquí se encadenan las piezas que ya están verificadas una a una. Esa
// verificación por piezas NO garantiza que el conjunto funcione: puede fallar el
// orden, el layout que espera la siguiente operación, o un residual sumado en el
// sitio equivocado. Por eso el forward está escrito para poder pararse en
// cualquier capa y comparar contra el volcado de un forward real.
//
// Dos tipos de bloque, y el reparto sale del propio fichero:
// · SSM (18 de 24 en el 0.8B, 48 de 65 en el 27B): qkv fusionado → conv causal
// → silu → q/k/v → delta net con estado recurrente → norm con puerta.
// · Atención (el resto): Q y puerta INTERCALADOS en el mismo tensor, QK-norm
// antes de RoPE, RoPE parcial, GQA, y la puerta multiplicando la salida.
// Los dos acaban igual: residual, norm, SwiGLU, residual.
//
// Licencia: código propio, Apache-2.0 (ver SPDX arriba y LICENSE). Sin dependencias externas.
import { parseGGUF } from './gguf.js';
import { leeRotacion } from './hadamard.js';
import { createOps, ROPE_NEOX, ROPE_NORM } from './ops.js';
import { createFlashOps } from './ops.flash.js';
import { readHParams, createState } from './state.js';
import {
copyIntoShader, convWindowShader, convSaveShader, siluShader, mulCabezaShader,
} from './kernels.model.wgsl.js';
import { QUANT_TYPES } from './kernels.wgsl.js';
// Q8_0/Q4_0 reordenados al subir (`__ELFFUSS_REPACK`, apagado por defecto).
import {
reordenActivo, tipoReordenado, comoReordenado, bloquesDe, reordena,
creaBufferR, subeReordenadoPorTrozos,
} from './repack.js';
import { createFusionOps, fusionActiva, puedeGatherNorma } from './ops.fusion.js';
const MiB = 1024 ** 2;
// Sube todos los tensores del fichero al dispositivo, uno por buffer.
// PENDIENTE consciente: un tensor por buffer exige que cada uno quepa en
// maxStorageBufferBindingSize. En escritorio el límite suele sobrar; en otros
// navegadores es mucho menor y la tabla de embeddings, de los tensores más
// grandes del modelo, puede no caber. Ahí hará falta que el matmul lea varios shards, que es
// trabajo del kernel, no de aquí. Se comprueba y se falla claro en vez de
// devolver ceros.
// El tamaño de lote del prefill. Es un `let` con gancho SOLO para poder
// medirlo: comparar dos valores exige cambiarlo en caliente, dentro de la misma
// sesión y alternando el orden. Editando el fichero entre pasadas, lo que se
// mueve la medida de una sesión a otra se mezcla con lo que se compara, y luego
// no hay forma de separar una cosa de la otra.
// 32 y no 128: medido, un lote más corto va algo MÁS rápido (menos posiciones
// enmascaradas que se calculan para tirar) y —lo que importa aquí— cada despacho
// dura menos, que es media defensa contra el cuelgue. La otra media es
// trocear el forward en varios command buffers (ver `cede`/`ctx.corta`).
let loteActual = 32;
// El reparto de la última carga. Lo consume una prueba interna y puede
// consumirlo la UI: es la espera más larga del producto.
let medidaCarga = null;
export function ultimaCarga() { return medidaCarga; }
// Cuántos tensores DEBÍAN subirse en la última carga. No es lo mismo que los del
// fichero: la cabeza MTP (`nextn_predict_layers`) no pertenece al forward y no
// se sube — en el modelo pequeño son 15 de 335. Un banco que compare contra
// `g.tensorCount` falla por construcción, que es lo que pasaba.
let subidosEsperados = 0;
export function ultimosSubidos() { return subidosEsperados; }
export function _setLote(n) { loteActual = Math.max(1, n | 0); }
export function _getLote() { return loteActual; }
// Cada cuántas capas se le da al dispositivo un respiro real para que el compositor
// del sistema pinte. Un forward del 27B son 64 capas; sin trocear, iban todas
// en un solo despacho larguísimo y la pantalla se quedaba sin pintar mientras
// tanto. Ver `ctx.corta`.
let cederCada = 2;
export function _setCeder(n) { cederCada = Math.max(1, n | 0); }
export function _getCeder() { return cederCada; }
// El respiro se cobra por RELOJ, no por capas. Lo que se persigue —que el
// compositor del sistema pinte— es un asunto de milisegundos de pared: da igual
// si en ese rato han pasado dos capas o cuarenta. Contándolo por capas se pagaba
// un frame entero cada dos capas aunque esas dos capas durasen medio frame, y
// como un rAF no se puede partir, el prefill acababa cuantizado al refresco del
// monitor: el reloj lo marcaba la pantalla y no el modelo.
// `cederCada` sigue siendo la granularidad mínima (solo se mira el reloj en esas
// capas) y esto es el límite de frecuencia. A 0 se recupera exactamente el
// comportamiento anterior, que es lo que usan los bancos para el A/B.
let cederMs = 100;
export function _setCederMs(ms) { cederMs = Math.max(0, +ms || 0); }
export function _getCederMs() { return cederMs; }
// Cada cuántas capas se CIERRA y envía el command buffer del prefill. Es otra
// cosa que `cederCada`: eso es cuándo se drena la cola y se cede un frame, esto
// es solo cuántos submits se parten. Va con gancho porque la nota de al lado
// afirmaba que trocear en cada capa es «GRATIS» y eso nadie lo había medido:
// una tanda de prefill aprovecha el ancho de banda de pesos 6,6 veces peor que
// un token suelto (medido con una prueba interna) y el prefill es justo el único
// camino que trocea. A 1 se recupera el comportamiento de siempre.
let cortaCada = 1;
export function _setCorta(n) { cortaCada = Math.max(1, n | 0); }
// Lo mismo para el DECODE (ver el bucle de capas). Con gancho para el A/B.
// En CADA capa por defecto. Medido en la misma sesión, tres rondas alternadas:
// ×1,30 en LFM3 y ×1,52 en Qwen3.5-0.8B. Un solo envío al
// final del token dejaba el dispositivo parado mientras la CPU grababa cientos
// de despachos; cortando, empieza con la primera capa. Cortar cada 2, 4 u 8
// capas queda en medio. No cambia la numérica: la cola respeta el orden.
let cortaDecode = globalThis.__ELFFUSS_CORTA_DECODE ?? 1;
export function _setCortaDecode(n) { cortaDecode = Math.max(0, n | 0); }
export function _getCorta() { return cortaCada; }
// Cede un frame al bucle de eventos: en el navegador deja que el compositor
// del sistema componga; fuera del DOM (tests) basta un turno de macrotarea.
//
// Y vuelve TAMBIÉN en cuanto se pide parar. No es un adorno: con la pestaña en
// segundo plano el navegador deja de dar frames —el rAF no llega hasta que la
// pestaña se vuelve a ver—, y un prefill esperando aquí no podría atender un
// «para» hasta entonces. Con la señal, parar no depende de que haya pantalla.
const nuevoFrame = (senal = null) => new Promise((r) => {
if (senal?.aborted) { r(); return; }
let id = null, hecho = false;
const fin = () => {
if (hecho) return;
hecho = true;
senal?.removeEventListener?.('abort', fin);
if (id !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id);
r();
};
senal?.addEventListener?.('abort', fin, { once: true });
if (typeof requestAnimationFrame === 'function') id = requestAnimationFrame(() => fin());
else setTimeout(fin, 0);
});
// El error de «se pidió parar». Lleva el NOMBRE estándar, AbortError, para que
// quien llama lo distinga de un fallo de verdad sin leer el texto: un turno
// parado no es un modelo roto, y enseñarlo como «el modelo falló» sería mentir.
export function errorParado(mensaje = 'parado a petición') {
if (typeof DOMException === 'function') return new DOMException(mensaje, 'AbortError');
const e = new Error(mensaje);
e.name = 'AbortError';
return e;
}
export async function loadModel(ranged, device, opts = {}) {
const onProgress = opts.onProgress || (() => {});
const g = await parseGGUF(ranged);
const hp = readHParams(g.meta, g.tensors);
const ops = createOps(device, { debug: opts.debug !== false, subgroupSize: opts.subgroupSize || 0 });
// Atención fusionada (`__ELFFUSS_FLASH`): solo cierres, sin pipelines ni buffers hasta que se use.
ops.flash = createFlashOps(device, { ctx: ops.ctx, despacha: ops.dispatchAny, subgroupSize: opts.subgroupSize || 0 });
const limit = device.limits.maxStorageBufferBindingSize;
// Antes de mover un solo byte: que TODOS los tipos del fichero tengan kernel.
// Un tipo sin soporte descubierto a mitad del forward sería el modelo entero
// ya subido y un error tres capas más abajo; aquí es una línea que dice cuál.
const sinKernel = new Map();
for (const t of g.tensors) {
if (!QUANT_TYPES[t.typeName]) {
if (!sinKernel.has(t.typeName)) sinKernel.set(t.typeName, t.name);
}
}
if (sinKernel.size) {
throw new Error('el fichero trae tipos sin kernel: ' +
[...sinKernel].map(([k, n]) => `${k} (p.ej. «${n}»)`).join(', '));
}
// Las rejillas de los i-quants, subidas antes del primer forward.
await ops.quant.prepare(g.tensors.map(t => t.typeName));
const weights = new Map();
let uploaded = 0;
// LA CABEZA MTP NO SE SUBE. El fichero trae `nextn_predict_layers` bloques de
// mas que el forward: state.js hace `nLayers = block_count - nextn` y nunca los
// recorre. En el modelo grande son 15 tensores que se suben al dispositivo, se
// quedan ahi y no se ejecutan NUNCA: memoria y tiempo de subida tirados en
// cada sesion, sobre un modelo cuya carga ya es la espera mas larga del
// producto.
//
// Se deriva de los METADATOS y no de un «blk.64» escrito a mano: si algun dia
// se implementa la especulacion con esa cabeza, basta con quitar este filtro y
// los pesos vuelven. Y se descuentan tambien de `total`, porque si no la barra
// de progreso no llegaria nunca al 100%.
const kNextn = Object.keys(g.meta).find((k) => k.endsWith('.nextn_predict_layers'));
const kBloques = Object.keys(g.meta).find((k) => k.endsWith('.block_count'));
const nextn = (kNextn && g.meta[kNextn]) || 0;
const nBloques = (kBloques && g.meta[kBloques]) || 0;
const saltados = nextn > 0 && nBloques > 0
? new Set(Array.from({ length: nextn }, (_, i) => `blk.${nBloques - nextn + i}.`))
: new Set();
const sobra = (t) => [...saltados].some((pre) => t.name.startsWith(pre));
const suben = saltados.size ? g.tensors.filter((t) => !sobra(t)) : g.tensors;
const total = suben.reduce((n, t) => n + t.nBytes, 0);
// ── DÓNDE SE VA EL TIEMPO DE CARGA ─────────────────────────────────────────
// Medido contra llama.cpp en la misma sesión y el mismo fichero, ellos cargan
// x3,5 más rápido que nosotros. Es la espera más larga que paga el usuario y
// nadie la había repartido nunca: «la carga tarda» tiene al menos tres causas
// que desde fuera se ven idénticas —traer los bytes, subirlos al dispositivo, y el
// número de viajes— y sin separarlas no se puede atacar ninguna.
//
// Se cuentan los TROZOS además del tiempo: este bucle pide un rango por
// tensor como mínimo, y un modelo pequeño tiene cientos. Si el tiempo está en
// la latencia de cientos de viajes en serie, el arreglo no es un kernel, es
// dejar de esperar uno por uno.
const ahoraC = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
const tCarga0 = ahoraC();
let msLeer = 0, msSubir = 0, trozos = 0;
// ── TRAMOS: una lectura para MUCHOS tensores ────────────────────────────────
// Esto pedía un rango por tensor. Medido en navegador limpio con el modelo
// pequeño: un viaje por tensor, con el 99,9 % del tiempo en LEER, menos del
// 1 % en encolar la subida y nada esperando al dispositivo. Y el coste por viaje
// apenas dependía de su tamaño, que es la firma de la LATENCIA: no se estaba
// llenando el enlace, se estaba esperando cientos de veces de una en una.
//
// En GGUF los tensores van CONTIGUOS, así que se agrupan en tramos de hasta
// TRAMO bytes y cada tramo se trae de una sola lectura; después se reparte en
// memoria, que sale gratis (encolar era el 0,1 %).
//
// Se agrupa SOLO si el tensor empieza justo donde acaba el anterior: si el
// fichero trae relleno entre tensores, agrupar a ciegas no daría error, daría
// pesos de otro sitio. Y un tensor más grande que TRAMO va en su propio tramo
// y se lee a trozos de CHUNK, igual que antes.
// Interruptor para poder medir el ANTES y el DESPUÉS en la MISMA sesión: una
// razón sacada de comparar dos tiradas no vale, porque entre tiradas los
// absolutos se mueven demasiado. A 1 byte cada tensor va en su propio tramo y
// se recupera el comportamiento de una lectura por tensor.
// El tramo por defecto, y no la mitad: barrido en la misma sesión, dos rondas
// por candidato y orden alternado, con una prueba interna. Contra el camino
// viejo —un viaje por tensor— agrupar da del orden de x7. Entre candidatos, de
// la mitad al valor elegido hay una mejora pequeña que se repite en LAS DOS
// rondas; al doble la diferencia cae dentro del ruido y pediría un buffer
// transitorio el doble de grande para nada. Ahí la curva ya está saturada:
// manda el enlace.
const TRAMO = Math.max(1, (globalThis.__ELFFUSS_TRAMO | 0) || 128 * MiB);
const CHUNK = 32 * MiB;
const tramos = [];
for (const t of suben) {
if (t.nBytes > limit) {
throw new Error(`«${t.name}» son ${(t.nBytes / MiB).toFixed(0)} MiB y el ` +
`límite de binding es ${(limit / MiB).toFixed(0)} MiB: hace falta que el ` +
`matmul lea varios shards`);
}
const ult = tramos[tramos.length - 1];
if (ult && t.start === ult.fin && (ult.fin - ult.ini) + t.nBytes <= TRAMO) {
ult.fin = t.start + t.nBytes; ult.ts.push(t);
} else {
tramos.push({ ini: t.start, fin: t.start + t.nBytes, ts: [t] });
}
}
// Holgura de 4 bytes en el buffer: la descodificación de bloques de 34 lee la
// palabra siguiente al recomponer los que no caen alineados.
const nuevoBuf = (t) => device.createBuffer({
label: t.name,
size: Math.ceil((t.nBytes + 4) / 4) * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
// FORMATO REORDENADO (`__ELFFUSS_REPACK`): los Q8_0/Q4_0 se suben con los
// cuantos alineados y las escalas aparte, y su descriptor pasa a decir
// Q8_0R/Q4_0R (ver repack.js). La foto del interruptor se toma UNA vez por
// carga. Apagado, `tipoReordenado` da null siempre y todo lo de abajo hace
// exactamente lo de antes.
const conReorden = reordenActivo();
let reordenados = 0;
for (const tr of tramos) {
const largo = tr.fin - tr.ini;
if (largo > TRAMO) {
// Tensor suelto demasiado grande: camino de trozos, uno solo en el tramo.
const t = tr.ts[0];
const tipoR = tipoReordenado(t, conReorden);
if (tipoR) {
// Los trozos van cortados en bloques enteros y cada uno se escribe en
// sus dos regiones (cuantos y escalas).
const buf = creaBufferR(device, t, tipoR);
await subeReordenadoPorTrozos({
device, ranged, t, buf, tipoR, trozo: CHUNK, ahora: ahoraC,
cuenta: (leer, subir, n) => {
msLeer += leer; msSubir += subir; trozos++;
uploaded += n;
onProgress(uploaded, total, t.name);
},
});
weights.set(t.name, { buf, t: comoReordenado(t, tipoR) });
reordenados++;
continue;
}
const buf = nuevoBuf(t);
for (let off = 0; off < t.nBytes; off += CHUNK) {
const len = Math.min(CHUNK, t.nBytes - off);
const tL = ahoraC();
const raw = new Uint8Array(await ranged.slice(t.start + off, len));
const tS = ahoraC(); msLeer += tS - tL; trozos++;
const pad = len % 4 === 0 ? raw : (() => {
const p = new Uint8Array(Math.ceil(len / 4) * 4); p.set(raw); return p;
})();
device.queue.writeBuffer(buf, off, pad, 0, pad.byteLength);
msSubir += ahoraC() - tS;
uploaded += len;
onProgress(uploaded, total, t.name);
}
weights.set(t.name, { buf, t });
continue;
}
const tL = ahoraC();
const span = new Uint8Array(await ranged.slice(tr.ini, largo));
msLeer += ahoraC() - tL; trozos++;
// Un tramo corto se leería sin error y dejaría ceros dentro de un peso.
if (span.byteLength !== largo) {
throw new Error(`tramo incompleto: pedidos ${largo} bytes desde ${tr.ini} ` +
`y llegaron ${span.byteLength}`);
}
for (const t of tr.ts) {
const tipoR = tipoReordenado(t, conReorden);
const buf = tipoR ? creaBufferR(device, t, tipoR) : nuevoBuf(t);
const tS = ahoraC();
const desde = t.start - tr.ini;
const n4 = Math.ceil(t.nBytes / 4) * 4;
const pad = new Uint8Array(n4);
// Formato reordenado: se escribe DENTRO de la copia de relleno que ya se
// hacía, en vez de copiar tal cual. Mismo tamaño, cero memoria extra.
if (tipoR) reordena(tipoR, span.subarray(desde, desde + t.nBytes), bloquesDe(t), pad);
else pad.set(span.subarray(desde, desde + t.nBytes));
device.queue.writeBuffer(buf, 0, pad, 0, n4);
msSubir += ahoraC() - tS;
uploaded += t.nBytes;
onProgress(uploaded, total, t.name);
weights.set(t.name, { buf, t: tipoR ? comoReordenado(t, tipoR) : t });
if (tipoR) reordenados++;
}
}
// `onSubmittedWorkDone` se mide aparte: es donde se paga de verdad el trabajo
// que `writeBuffer` solo había ENCOLADO. Sin separarlo, subir parecería
// gratis y el tiempo aparecería como un hueco sin dueño.
const tEspera = ahoraC();
await device.queue.onSubmittedWorkDone();
subidosEsperados = suben.length;
medidaCarga = {
tensores: suben.length, saltados: g.tensors.length - suben.length, trozos, bytes: total,
leerMs: msLeer, subirMs: msSubir, esperaMs: ahoraC() - tEspera,
totalMs: ahoraC() - tCarga0,
// Cuántos tensores se subieron reordenados (0 con `__ELFFUSS_REPACK` apagado).
reordenados,
};
console.log(`[engine] carga: ${suben.length} tensores en ${trozos} trozos · ` +
`leer ${msLeer.toFixed(0)} ms · encolar ${msSubir.toFixed(0)} ms · ` +
`esperar GPU ${medidaCarga.esperaMs.toFixed(0)} ms · total ${medidaCarga.totalMs.toFixed(0)} ms`);
// `picoGPU` es el pico de memoria pedida al dispositivo sin reciclar dentro de un
// solo forward. Un prefill largo acumula TODOS los intermedios de todas las
// capas, y cada uno escala con el número de tokens.
return { g, hp, ops, weights, device, bytes: total, picoGPU: () => ops.ctx.picoBytes };
}
export function createSession(model, opts = {}) {
const { device, hp, ops, weights } = model;
// Sonda de diagnostico: cuando esta puesta, cada intermedio con nombre se
// ofrece al llamador. Es lo que permite comparar contra el volcado nodo a
// nodo de un forward real y localizar el paso exacto donde algo se desvia,
// en vez de ver solo que la capa entera no cuadra.
const probe = opts.probe || null;
let capaActual = -1;
// Buffers que una sonda ha pedido CONSERVAR hasta el final del forward. Sin
// esto el mecanismo solo servía para la última capa, y de la forma más
// traicionera que hay: `ctx.recycleDesde` recicla los intermedios en cuanto
// la capa termina —protege los de `tap`, pero no los que se entregan por
// `probe`—, así que leer uno después del forward devolvía el trozo de memoria
// REUTILIZADO por las capas siguientes. Un banco que sondeó nueve capas sacó
// la misma cifra en ocho de ellas, idéntica hasta el decimal, y solo la última
// era real. No fallaba nada: contestaba otra cosa.
// El contrato: si la sonda devuelve `true`, ese buffer no se recicla.
let conservadas = new Set();
const P = (name, buf, n) => {
if (probe && probe(name, buf, n, capaActual) === true) conservadas.add(buf);
return buf;
};
const { quant, nn, ssm: ssmOps, attn, ctx } = ops;
// ── FUSIÓN DE KERNELS DE FILA (`__ELFFUSS_FUSION`, apagada por defecto) ─────
// Cuatro cadenas de despachos pequeños pasan a uno cada una, con la MISMA
// aritmética en el mismo orden (ver `kernels.fusion.wgsl.js`): post-norma +
// residual + norma siguiente (F1), embedding + primera norma (F2), la
// convolución corta tras `in_proj` (F4) y QK-norm + RoPE + escritura en la
// caché (F6). Se decide por PASADA (`fusionPase`), no por sesión: un A/B
// alterna en la misma sesión sin recargar el modelo. Apagada, nada de esto
// se toca y el forward es el de siempre, despacho a despacho.
const fus = createFusionOps(ops);
let fusionPase = false;
// La norma de ENTRADA que ya dejó hecha la capa anterior (F1) o el embedding
// (F2): { capa, peso, buf }, con `capa` el índice de la capa que la consume o
// 'salida' para la norma final, y `peso` el tensor con el que se hizo. Se
// consume una vez: quien la pide la borra. Quien la pide dice además QUÉ peso
// usaría él: si no coincide, no se usa y se calcula la de siempre.
let nrmPendiente = null;
const normaHecha = (capa, peso) => {
const n = nrmPendiente;
nrmPendiente = null;
return n && n.capa === capa && n.peso === peso ? n.buf : null;
};
// Qué norma va DESPUÉS de cada capa: la de entrada de la siguiente o la final.
// Todos los bloques (ssm, conv, atención) empiezan por su `attn_norm`.
const normaSiguiente = new Map((hp.layers || []).map((l, k) => {
const sig = hp.layers[k + 1];
return [l.index, sig ? { capa: sig.index, peso: `blk.${sig.index}.attn_norm.weight` }
: { capa: 'salida', peso: 'output_norm.weight' }];
}));
const state = createState(device, hp, opts);
const W = (name) => {
const w = weights.get(name);
if (!w) throw new Error('falta el tensor «' + name + '»');
return w;
};
const get = (name) => W(name).buf;
const has = (name) => weights.has(name);
// Una proyección. El tipo de cuantización lo pone el FICHERO, no esta función:
// el 0.8B lo trae todo en Q8_0, otros ficheros mezclan varios tipos, y el
// forward es el mismo. Este es el único punto donde el modelo toca la
// cuantización; de aquí en adelante todo es f32.
// ROTACIÓN DE HADAMARD. Si el fichero la declara, las activaciones que entran
// a los tensores enumerados hay que girarlas antes del matmul. No aplicarla no
// falla: carga, ejecuta y devuelve ruido con cara de respuesta.
const rot = leeRotacion((model.g && model.g.meta) || {});
const bufSignos = new Map(); // anchura -> buffer subido una sola vez
const signosDe = (ancho) => {
if (!bufSignos.has(ancho)) {
const v = rot.signos.get(ancho);
if (!v) throw new Error(`la rotación no trae signos para la anchura ${ancho}`);
// NO se pide al pozo de buffers: `ctx.alloc` entrega memoria reciclable y
// `recycle()` la devuelve, así que el mismo buffer acababa siendo el
// DESTINO de otra operación dentro del mismo pase — y WebGPU lo rechaza
// con «includes writable usage and another usage in the same
// synchronization scope», que no dice ni una palabra de reciclaje. Los
// signos viven lo que vive el modelo, como los pesos.
const buf = device.createBuffer({
label: `signos.${ancho}`, size: v.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buf, 0, v);
bufSignos.set(ancho, buf);
}
return bufSignos.get(ancho);
};
// Varios tensores comparten la MISMA activación de entrada (por ejemplo la
// proyección fusionada y la puerta, las dos tras la misma norma). Sin memoria
// se giraría lo mismo dos veces: correcto y tirado a la basura.
let memoRot = new Map();
// Geometría del reordenado de rasgos que pide `gdn_v_grouped`. Solo lo lleva
// la salida del SSM: su activación llega por teselas [hd, nk, rep] y el peso
// se guardó agrupado [hd, rep, nk]. Los nombres salen de la cabecera, no de
// constantes: n_v es el rango del paso temporal y n_k el número de grupos.
const meta = (model.g && model.g.meta) || {};
const clave = (suf) => Object.keys(meta).find((k) => k.endsWith(suf));
const nV = Number(meta[clave('.ssm.time_step_rank')] || 0);
const nK = Number(meta[clave('.ssm.group_count')] || 0);
const permDe = (name, ancho) => {
if (!rot || !rot.vGrouped || !name.includes('.ssm_out.')) return null;
if (!nV || !nK || nV % nK !== 0 || ancho % nV !== 0) {
throw new Error(`geometría de cabezas incompatible con el reordenado: ancho ${ancho}, n_v ${nV}, n_k ${nK}`);
}
return { hd: ancho / nV, nk: nK, rep: nV / nK };
};
const giraEntrada = async (x, ancho, tokens, perm) => {
// La clave lleva el reordenado: `ssm_out` comparte la activación de entrada
// con otros tensores pero su transformada NO es la misma, y compartir la
// memoria entre los dos daría el resultado del primero que pasara.
const k = perm ? `${perm.hd}:${perm.nk}:${perm.rep}` : 'llana';
// La MISMA activación alimenta varios tensores —`nrm` va a siete matmuls en
// una capa— y para todos salvo `ssm_out` la rotación es idéntica. Sin
// memoria se calcula una vez por matmul: cientos de veces por token, y cada
// una escribe su resultado a memoria para que el matmul lo lea de vuelta.
//
// La memoria estuvo APAGADA por una razón buena: se indexaba por el buffer,
// y los buffers se reciclan, así que el que fue una activación podía ser
// otra distinta más adelante y la segunda recibía la rotación de la primera.
// No fallaba, no avisaba, y el modelo decía ruido.
//
// Ahora la clave es la MARCA DE REPARTO (`ctx.marcaDe`): el pozo le pone un
// número distinto a cada buffer cada vez que lo entrega, de modo que un
// buffer reciclado es un valor nuevo y no puede alias con el anterior.
// Sin marca —un buffer que no salió del pozo— no se memoriza nada: es
// preferible repetir el trabajo a arriesgarse a devolver lo que no es.
const marca = ops.ctx.marcaDe?.get(x);
if (marca === undefined || globalThis.__ELFFUSS_ROT_MEMO === false) {
return await nn.hadamard(x, signosDe(ancho), { rows: tokens, ancho, bloque: rot.bloque, perm });
}
const clave = `${marca}:${k}`;
// Diagnóstico: sin contar aciertos no se sabe si la memoria sirve. Un x1,00
// puede ser «no hay nada que ahorrar» o «no está acertando», y son cosas
// muy distintas.
ops.ctx.rotStats = ops.ctx.rotStats || { acierto: 0, fallo: 0 };
if (memoRot.has(clave)) { ops.ctx.rotStats.acierto++; return memoRot.get(clave); }
ops.ctx.rotStats.fallo++;
const y = await nn.hadamard(x, signosDe(ancho), { rows: tokens, ancho, bloque: rot.bloque, perm });
memoRot.set(clave, y);
return y;
};
// Cuenta cuántos tensores rotados se han visto de verdad. Si el fichero
// declara 401 y el forward toca 12, algo no encaja y hay que enterarse.
const rotVistos = new Set();
// GEMV2: proyecciones que ya dejó hechas el despacho fusionado de Q/K/V/puerta
// (ver `qkvFusion`), por nombre de peso. Sin el interruptor está siempre vacío.
const hechas = new Map();
const mm = async (name, x, rows, k, tokens) => {
// Una proyección ya hecha se entrega UNA vez, y solo a quien la pide sobre
// la MISMA entrada: misma marca de reparto, así que un búfer reciclado no
// vale aunque sea el mismo objeto.
if (hechas.size) {
const h = hechas.get(name);
if (h) {
hechas.delete(name);
if (h.marca === ctx.marcaDe?.get(x) && h.rows === rows && h.tokens === tokens) return h.y;
}
}
const w = W(name);
let xin = x;
if (rot && rot.pesos.has(name)) {
rotVistos.add(name);
xin = await giraEntrada(x, k, tokens, permDe(name, k));
}
return quant.matmul(w.t.typeName, w.buf, xin, rows, k, tokens);
};
// Los pesos F32 (normas, ssm_a, dt) se usan tal cual; los cuantizados pasan
// por el matmul, que descuantiza dentro del shader.
const pipe = ctx.pipeline;
const eps = hp.eps;
const D = hp.headDim, H = hp.heads, KVH = hp.kvHeads, E = hp.embd;
const SH = hp.ssmHeads; // cabezas de CLAVE (q, k)
const SV = hp.ssmVHeads; // cabezas de VALOR (v, alpha, beta, salida)
const SS = hp.ssmState, CC = hp.convChannels, CK = hp.convKernel;
const KEY = SH * SS; // key_dim = value_dim = 2048 en el 0.8B
// GEMV fusionados del decode (`__ELFFUSS_GEMV2`, apagado por defecto): gate+up
// con la SwiGLU en el epílogo, y Q/K/V/puerta en un despacho. Están hechos para
// dar bit a bit lo mismo que las matrices sueltas con el GEMV nuevo: el orden
// de la suma de cada fila es el mismo (ver kernels.gemv.wgsl.js).
// Si no aplican —más de 3 tokens, tipos distintos, rotación de Hadamard,
// dispositivo sin subgrupos de 32— no hacen nada y el bloque sigue como siempre.
const gemv2 = quant.gemv || null;
const conRotacion = (name) => !!(rot && (rot.pesos.has(name) || rot.inversos.has(name)));
async function gateUpFusion(i, nrm, tokens) {
if (!gemv2 || tokens > 3) return null;
const ng = `blk.${i}.ffn_gate.weight`, nu = `blk.${i}.ffn_up.weight`;
if (conRotacion(ng) || conRotacion(nu)) return null;
const wg = W(ng), wu = W(nu);
if (wg.t.typeName !== wu.t.typeName) return null;
return gemv2.gateUp(wg.t.typeName, wg.buf, wu.buf, nrm, hp.ffnLength, E, tokens);
}
// Q, K, V y la puerta de una capa de atención en UN despacho. Las salidas no
// se devuelven: se dejan en `hechas`, por nombre de peso, y `mm` las entrega
// cuando el bloque pide esas proyecciones sobre la misma entrada. Así el
// bloque sigue igual línea a línea, y cualquier variante que pida las
// proyecciones en otro orden o desde otro sitio las encuentra hechas.
// Con el mínimo de búferes por etapa las cuatro no caben (nueve búferes):
// entonces van Q/K/V juntas (siete) y la puerta por su GEMV suelto.
async function qkvFusion(i, nrm, tokens) {
hechas.clear();
if (!gemv2 || tokens > 3) return;
const marca = ctx.marcaDe?.get(nrm);
if (marca === undefined) return;
const nombres = [`blk.${i}.attn_q.weight`, `blk.${i}.attn_k.weight`, `blk.${i}.attn_v.weight`];
const filas = [(hp.qGate ? 2 : 1) * H * D, KVH * D, KVH * D];
// La puerta por cabeza solo la usa el bloque cuando no hay puerta en Q.
if (!hp.qGate && hp.headGate) { nombres.push(`blk.${i}.attn_gate.weight`); filas.push(H); }
if (nombres.some(conRotacion)) return;
const ws = nombres.map(W);
const tipo = ws[0].t.typeName;
if (ws.some((w) => w.t.typeName !== tipo)) return;
let n = nombres.length;
let ys = await gemv2.secciones(tipo, ws.map((w) => w.buf), nrm, filas, E, tokens);
if (!ys && n === 4) {
n = 3;
ys = await gemv2.secciones(tipo, ws.slice(0, 3).map((w) => w.buf), nrm, filas.slice(0, 3), E, tokens);
}
if (!ys) return;
for (let j = 0; j < n; j++) hechas.set(nombres[j], { y: ys[j], marca, rows: filas[j], tokens });
}
async function gather(idsBuf, tokens) {
const w = W('token_embd.weight');
const z = await quant.gather(w.t.typeName, w.buf, idsBuf, E, tokens);
// La tabla guarda las filas YA giradas, así que aquí se deshace: h = s ⊙ (H z).
// Los signos van DESPUÉS, al revés que en el camino normal — es la inversa,
// y confundir el orden no falla, da ruido.
if (rot && rot.inversos.has('token_embd.weight')) {
rotVistos.add('token_embd.weight');
return await nn.hadamard(z, signosDe(E), { rows: tokens, ancho: E, bloque: rot.bloque, inverso: true });
}
return z;
}
// FUSIÓN (F2): el embedding y la norma de entrada de la primera capa en un
// despacho, que deja la norma para esa capa (`nrmPendiente`). Solo con el
// lector de bloques de siempre y sin rotación inversa: una tabla girada,
// o con rejilla va por `gather` y la norma de siempre (una reordenada al cargar
// sí entra: ver `puedeGatherNorma`).
async function gatherFusion(idsBuf, tokens) {
const w = W('token_embd.weight');
const l0 = hp.layers[0];
if (!l0 || (rot && rot.inversos.has('token_embd.weight')) || !puedeGatherNorma(w)) {
return await gather(idsBuf, tokens);
}
const { x, nrm } = await fus.gatherNorma(w.t.typeName, w.buf, idsBuf,
get(`blk.${l0.index}.attn_norm.weight`), E, tokens, eps);
nrmPendiente = { capa: l0.index, peso: `blk.${l0.index}.attn_norm.weight`, buf: nrm };
return x;
}
const raw = (label, p, buffers, groups) => ops.dispatchAny(label, p, buffers, groups);
async function silu(src, n) {
const p = await pipe('silu', siluShader());
const dst = ctx.alloc(n * 4, 'silu');
const params = ctx.uniform([new Uint32Array([n, 0, 0, 0])]);
await raw('silu', p, [src, dst, params], Math.ceil(n / 256));
return dst;
}
// `dstF16` va en la CLAVE del pipeline, no solo en el shader: con una sola
// clave, la primera llamada fijaría el compilado y la segunda escribiría con
// el tipo de la primera. La caché saldría escrita a medias y sin un error.
async function copyInto(src, dst, dstOffset, n, srcOffset = 0, dstF16 = false) {
const p = await pipe(`copy_into${dstF16 ? '_h' : ''}`, copyIntoShader(256, dstF16));
const params = ctx.uniform([new Uint32Array([n, dstOffset, srcOffset, 0])]);
await raw('copy_into', p, [src, dst, params], Math.ceil(n / 256));
}
// Corte contiguo de una fila ancha: q, k y v salen del mismo tensor de 6144.
const slice = (src, offset, width, tokens, rowWidth) =>
attn.strideCopy(src, { headDim: width, heads: 1, tokens, stride: rowWidth, offset });
// El cierre común de los dos tipos de bloque: residual, norm, SwiGLU, residual.
// Con `postNorms` (lfm3) la salida del bloque y la del FFN pasan por su propia
// RMSNorm ANTES de sumarse al residual.
async function feedForward(x, layerOut, i, tokens) {
const n = tokens * E;
// FUSIÓN (F1): post-norma + residual + norma del FFN en un despacho. Sin
// fusión `f1` es null y lo de abajo es exactamente lo de siempre.
const f1 = fusionPase ? await fus.normaResidual(layerOut, x,
hp.postNorms ? get(`blk.${i}.post_attention_norm.weight`) : null,
get(`blk.${i}.${hp.ffnNorm}.weight`), tokens, E, eps) : null;
if (hp.postNorms && !f1) {
layerOut = await nn.rmsnorm(layerOut, get(`blk.${i}.post_attention_norm.weight`), tokens, E, eps);
}
const res = f1 ? f1.res : await nn.add(x, layerOut, n);
// El nombre sale del perfil, no de `has(...)`: si se decidiera por presencia
// del tensor, un modelo al que le faltara dejaría de fallar y empezaría a
// mentir con una norma menos.
const nrm = f1 ? f1.nrm : await nn.rmsnorm(res, get(`blk.${i}.${hp.ffnNorm}.weight`), tokens, E, eps);
// gate+up+SwiGLU en un despacho (`__ELFFUSS_GEMV2`); null = camino de siempre.
const f3 = globalThis.__ELFFUSS_GEMV2 === true ? await gateUpFusion(i, nrm, tokens) : null;
const gate = f3 ? null : await mm(`blk.${i}.ffn_gate.weight`, nrm, hp.ffnLength, E, tokens);
const up = f3 ? null : await mm(`blk.${i}.ffn_up.weight`, nrm, hp.ffnLength, E, tokens);
// La intermedia del FFN lleva sonda. No es simetría con los otros nodos:
// las tres matrices del FFN son dos tercios de los pesos del modelo, y si
// esta activación es dispersa, las columnas de `ffn_down` que multiplican a
// un canal despreciable no hace falta ni LEERLAS. Medir eso exige poder
// verla, y sin sonda no se puede.
const sw = P('ffn_sw', f3 || await nn.swiglu(gate, up, tokens * hp.ffnLength), tokens * hp.ffnLength);
let down = await mm(`blk.${i}.ffn_down.weight`, sw, E, hp.ffnLength, tokens);
if (f1) return await cierreFusion(down, res, i, tokens);
if (hp.postNorms) down = await nn.rmsnorm(down, get(`blk.${i}.post_ffw_norm.weight`), tokens, E, eps);
return await nn.add(res, down, n);
}
// FUSIÓN (F1), el cierre del bloque: post-norma del FFN + residual + la norma
// de lo que viene después (la `attn_norm` de la capa siguiente o la final),
// que se deja hecha en `nrmPendiente` para quien la consuma. Esa norma nace
// con los intermedios del FFN de esta capa aún sin reciclar: en un modelo sin
// post-normas el pico de memoria puede llevar una fila de activaciones más;
// con ellas (lfm3) baja, porque las post-normas ya no se materializan.
async function cierreFusion(down, res, i, tokens) {
const sig = normaSiguiente.get(i);
const r = await fus.normaResidual(down, res,
hp.postNorms ? get(`blk.${i}.post_ffw_norm.weight`) : null, get(sig.peso), tokens, E, eps);
nrmPendiente = { capa: sig.capa, peso: sig.peso, buf: r.nrm };
return r.res;
}
// ── LA ÚLTIMA CAPA, SOLO PARA EL ÚLTIMO TOKEN (interruptor GEMM2_COLA) ─────
// De la última capa de un prompt solo se usa la fila del último token: es la
// única que llega a la cabeza. Sus cachés (KV, ventana de la convolución,
// estado recurrente) sí necesitan TODOS los tokens, y se escriben antes de la
// proyección de salida. Así que el corte va justo ahí: la mezcla se calcula
// entera, y la proyección de salida, las normas sándwich, el FFN y las sumas
// residuales se hacen para UNA fila.
//
// Tiene que dar los MISMOS bits. Las normas, las sumas y SwiGLU van fila a
// fila o elemento a elemento, así que una fila sola sale igual que dentro del
// lote. Los matmuls no: con un token caerían en el matvec, que suma en otro
// orden. Por eso van por `quant.gemm2.cola`, que usa el mismo kernel y la misma
// forma que habría tocado al lote entero. Si alguno de los cuatro pesos no
// puede ir así, la capa se calcula como siempre (ver `colaReproducible`).
//
// `colaLote` vale el tamaño del lote SOLO mientras se calcula la última capa
// con el corte puesto; se fija antes de cada capa y se borra al empezar cada
// pasada, para que un forward que lanzó a medias no deje el corte armado.
// `colaCortada` lo pone `colaFinal`: dice que el bloque llegó de verdad al
// corte, que es lo que `unaPasada` exige antes de dar la capa por cortada.
let colaLote = 0;
let colaCortada = false;
const mmCola = async (name, x1, rows, k, lote) => {
const w = W(name);
let xin = x1;
if (rot && rot.pesos.has(name)) {
rotVistos.add(name);
xin = await giraEntrada(x1, k, 1, permDe(name, k));
}
const y = await quant.gemm2.cola(w.t.typeName, w.buf, xin, rows, k, lote);
// `colaReproducible` lo comprobó antes de empezar: si aun así no aplica es
// un fallo de programación, y se dice. Caer al matvec daría otros bits.
if (!y) throw new Error(`cola: «${name}» no se puede calcular con el kernel del lote`);
return y;
};
async function feedForwardCola(x1, out1, i, lote) {
let layerOut = out1;
if (hp.postNorms) {
layerOut = await nn.rmsnorm(layerOut, get(`blk.${i}.post_attention_norm.weight`), 1, E, eps);
}
const res = await nn.add(x1, layerOut, E);
const nrm = await nn.rmsnorm(res, get(`blk.${i}.${hp.ffnNorm}.weight`), 1, E, eps);
const gate = await mmCola(`blk.${i}.ffn_gate.weight`, nrm, hp.ffnLength, E, lote);
const up = await mmCola(`blk.${i}.ffn_up.weight`, nrm, hp.ffnLength, E, lote);
const sw = await nn.swiglu(gate, up, hp.ffnLength);
let down = await mmCola(`blk.${i}.ffn_down.weight`, sw, E, hp.ffnLength, lote);
if (hp.postNorms) down = await nn.rmsnorm(down, get(`blk.${i}.post_ffw_norm.weight`), 1, E, eps);
return await nn.add(res, down, E);
}
// `mezcla` es la salida del bloque ANTES de su proyección de salida, con
// `ancho` columnas; `x` la entrada del bloque (el residual). Devuelve UNA fila.
async function colaFinal(x, mezcla, nombreSalida, ancho, i, tokens) {
colaCortada = true;
const mezclaFila = ctx.alloc(ancho * 4, 'cola.mezcla');
await copyInto(mezcla, mezclaFila, 0, ancho, (tokens - 1) * ancho);
const x1 = ctx.alloc(E * 4, 'cola.x');
await copyInto(x, x1, 0, E, (tokens - 1) * E);
const out1 = await mmCola(nombreSalida, mezclaFila, E, ancho, tokens);
return await feedForwardCola(x1, out1, i, tokens);
}
function colaReproducible(l, tokens) {
const i = l.index;
const salida = l.kind === 'conv' ? [`blk.${i}.shortconv.out_proj.weight`, E, E]
: l.kind === 'ssm' ? [`blk.${i}.ssm_out.weight`, E, hp.ssmInner]
: [`blk.${i}.attn_output.weight`, E, H * D];
return [salida,
[`blk.${i}.ffn_gate.weight`, hp.ffnLength, E],
[`blk.${i}.ffn_up.weight`, hp.ffnLength, E],
[`blk.${i}.ffn_down.weight`, E, hp.ffnLength],
].every(([n, r, k]) => has(n) && quant.gemm2.reproducible(W(n).t.typeName, r, k, tokens));
}
// La convolución CORTA de lfm3. Mismo andamio que la conv del SSM (ventana con
// el estado de los tokens anteriores, conv depthwise causal, guardar el
// estado), pero sin silu y entre dos puertas:
// in_proj → [B | C | x] → B·x → conv(K) → C·y → out_proj
// El orden de los tres trozos es el de llama.cpp (`build_lfm_shortconv`):
// B primero, luego C, luego x. Cambiarlo no peta, da ruido con cara de texto.
async function convBlock(x, i, tokens) {
capaActual = i;
const nrm = P('attn_norm', normaHecha(i, `blk.${i}.attn_norm.weight`) ?? await nn.rmsnorm(x, get(`blk.${i}.attn_norm.weight`), tokens, E, eps), tokens * E);
const bcx = P('in_proj', await mm(`blk.${i}.shortconv.in_proj.weight`, nrm, 3 * E, E, tokens), tokens * 3 * E);
let y;
if (fusionPase && fus.convCabe(CK)) {
// FUSIÓN (F4): de `in_proj` a la entrada de `out_proj` en un despacho —los
// tres cortes, B·x, la ventana, la conv, guardar el estado y C·y—. Solo
// sustituye esa fontanería y deja `y` donde la deja la cadena: la cola del
// bloque (`out_proj` y el FFN) es UNA para los dos caminos, y lo que se le
// cuelgue a esa cola lo ven los dos. Los intermedios `bx` y `conv` no
// llegan a existir, así que esas dos sondas no se ofrecen. Un kernel de
// conv más largo que la tesela del kernel fusionado va por la cadena.
y = await fus.convCorta(bcx, state.ssm.get(i).conv, get(`blk.${i}.shortconv.conv.weight`), E, tokens, CK);
} else {
const b = await slice(bcx, 0, E, tokens, 3 * E);
const c = await slice(bcx, E, E, tokens, 3 * E);
const xs = await slice(bcx, 2 * E, E, tokens, 3 * E);
const bx = P('bx', await attn.mul(b, xs, tokens * E), tokens * E);
const st = state.ssm.get(i);
const window = CK - 1 + tokens;
const pw = await pipe('conv_window', convWindowShader());
const win = ctx.alloc(E * window * 4, 'convwin');
const pwp = ctx.uniform([new Uint32Array([E, tokens, CK - 1, window])]);
await raw('conv_window', pw, [st.conv, bx, win, pwp], Math.ceil(E * window / 256));
const conv = P('conv', await ssmOps.conv(win, get(`blk.${i}.shortconv.conv.weight`), E, tokens, CK), tokens * E);
const ps = await pipe('conv_save', convSaveShader());
const psp = ctx.uniform([new Uint32Array([E, tokens, CK - 1, window])]);
await raw('conv_save', ps, [win, st.conv, psp], Math.ceil(E * (CK - 1) / 256));
y = await attn.mul(c, conv, tokens * E);
}
if (colaLote) return await colaFinal(x, y, `blk.${i}.shortconv.out_proj.weight`, E, i, tokens);
const out = P('conv_out', await mm(`blk.${i}.shortconv.out_proj.weight`, y, E, E, tokens), tokens * E);
return await feedForward(x, out, i, tokens);
}
async function ssmBlock(x, i, tokens) {
capaActual = i;
const nrm = P('attn_norm', normaHecha(i, `blk.${i}.attn_norm.weight`) ?? await nn.rmsnorm(x, get(`blk.${i}.attn_norm.weight`), tokens, E, eps), tokens * E);
// qkv fusionado → ventana de conv (transposición + concatenación con el
// estado del token anterior) → conv depthwise causal → silu.
const qkv = P('qkv', await mm(`blk.${i}.attn_qkv.weight`, nrm, CC, E, tokens), tokens * CC);
const st = state.ssm.get(i);
const window = CK - 1 + tokens;
const pw = await pipe('conv_window', convWindowShader());
const win = ctx.alloc(CC * window * 4, 'convwin');
const pwp = ctx.uniform([new Uint32Array([CC, tokens, CK - 1, window])]);
await raw('conv_window', pw, [st.conv, qkv, win, pwp], Math.ceil(CC * window / 256));
const conv = P('conv_raw', await ssmOps.conv(win, get(`blk.${i}.ssm_conv1d.weight`), CC, tokens), tokens * CC);
const act = P('conv_silu', await silu(conv, CC * tokens), tokens * CC);
// El estado de conv se guarda DESPUÉS de leer la ventana, no antes.
const ps = await pipe('conv_save', convSaveShader());
const psp = ctx.uniform([new Uint32Array([CC, tokens, CK - 1, window])]);
await raw('conv_save', ps, [win, st.conv, psp], Math.ceil(CC * (CK - 1) / 256));
const qRawS = P('q_conv', await slice(act, 0, KEY, tokens, CC), tokens * KEY);
const kRawS = P('k_conv', await slice(act, KEY, KEY, tokens, CC), tokens * KEY);
const q = P('q_l2', await ssmOps.l2norm(qRawS, tokens * SH, SS, eps), tokens * KEY);
const k = P('k_l2', await ssmOps.l2norm(kRawS, tokens * SH, SS, eps), tokens * KEY);
const v = P('v_conv', await slice(act, 2 * KEY, hp.ssmInner, tokens, CC), tokens * hp.ssmInner);
// alpha y beta salen de la ENTRADA normalizada, no del qkv ni de la conv.
const alpha = P('alpha', await mm(`blk.${i}.ssm_alpha.weight`, nrm, SV, E, tokens), tokens * SV);
const gdecay = P('gate', await ssmOps.decayGate(alpha, get(`blk.${i}.ssm_dt.bias`),
get(`blk.${i}.ssm_a`), SV, tokens), tokens * SV);
const betaRaw = await mm(`blk.${i}.ssm_beta.weight`, nrm, SV, E, tokens);
const beta = P('beta', await ssmOps.sigmoid(betaRaw, SV * tokens), tokens * SV);
const o = P('gdn_out', await ssmOps.gatedDeltaNet(q, k, v, gdecay, beta, st.state, SV, tokens, SH), tokens * hp.ssmInner);
// La puerta del norm final sale de su propia proyección.
//
// Los tres nodos que van de aquí al residual llevan sonda porque son el
// último tramo del bloque donde el recuento de cabezas de VALOR vuelve a
// importar: la norma con puerta normaliza por filas de SS sobre tokens×SV.
// Sin sonda, un fallo aquí no aparecía como «la norma con puerta está mal»,
// aparecía como una escalera que se desvía desde el bloque 0 sin decir en
// qué operación.
const z = P('z', await mm(`blk.${i}.attn_gate.weight`, nrm, hp.ssmInner, E, tokens), tokens * hp.ssmInner);
const gn = P('gated_norm',
await ssmOps.gatedNorm(o, get(`blk.${i}.ssm_norm.weight`), z, tokens * SV, SS, eps),
tokens * hp.ssmInner);
if (colaLote) return await colaFinal(x, gn, `blk.${i}.ssm_out.weight`, hp.ssmInner, i, tokens);
const out = P('ssm_out', await mm(`blk.${i}.ssm_out.weight`, gn, E, hp.ssmInner, tokens), tokens * E);
return await feedForward(x, out, i, tokens);
}
// `rope_freqs` (lfm3): un solo tensor para todas las capas globales.
const factoresRope = has('rope_freqs.weight') ? get('rope_freqs.weight') : null;
async function attnBlock(x, l, tokens, posBuf) {
const i = l.index;
capaActual = i;
// Base de RoPE y ventana POR CAPA (lfm3); en el resto, las del modelo.
const ropeBase = l.ropeBase ?? hp.ropeFreqBase;
const factores = l.ropeFactores ? factoresRope : null;
if (l.ropeFactores && !factores) throw new Error(`la capa ${i} pide rope_freqs y el fichero no lo trae`);
const nrm = normaHecha(i, `blk.${i}.attn_norm.weight`) ?? await nn.rmsnorm(x, get(`blk.${i}.attn_norm.weight`), tokens, E, eps);
// En qwen35 `attn_q` proyecta al DOBLE —Q y una puerta INTERCALADOS por
// cabeza— y hay que partirlo. En llama proyecta a H*D y ya está, así que no
// hay nada que partir: el matmul deja [T][H*D], que es justo [T][H][D], la
// forma que esperan RoPE y los scores. Ni una copia de por medio.
// GEMV2: Q, K, V y la puerta en un despacho; las recoge `mm` (ver `qkvFusion`).
if (globalThis.__ELFFUSS_GEMV2 === true) await qkvFusion(i, nrm, tokens);
let qRaw, gRaw = null;
if (hp.qGate) {
const qfull = await mm(`blk.${i}.attn_q.weight`, nrm, 2 * H * D, E, tokens);
qRaw = await attn.strideCopy(qfull, { headDim: D, heads: H, tokens, stride: 2 * D, offset: 0 });
gRaw = await attn.strideCopy(qfull, { headDim: D, heads: H, tokens, stride: 2 * D, offset: D });
} else {
qRaw = await mm(`blk.${i}.attn_q.weight`, nrm, H * D, E, tokens);
}
// QK-norm ANTES de RoPE.
//
// Y el RoPE va en NEOX aunque la referencia use MRoPE intercalado (qwen35
// declara IMROPE, con cuatro secciones de dimensiones). NO es una
// simplificación: para texto puro las dos son la MISMA función, y conviene
// dejar por qué, porque parece un bug cada vez que se lee.
// 1. El MRoPE reparte las dimensiones entre cuatro ejes de posición
// (t, h, w y uno extra) — de ahí que sea multimodal.
// 2. Con tokens de texto, llama.cpp escribe la MISMA posición en las
// cuatro ranuras, así que los cuatro ángulos valen lo mismo y da igual
// qué eje toque a cada dimensión.
// 3. Los cuatro avanzan en bloque: `indep_sects` sale de `is_vision`, que
// es falso aquí, o sea que ninguna sección reinicia su ángulo.
// 4. Y el emparejamiento de elementos es literalmente la misma llamada
// que el NEOX, `rotate_pairs(n_dims, n_dims/2)`.
// Deja de valer en cuanto entre una imagen: ahí las posiciones dejan de ser
// iguales y hace falta el MRoPE de verdad.
// ⚠️ EL MODO DE RoPE SALE DEL PERFIL, y es el cambio que más miedo da de
// toda esta lista: equivocarse NO peta. Sale texto gramatical que se
// degrada al crecer el contexto, o sea el fallo más caro de detectar que
// hay. qwen35 empareja como NEOX; llama usa NORM porque su conversor a GGUF
// ya permuta los pesos. Ver `state.js`, ARQUITECTURAS.
const modoRope = hp.ropeMode === 'norm' ? ROPE_NORM : ROPE_NEOX;
// A la caché, a partir de nPast: nunca se pisa lo ya cacheado.
const cache = state.kv.get(i);
const per = KVH * D;
// UNA sola fuente de verdad para la precisión de la caché, leída del estado
// que la reservó. Escribirla y leerla con precisiones distintas no lanza:
// devuelve basura, y por eso el dato no se repite en cuatro sitios.
const kvF16 = !!state.kvF16;
let q;
if (fusionPase && fus.qkvCabe(D, hp.ropeDims, { norma: !!hp.qkNorm, factores: !!factores })) {
// FUSIÓN (F6): QK-norm + RoPE de q y k + K y V a la caché, en un despacho.
// Las proyecciones de k y v se piden aquí, antes, porque el kernel quiere
// las tres a la vez; el orden entre proyecciones independientes no cambia
// ningún resultado. La misma precisión de caché que la cadena (`kvF16`).
// Si el dispositivo no admite tantos buffers por despacho, va partido en
// dos (consulta, y clave con valor); si ni así, por la cadena.
const kRaw = await mm(`blk.${i}.attn_k.weight`, nrm, KVH * D, E, tokens);
const v = await mm(`blk.${i}.attn_v.weight`, nrm, KVH * D, E, tokens);
q = await fus.qkNormaRopeCache({
q: qRaw, k: kRaw, v, pos: posBuf,
wq: hp.qkNorm ? get(`blk.${i}.attn_q_norm.weight`) : null,
wk: hp.qkNorm ? get(`blk.${i}.attn_k_norm.weight`) : null,
cacheK: cache.k, cacheV: cache.v,
heads: H, kvHeads: KVH, headDim: D, tokens, nPast: state.nPast,
nDims: hp.ropeDims, mode: modoRope, freqBase: ropeBase, factores, kvF16, eps,
});
} else {
// La QK-norm tampoco es universal: llama no trae `attn_q_norm` siquiera.
const qn = hp.qkNorm
? await nn.rmsnorm(qRaw, get(`blk.${i}.attn_q_norm.weight`), tokens * H, D, eps)
: qRaw;
q = await nn.rope(qn, posBuf,
{ rows: tokens * H, ne0: D, heads: H, nDims: hp.ropeDims, mode: modoRope, freqBase: ropeBase, factores });
const kRaw = await mm(`blk.${i}.attn_k.weight`, nrm, KVH * D, E, tokens);
const kn = hp.qkNorm
? await nn.rmsnorm(kRaw, get(`blk.${i}.attn_k_norm.weight`), tokens * KVH, D, eps)
: kRaw;
const k = await nn.rope(kn, posBuf,
{ rows: tokens * KVH, ne0: D, heads: KVH, nDims: hp.ropeDims, mode: modoRope, freqBase: ropeBase, factores });
const v = await mm(`blk.${i}.attn_v.weight`, nrm, KVH * D, E, tokens);
await copyInto(k, cache.k, state.nPast * per, tokens * per, 0, kvF16);
await copyInto(v, cache.v, state.nPast * per, tokens * per, 0, kvF16);
}
const nKv = state.nPast + tokens;
const cfg = { headDim: D, heads: H, kvHeads: KVH, tokens, nKv, nPast: state.nPast, kvF16 };
// ── Atención FUSIONADA (`__ELFFUSS_FLASH`, ver ops.flash.js) ────────────
// Puntuaciones, softmax con la máscara causal y de ventana y combinación en
// uno o dos despachos, sin materializar las puntuaciones, y con la puerta
// por cabeza de lfm3 dentro de su epílogo. Misma matemática salvo redondeo.
// Sustituye SOLO al núcleo: lo de después (la puerta de Q de qwen35, la
// proyección de salida, el residual) es el mismo código para las dos rutas,
// y así lo que se añada ahí vale también para esta. Si la forma no se puede
// atender, `fusionada` queda en null y va la cadena de siempre, que da lo
// mismo más despacio. La puerta de cabeza va dentro solo si no hay la de Q:
// la misma precedencia que abajo. `puede` recibe la misma forma (con la
// puerta) que `atencion`: si no, preguntaría por otro kernel.
const fusionada = globalThis.__ELFFUSS_FLASH === true && ops.flash?.puede({ ...cfg, ventana: l.ventana || 0, puerta: !hp.qGate && !!hp.headGate })
? await ops.flash.atencion(q, cache.k, cache.v, { ...cfg, ventana: l.ventana || 0,
puerta: !hp.qGate && hp.headGate ? await mm(`blk.${i}.attn_gate.weight`, nrm, H, E, tokens) : null })
: null;
const scores = fusionada ? null : await attn.scores(q, cache.k, cfg);
const probs = fusionada ? null : await nn.softmax(scores,
{ rows: H * tokens, nKv, nQ: tokens, nPast: state.nPast, scale: 1 / Math.sqrt(D), ventana: l.ventana || 0 });
const att = fusionada || await attn.combine(probs, cache.v, cfg);
// La puerta multiplica la salida ANTES de la proyección — cuando la hay.
// En llama la atención va directa a la proyección de salida.
let gated = att;
if (hp.qGate) {
const gsig = await ssmOps.sigmoid(gRaw, tokens * H * D);
gated = await attn.mul(att, gsig, tokens * H * D);
} else if (hp.headGate && !fusionada) {
// lfm3: un escalar por cabeza, de la ENTRADA normalizada (no de Q).
const gh = await mm(`blk.${i}.attn_gate.weight`, nrm, H, E, tokens);
const p = await pipe('mul_cabeza', mulCabezaShader());
gated = ctx.alloc(tokens * H * D * 4, 'gated');
const pp = ctx.uniform([new Uint32Array([tokens * H * D, D, 0, 0])]);
await raw('mul_cabeza', p, [att, gh, gated, pp], Math.ceil(tokens * H * D / 256));
}
if (colaLote) return await colaFinal(x, gated, `blk.${i}.attn_output.weight`, H * D, i, tokens);
const out = await mm(`blk.${i}.attn_output.weight`, gated, E, H * D, tokens);
return await feedForward(x, out, i, tokens);
}
// El reloj del respiro de un prefill troceado, con `__ELFFUSS_PLAN` (si no,
// null y todo como siempre). Sin él, cada pasada arrancaba en «ya toca» y
// pagaba, tras su segunda capa, un frame entero con el dispositivo parado:
// uno por lote. Dos cosas lo evitan sin perder para qué existe el respiro
// (que el compositor pinte):
// · el reloj se COMPARTE entre las pasadas del mismo forward, como si
// fueran una sola: un respiro cada `cederMs` como mucho;
// · un frame PINTADO cuenta como respiro. Un latido con rAF apunta cuándo
// se pintó el último; si el compositor está pintando mientras esperamos
// al dispositivo entre lotes, forzar otro hueco no compra nada. Si deja de
// pintar, el latido se para y el respiro vuelve a entrar igual que antes.
// El PRIMERO del forward sigue entrando en cuanto se puede (lo que busca es
// que lo último de la pantalla se vea antes de que el dispositivo se ocupe),
// pero con PLAN cede el frame SIN vaciar la cola (`respiroSinVaciar`): en ese
// momento el dispositivo solo tiene en cola las capas recién enviadas, el
// compositor entra entre un envío y el siguiente, y el dispositivo sigue
// trabajando mientras llega el frame en vez de esperarlo parado. Esto vale
// también para el prefill de una sola pasada, que era un frame ocioso por
// forward. Los respiros siguientes, si hacen falta (si no se ve pintar),
// vacían la cola como siempre: ahí el hueco es lo que garantiza el frame.
// Con `cederMs = 0` (el A/B) nada cambia: respira cada `cederCada` capas y
// vaciando siempre.
let relojRespiro = null;
let respiroSinVaciar = false;
const nuevoRelojRespiro = () => {
const r = { t: -Infinity, frame: -Infinity, actividad: performance.now() };
if (typeof requestAnimationFrame === 'function') {
const late = () => {
// Se apaga solo cuando el forward deja de dar señales (acabó o se paró).
if (performance.now() - r.actividad > 2000 || typeof requestAnimationFrame !== 'function') return;
r.frame = performance.now();
requestAnimationFrame(late);
};
requestAnimationFrame(late);
}
return r;
};
// UNA pasada: la implementación de siempre, intacta. La trocea `forward`.
//
// `signal` se mira en cada FRONTERA DE CAPA, y parar no rompe el lote a
// medias: se deja de grabar, el callback devuelve null y `ctx.batch` cierra y
// envía lo grabado por su camino normal (ops.js no sabe nada de parar). Lo que
// sí hay que hacer antes de lanzar es esperar al dispositivo: los uniformes del lote
// solo se pueden reciclar después de una sincronización —ver `forward`—, y
// quien recibe el error va a reciclar. `state.advance` NO se llama: el lote no
// terminó, así que nPast sigue donde estaba y lo que se escribió en la caché
// por encima queda tapado por la máscara causal. El estado recurrente sí quedó
// a medias, y por eso todo turno empieza con `restaura()` o `reset()`.
const unaPasada = async (tokenIds, { tap = null, todosLosLogits = false, signal = null } = {}) => {
let logitsTokens = 1;
let parado = false;
const tokens = tokenIds.length;
const taps = new Map();
const idsBuf = ctx.alloc(tokens * 4, 'ids');
device.queue.writeBuffer(idsBuf, 0, new Uint32Array(tokenIds));
const posBuf = ctx.alloc(tokens * 4, 'pos');
device.queue.writeBuffer(posBuf, 0,
Int32Array.from({ length: tokens }, (_, i) => state.nPast + i));
memoRot = new Map(); // la memoria de rotación vale para UN forward
conservadas = new Set(); // y lo que las sondas pidan conservar, también
// FUSIÓN: el interruptor se lee UNA vez por pasada, y una norma pendiente de
// una pasada anterior (parada a medias) no vale para esta.
fusionPase = fusionActiva();
nrmPendiente = null;
// GEMM2_COLA (ver `colaFinal`). Nunca con sondas, taps ni todos los logits:
// ahí se leen las filas de todos los tokens de la última capa.
colaLote = 0;
colaCortada = false;
const ultimaCapa = hp.layers[hp.layers.length - 1];
const cola = globalThis.__ELFFUSS_GEMM2 === true && globalThis.__ELFFUSS_GEMM2_COLA === true &&
tokens > 1 && !tap && !todosLosLogits && !probe && !!quant.gemm2 && colaReproducible(ultimaCapa, tokens);
let colaHecha = false;
const logits = await ops.batch('forward', async () => {
let x = fusionPase ? await gatherFusion(idsBuf, tokens) : await gather(idsBuf, tokens);
// El índice −1 es el embedding, antes de la primera capa. No es un
// capricho de simetría: si los ids o la tabla se leen mal, TODAS las
// capas se desvían y la escalera señala la 0 sin distinguir «entré mal»
// de «la capa 0 calcula mal». Con este ancla esa duda se resuelve.
if (tap && tap.has(-1)) taps.set(-1, x);
let capaN = 0;
// Arranca en «ya toca»: la primera capa de un prefill largo es un buen
// momento para dejar pintar, y no depender del reloj del forward anterior.
// Con PLAN, las pasadas siguientes del MISMO forward siguen su reloj.
let ultimoRespiro = relojRespiro ? relojRespiro.t : -Infinity;
for (const l of hp.layers) {
if (signal?.aborted) { parado = true; break; }
// Se recicla lo de CADA capa en cuanto termina, en vez de guardarlo
// todo hasta el final del forward. Sin esto el pico de memoria del dispositivo
// crecía con capas × tokens, o sea que con un prompt normal llegaba a
// pedir varias veces lo que pesa el modelo entero — y el factor empeora
// cuantas más capas tiene.
const marca = ctx.scratch.length;
// FUSIÓN: la norma que dejó hecha la capa anterior muere al acabar ESTA,
// como la norma que sustituye (ver `reciclableDesde`). Sin fusión no hay
// pendiente y la marca es la de siempre.
const marcaCapa = nrmPendiente ? fus.reciclableDesde(nrmPendiente.buf, marca) : marca;
const previo = x;
colaLote = cola && l === ultimaCapa ? tokens : 0;
x = l.kind === 'ssm' ? await ssmBlock(x, l.index, tokens)
: l.kind === 'conv' ? await convBlock(x, l.index, tokens)
: await attnBlock(x, l, tokens, posBuf);
if (colaLote) {
// Que el corte estuviera armado no basta: el bloque tiene que haber
// pasado por `colaFinal`. Un camino del bloque que vuelva antes del
// corte (otra variante del mismo bloque, una fusión) devuelve TODAS
// las filas, y la cabeza de una fila leería la del PRIMER token:
// logits de otro token, sin un error. Se dice aquí, antes de la cabeza.
if (!colaCortada) throw new Error(`cola: la capa ${l.index} volvió sin pasar por el corte (colaFinal)`);
colaHecha = true; colaLote = 0; // x es ya UNA fila
}
if (tap && tap.has(l.index)) taps.set(l.index, x);
// Vivos: la salida de esta capa (entra en la siguiente), la entrada si
// el bloque la devolvió tal cual, y cualquier tensor con sonda puesta,
// que el llamador va a leer DESPUÉS del forward.
// Con fusión, también la norma que esta capa deja hecha para la siguiente.
ctx.recycleDesde(marcaCapa, x, previo, ...taps.values(), ...conservadas,
...(nrmPendiente ? [nrmPendiente.buf] : []));
// Trocear el forward para no colgar el ordenador. SOLO en prefill
// (tokens>1): el decode es 1 token y nunca colgó, y trocearlo lo
// ralentizaría sin motivo. Se corta el command buffer en CADA capa
// (barato: solo un submit más) y de vez en cuando se drena la cola y se
// cede un frame, para que el compositor tenga su hueco. No cambia la
// numérica: el orden de la cola se respeta. Ver ctx.corta.
//
// El troceo es GRATIS y va en todas las capas; el respiro es CARO (drena
// la cola, que mata el solape entre la CPU y el dispositivo, y encima espera
// al refresco) y va
// limitado por reloj. Ver `cederMs`: antes iba atado al contador de capas
// y era lo que fijaba el techo del prefill.
// DECODE: cortar el command buffer cada `cortaDecode` capas deja que el
// dispositivo empiece mientras la CPU sigue grabando el resto del token.
// Sin respiro (no hace falta: un token no cuelga nada). 0 = no cortar.
if (tokens === 1 && cortaDecode > 0 && (capaN + 1) % cortaDecode === 0 && capaN + 1 < hp.layers.length) ctx.corta();
if (tokens === 1) capaN++;
if (tokens > 1) {
if (relojRespiro) {
relojRespiro.actividad = performance.now();
if (relojRespiro.frame > ultimoRespiro) ultimoRespiro = relojRespiro.frame;
}
const toca = (capaN + 1) % cortaCada === 0;
// El respiro drena la cola; si el pase sigue abierto, lo que drena es
// una cola VACÍA —el trabajo ni se ha enviado— y se paga el frame sin
// conseguir el hueco que se buscaba. Así que cuando el respiro va a
// entrar, se corta aunque no tocase por intervalo.
const respira = (capaN + 1) % cederCada === 0 && performance.now() - ultimoRespiro >= cederMs;
if (toca || respira) ctx.corta();
capaN++;
if (respira) {
// Con PLAN, el primero del forward no vacía la cola (ver arriba).
if (!(respiroSinVaciar && cederMs > 0 && ultimoRespiro === -Infinity)) await device.queue.onSubmittedWorkDone();
await nuevoFrame(signal);
ultimoRespiro = performance.now();
if (relojRespiro) relojRespiro.t = ultimoRespiro;
}
}
}
// La última frontera: tras la última capa solo queda la cabeza, y unos
// logits que nadie va a leer no merecen la espera.
if (signal?.aborted) parado = true;
if (parado) return null;
if (colaHecha) {
// La última capa ya dejó solo la fila del último token: la misma norma
// y la misma cabeza que abajo, sin el recorte (ya está hecho).
const nrm1 = await nn.rmsnorm(x, get('output_norm.weight'), 1, E, eps);
const cabeza = has('output.weight') ? 'output.weight' : 'token_embd.weight';
logitsTokens = 1;
return await mm(cabeza, nrm1, model.g.meta['tokenizer.ggml.tokens'].length, E, 1);
}
// Con fusión, la norma final ya la dejó hecha la última capa (F1).
const nrm = normaHecha('salida', 'output_norm.weight') ?? await nn.rmsnorm(x, get('output_norm.weight'), tokens, E, eps);
// SOLO EL ÚLTIMO TOKEN. La cabeza es la matriz más grande del modelo
// (vocabulario × embedding) y para generar solo hace falta la última fila:
// calcularla para todo el prompt costaba cientos de veces más, y se pagaba
// por N−1 vectores de logits que se descartan enteros.
let entradaCabeza = nrm, tokensCabeza = tokens;
if (!todosLosLogits && tokens > 1) {
const fila = ctx.alloc(E * 4, 'ultimo');
await copyInto(nrm, fila, 0, E, (tokens - 1) * E);
entradaCabeza = fila; tokensCabeza = 1;
}
const head = has('output.weight') ? 'output.weight' : 'token_embd.weight';
logitsTokens = tokensCabeza;
return await mm(head, entradaCabeza, model.g.meta['tokenizer.ggml.tokens'].length,
E, tokensCabeza);
});
if (parado) {
// La espera va ANTES de lanzar, no es opcional: ver arriba.
await device.queue.onSubmittedWorkDone();
throw errorParado();
}
state.advance(tokens);
// `logitsTokens` dice para CUÁNTOS tokens hay logits: casi siempre 1,
// aunque `tokens` sea el prompt entero. Sin este dato, quien deduzca el
// tamaño del vocabulario dividiendo por `tokens` se equivoca.
return { logits, tokens, logitsTokens, taps };
};
// DIAGNÓSTICO DE COBERTURA. El fichero enumera cuántos tensores llevan
// rotación; si el forward toca muchos menos, hay un camino que se la salta —
// y eso no falla, devuelve ruido.
const _rotacion = () => (rot ? {
declarados: rot.pesos.size, inversos: rot.inversos.size,
aplicados: rotVistos.size, bloque: rot.bloque,
faltan: [...rot.pesos, ...rot.inversos].filter((n) => !rotVistos.has(n)),
} : null);
return {
_rotacion,
state,
hp,
// Ejecuta el forward y devuelve los logits del ÚLTIMO token.
// `tap` permite pedir el residual de una capa concreta para compararlo con
// el oráculo: es la diferencia entre «el texto suena raro» y «el bloque 7
// se desvía y los seis anteriores cuadran».
// Prefill TROCEADO. La máscara causal se aplica en el softmax, DESPUÉS de
// calcular los scores, así que una pasada de N tokens calcula la matriz
// N×N entera y tira la mitad. Troceando, cada lote solo se mide contra lo
// que ya hay: para 1347 tokens en lotes de 512 son 1,22 M de posiciones en
// vez de 1,81 M, y la diferencia CRECE con la longitud (×1,6 a 2048, ×1,78
// a 4096). Es la parte «superlineal» que la medida del prefill detectó y la
// razón de que llama.cpp —que trocea por defecto— fuera 58× más rápido.
//
// No toca ningún kernel ni la numérica: es la misma maquinaria del decode,
// con nPast avanzando entre lotes. Lo que cambia es cuánto trabajo inútil
// se hace, no el resultado.
//
// PARAR: `opts.signal` (un AbortSignal) se mira al entrar, entre lotes y en
// cada frontera de capa dentro de un lote. Al entrar no se toca nada; entre
// lotes, nPast queda al final del último lote completo; dentro de uno, donde
// empezó ese lote. Las tres salidas lanzan un AbortError y dejan el
// dispositivo en reposo, así que `recycle()` es seguro justo después. Todo lote terminado
// se avisa por `onProgress`, también el último antes del «para»: lo que se
// cuenta es trabajo hecho, y nPast lo incluye.
async forward(tokenIds, opts = {}) {
const senal = opts.signal || null;
if (senal?.aborted) throw errorParado();
// 128 y no 512, y la razón NO es la memoria: es que el dispositivo tiene que poder
// ATENDER AL ESCRITORIO entre despacho y despacho. El bucle del SSM recorre
// todos los tokens del lote DENTRO de un solo despacho, sin ceder; con 512
// ese núcleo corre largo, y el sistema puede matar la sesión gráfica cuando
// el compositor no consigue pintar dentro del plazo de su vigilante.
//
// Trocear más fino además REDUCE trabajo: la máscara causal se aplica en el
// softmax, o sea que cada lote calcula su matriz entera; a lotes más cortos,
// menos posiciones enmascaradas se calculan para tirarlas.
const LOTE = loteActual;
// Con `tap` NO se trocea: la referencia contra la que se compara sale de
// UNA pasada, y comparar 64 escalones contra tres pasadas concatenadas no
// significa nada. Con `todosLosLogits`
// tampoco, porque troceando solo sobreviven los del último lote.
// `onProgress(hechos, total)` se llama DESPUÉS de cada lote, que es justo
// después de esperar a que el dispositivo lo termine. Así lo que se reporta es
// trabajo HECHO y no encolado: una barra que avanza por encolado llega al
// 100 % y se queda ahí mientras el dispositivo sigue, que es peor que no tenerla.
const avisa = typeof opts.onProgress === 'function' ? opts.onProgress : null;
// Un reloj nuevo por forward troceado (ver `relojRespiro`): nunca se
// hereda el de otro forward.
relojRespiro = globalThis.__ELFFUSS_PLAN === true && !opts.tap && !opts.todosLosLogits &&
tokenIds.length > LOTE ? nuevoRelojRespiro() : null;
respiroSinVaciar = globalThis.__ELFFUSS_PLAN === true && !opts.tap && !opts.todosLosLogits;
if (opts.tap || opts.todosLosLogits || tokenIds.length <= LOTE) {
const r = await unaPasada(tokenIds, opts);
avisa?.(tokenIds.length, tokenIds.length);
return r;
}
let ultimo = null;
for (let i = 0; i < tokenIds.length; i += LOTE) {
ultimo = await unaPasada(tokenIds.slice(i, i + LOTE), opts);
// ESPERAR a que el lote termine en el dispositivo antes de encolar el siguiente.
// Los buffers uniform se reciclan ENTRE lotes (ops.js lo dice: «se
// reciclan entre lotes, no dentro»), y eso solo es seguro si hay una
// sincronización en medio. El bucle de decode la tiene sin buscarla,
// porque lee los logits en cada paso; aquí no había ninguna, así que el
// lote siguiente reutilizaba uniformes que el anterior todavía no había
// consumido. El resultado no era un error: era OTRO token (197 en vez
// de 62), que es la peor forma de fallar porque sale texto plausible.
await device.queue.onSubmittedWorkDone();
// El aviso va JUSTO DESPUÉS de esa espera y ANTES de mirar la señal: el
// lote ya está hecho y nPast lo incluye, así que se cuenta aunque el
// prefill se pare aquí mismo. Callarlo dejaría el progreso un lote por
// detrás de lo que de verdad entró. Quien no quiera avisos después de
// un «para» los filtra él: el proveedor deja de pasarlos a `onPrefill`
// en cuanto nota la señal.
avisa?.(Math.min(i + LOTE, tokenIds.length), tokenIds.length);
// Y ahora la señal, con el dispositivo parado y nPast al final del último lote
// completo. Tras el último lote no se mira: el trabajo ya está hecho y
// tirarlo no ahorra nada, así que el forward termina y el «para» lo
// nota quien llama en su siguiente comprobación.
if (senal?.aborted && i + LOTE < tokenIds.length) throw errorParado();
}
// `tokens` se corrige al total: quien lo lea espera cuántos entraron, no
// cuántos traía el último lote.
return { ...ultimo, tokens: tokenIds.length };
},
recycle: () => ctx.recycle(),
destroy: () => state.destroy(),
};
}
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 UtopiaIA
// Elffuss Runtime · el modelo: carga de pesos y forward de qwen35.
// ─────────────────────────────────────────────────────────────────────────────
// Aquí se encadenan las piezas que ya están verificadas una a una. Esa
// verificación por piezas NO garantiza que el conjunto funcione: puede fallar el
// orden, el layout que espera la siguiente operación, o un residual sumado en el
// sitio equivocado. Por eso el forward está escrito para poder pararse en
// cualquier capa y comparar contra el volcado de un forward real.
//
// Dos tipos de bloque, y el reparto sale del propio fichero:
// · SSM (18 de 24 en el 0.8B, 48 de 65 en el 27B): qkv fusionado → conv causal
// → silu → q/k/v → delta net con estado recurrente → norm con puerta.
// · Atención (el resto): Q y puerta INTERCALADOS en el mismo tensor, QK-norm
// antes de RoPE, RoPE parcial, GQA, y la puerta multiplicando la salida.
// Los dos acaban igual: residual, norm, SwiGLU, residual.
//
// Licencia: código propio, Apache-2.0 (ver SPDX arriba y LICENSE). Sin dependencias externas.
import { parseGGUF } from './gguf.js';
import { leeRotacion } from './hadamard.js';
import { createOps, ROPE_NEOX, ROPE_NORM } from './ops.js';
import { createFlashOps } from './ops.flash.js';
import { readHParams, createState } from './state.js';
import {
copyIntoShader, convWindowShader, convSaveShader, siluShader, mulCabezaShader,
} from './kernels.model.wgsl.js';
import { QUANT_TYPES } from './kernels.wgsl.js';
// Q8_0/Q4_0 reordenados al subir (`__ELFFUSS_REPACK`, apagado por defecto).
import {
reordenActivo, tipoReordenado, comoReordenado, bloquesDe, reordena,
creaBufferR, subeReordenadoPorTrozos,
} from './repack.js';
import { createFusionOps, fusionActiva, puedeGatherNorma } from './ops.fusion.js';
const MiB = 1024 ** 2;
// Sube todos los tensores del fichero al dispositivo, uno por buffer.
// PENDIENTE consciente: un tensor por buffer exige que cada uno quepa en
// maxStorageBufferBindingSize. En escritorio el límite suele sobrar; en otros
// navegadores es mucho menor y la tabla de embeddings, de los tensores más
// grandes del modelo, puede no caber. Ahí hará falta que el matmul lea varios shards, que es
// trabajo del kernel, no de aquí. Se comprueba y se falla claro en vez de
// devolver ceros.
// El tamaño de lote del prefill. Es un `let` con gancho SOLO para poder
// medirlo: comparar dos valores exige cambiarlo en caliente, dentro de la misma
// sesión y alternando el orden. Editando el fichero entre pasadas, lo que se
// mueve la medida de una sesión a otra se mezcla con lo que se compara, y luego
// no hay forma de separar una cosa de la otra.
// 32 y no 128: medido, un lote más corto va algo MÁS rápido (menos posiciones
// enmascaradas que se calculan para tirar) y —lo que importa aquí— cada despacho
// dura menos, que es media defensa contra el cuelgue. La otra media es
// trocear el forward en varios command buffers (ver `cede`/`ctx.corta`).
let loteActual = 32;
// El reparto de la última carga. Lo consume una prueba interna y puede
// consumirlo la UI: es la espera más larga del producto.
let medidaCarga = null;
export function ultimaCarga() { return medidaCarga; }
// Cuántos tensores DEBÍAN subirse en la última carga. No es lo mismo que los del
// fichero: la cabeza MTP (`nextn_predict_layers`) no pertenece al forward y no
// se sube — en el modelo pequeño son 15 de 335. Un banco que compare contra
// `g.tensorCount` falla por construcción, que es lo que pasaba.
let subidosEsperados = 0;
export function ultimosSubidos() { return subidosEsperados; }
export function _setLote(n) { loteActual = Math.max(1, n | 0); }
export function _getLote() { return loteActual; }
// Cada cuántas capas se le da al dispositivo un respiro real para que el compositor
// del sistema pinte. Un forward del 27B son 64 capas; sin trocear, iban todas
// en un solo despacho larguísimo y la pantalla se quedaba sin pintar mientras
// tanto. Ver `ctx.corta`.
let cederCada = 2;
export function _setCeder(n) { cederCada = Math.max(1, n | 0); }
export function _getCeder() { return cederCada; }
// El respiro se cobra por RELOJ, no por capas. Lo que se persigue —que el
// compositor del sistema pinte— es un asunto de milisegundos de pared: da igual
// si en ese rato han pasado dos capas o cuarenta. Contándolo por capas se pagaba
// un frame entero cada dos capas aunque esas dos capas durasen medio frame, y
// como un rAF no se puede partir, el prefill acababa cuantizado al refresco del
// monitor: el reloj lo marcaba la pantalla y no el modelo.
// `cederCada` sigue siendo la granularidad mínima (solo se mira el reloj en esas
// capas) y esto es el límite de frecuencia. A 0 se recupera exactamente el
// comportamiento anterior, que es lo que usan los bancos para el A/B.
let cederMs = 100;
export function _setCederMs(ms) { cederMs = Math.max(0, +ms || 0); }
export function _getCederMs() { return cederMs; }
// Cada cuántas capas se CIERRA y envía el command buffer del prefill. Es otra
// cosa que `cederCada`: eso es cuándo se drena la cola y se cede un frame, esto
// es solo cuántos submits se parten. Va con gancho porque la nota de al lado
// afirmaba que trocear en cada capa es «GRATIS» y eso nadie lo había medido:
// una tanda de prefill aprovecha el ancho de banda de pesos 6,6 veces peor que
// un token suelto (medido con una prueba interna) y el prefill es justo el único
// camino que trocea. A 1 se recupera el comportamiento de siempre.
let cortaCada = 1;
export function _setCorta(n) { cortaCada = Math.max(1, n | 0); }
// Lo mismo para el DECODE (ver el bucle de capas). Con gancho para el A/B.