Три подводных камня при переходе с TensorFlow.js на LiteRT.js
Запуск моделей ИИ в веб-браузере — дело захватывающее, но вы быстро упретесь в ограничения. Когда дело доходит до обработки изображений высокого разрешения или тяжелых вычислений, интерфейс начинает «тормозить». TensorFlow.js (TF.js) использует бэкенд WebGL и привязки к ядрам JavaScript, что создает серьезные накладные расходы при выполнении масштабных матричных вычислений.
С другой стороны, LiteRT.js портирует C++ native runtime в браузер, скомпилировав его в WebAssembly (Wasm). Это фундаментально решает проблему узких мест. Кроме того, упрощается процесс переноса моделей PyTorch в веб. Раньше приходилось проходить путь от PyTorch к ONNX, затем через TensorFlow к формату TF.js, из-за чего нарушалась совместимость операторов и терялась точность. Теперь достаточно использовать одну библиотеку (ai-edge-torch), чтобы получить стандартный файл .tflite.
Если жалко выбрасывать старые наработки, можно использовать пакет @litertjs/tfjs-interop, предоставленный Google. При этом предобработка или постобработка данных остаются в пайплайне TF.js, а заменяется только часть, отвечающая за выполнение предсказаний модели, на LiteRT.js.
Решение проблемы несовместимости NCHW и NHWC
Модели TFLite, преобразованные из PyTorch, обычно требуют структуру, где каналы идут первыми — NCHW (каналы, высота, ширина). Однако массив ImageData в Canvas браузера представляет собой формат NHWC (высота, ширина, каналы), где пиксели идут по порядку. Необходим код предобработки, который устраняет этот разрыв синхронно, не перегружая основной поток.
Сначала создается Float32Array, размер которого равен общему количеству пикселей ImageData, умноженному на 3. Затем для каждого канала (красного, зеленого и синего) задаются смещения: 0, общее количество пикселей и дважды общее количество пикселей. Наконец, значения пикселей от 0 до 255 нормализуются путем деления на 255.0 и присваиваются соответствующим смещениям каналов. Это процесс переупорядочивания входных данных в плоский тензорный буфер формата NCHW.
`javascript
/**
- Утилита предобработки для быстрого преобразования буфера ImageData формата NHWC в высокопроизводительный NCHW Float32Array
- @param {ImageData} imageData - Исходные данные пикселей, полученные из HTML5 Canvas
- @param {number} width - Горизонтальное разрешение входного изображения, требуемое целевой моделью
- @param {number} height - Вертикальное разрешение входного изображения, требуемое целевой моделью
- @returns {Float32Array} Плоский тензорный буфер, переупорядоченный в формате NCHW
*/
export function preprocessNHWCToNCHW(imageData, width, height) {
const { data } = imageData;
const totalPixels = width * height;
const nchwBuffer = new Float32Array(totalPixels * 3);
const rChannelOffset = 0;
const gChannelOffset = totalPixels;
const bChannelOffset = totalPixels * 2;
for (let i = 0; i < totalPixels; i++) {
const srcIndex = i * 4;
nchwBuffer[rChannelOffset + i] = data[srcIndex] / 255.0;
nchwBuffer[gChannelOffset + i] = data[srcIndex + 1] / 255.0;
nchwBuffer[bChannelOffset + i] = data[srcIndex + 2] / 255.0;
}
return nchwBuffer;
}
`
Предотвращение «фризов» интерфейса с помощью Web Worker и Zero-copy
Худшая ситуация при запуске глубокого обучения во фронтенде — это зависание UI. Чтобы браузер отображал плавную анимацию со скоростью 60 кадров в секунду, цикл событий должен обрабатывать синхронные операции в пределах 16.6 мс. Однако тензорные вычисления легко блокируют однопоточный цикл главного рендерера.
LiteRT.js работает примерно в 3 раза быстрее, чем традиционные инструменты на базе JavaScript. При подключении аппаратного ускорения WebGPU или WebNN он становится от 5 до 60 раз быстрее, чем в режиме CPU. Бэкенд WebNN, использующий выделенные NPU, требует обязательного включения функции интеграции промисов JavaScript (JSPI) для связи синхронного планировщика ядер WebAssembly с асинхронным циклом управления оборудованием браузера. Чтобы использовать эти ресурсы ускорения и при этом не нагружать основной поток, инициализацию библиотеки и весь конвейер вывода следует изолировать внутри Web Worker.
При передаче данных между потоками обычное копирование буфера создает нагрузку на CPU и кучу памяти. Необходимо использовать передаваемые объекты (Transferable Objects), чтобы передавать право собственности на область физической памяти, что устраняет задержки. Буфер, право собственности на который было передано, немедленно становится недействительным в контексте отправителя, что обеспечивает безопасность между потоками.
`javascript
// litert-worker.js - Модуль Web Worker для фоновых вычислений вывода
import { loadLiteRt, loadAndCompile, Tensor } from '@litertjs/core';
let compiledModel = null;
let isLoaded = false;
self.onmessage = async (event) => {
const { type, payload } = event.data;
switch (type) {
case 'LOAD_MODEL':
try {
await loadLiteRt(payload.wasmDirectory, { jspi: payload.enableJspi || false });
compiledModel = await loadAndCompile(payload.modelUrl, {
accelerator: payload.accelerator || 'webgpu'
});
isLoaded = true;
self.postMessage({ type: 'MODEL_READY' });
} catch (err) {
self.postMessage({ type: 'ERROR', error: Initialization failed: ${err.message} });
}
break;
case 'RUN_INFERENCE':
if (!isLoaded || !compiledModel) {
self.postMessage({ type: 'ERROR', error: 'Model has not been loaded' });
return;
}
try {
const rawInputData = payload.bufferData;
const inputShape = payload.shape;
const inputTensor = new Tensor(rawInputData, inputShape);
const results = await compiledModel.run(inputTensor);
const cpuOutputTensor = await results[0].moveTo('wasm');
const outputBuffer = cpuOutputTensor.toTypedArray();
inputTensor.delete();
cpuOutputTensor.delete();
results[0].delete();
self.postMessage(
{
type: 'INFERENCE_COMPLETE',
payload: {
data: outputBuffer,
shape: results[0].shape
}
},
[outputBuffer.buffer]
);
} catch (err) {
self.postMessage({ type: 'ERROR', error: `Inference failed: ${err.message}` });
}
break;
default:
self.postMessage({ type: 'UNKNOWN_OP' });
}
};
`
`javascript
// litert-bridge.js - Класс AI-оркестратора для основного потока
export class LiteRtBridge {
constructor(workerPath) {
this.worker = new Worker(workerPath);
this.promiseMap = new Map();
this.tokenCounter = 0;
this.worker.onmessage = (event) => {
const { type, payload, error } = event.data;
if (type === 'MODEL_READY') {
if (this.initResolve) this.initResolve();
} else if (type === 'INFERENCE_COMPLETE') {
const currentToken = this.tokenCounter;
const promiseHandler = this.promiseMap.get(currentToken);
if (promiseHandler) {
promiseHandler.resolve(payload);
this.promiseMap.delete(currentToken);
}
} else if (type === 'ERROR') {
const currentToken = this.tokenCounter;
const promiseHandler = this.promiseMap.get(currentToken);
if (promiseHandler) {
promiseHandler.reject(new Error(error));
this.promiseMap.delete(currentToken);
} else if (this.initReject) {
this.initReject(new Error(error));
}
}
};
}
bootstrap(wasmDirectory, modelUrl, accelerator = 'webgpu') {
return new Promise((resolve, reject) => {
this.initResolve = resolve;
this.initReject = reject;
this.worker.postMessage({
type: 'LOAD_MODEL',
payload: { wasmDirectory, modelUrl, accelerator, enableJspi: true }
});
});
}
execute(inputFloat32Array, inputShape) {
return new Promise((resolve, reject) => {
this.tokenCounter++;
this.promiseMap.set(this.tokenCounter, { resolve, reject });
this.worker.postMessage(
{
type: 'RUN_INFERENCE',
payload: {
bufferData: inputFloat32Array,
shape: inputShape
}
},
[inputFloat32Array.buffer]
);
});
}
}
`
Не доверяйте автоматическому сборщику мусора
При работе с TF.js стандартом был паттерн очистки тензоров внутри области видимости синхронного вызова с помощью tf.tidy(). Однако при использовании асинхронного кода или Promise возникали ошибки, когда область видимости покидалась до завершения асинхронной работы, что приводило к случайному удалению тензоров или пропускам их сбора.
LiteRT.js еще более строг. Объекты не попадают под действие сборщика мусора (GC) браузерного движка. Движки JavaScript, такие как V8, не могут отслеживать состояние кучи линейного виртуального адресного пространства WebAssembly и буферов WebGPU. Если явно не вызывать .delete() для экземпляров тензоров после завершения их использования, память браузера будет бесконечно заполняться. В сервисах, транслирующих видео высокого разрешения десятки раз в секунду, вкладка браузера может упасть через несколько минут.
Необходимо вручную управлять временем жизни тензоров, созданных в асинхронном конвейере, и создать класс-трекер области видимости, который гарантирует пакетное удаление, чтобы работать спокойно.
`javascript
/**
- Асинхронный менеджер области видимости памяти, обеспечивающий ручное отслеживание и гарантированное уничтожение тензоров в куче WebAssembly
*/
export class LiteRtScopeTracker {
constructor() {
this.trackList = new Set();
}
/**
- Включение созданного или перемещенного тензора в список управления жизненным циклом
- @param {Tensor} tensor - Тензор LiteRT.js для отслеживания и последующего удаления
- @returns {Tensor} Возвращает переданный объект тензора для поддержки написания инлайн-кода
*/
register(tensor) {
if (tensor && typeof tensor.delete === 'function') {
this.trackList.add(tensor);
}
return tensor;
}
/**
- Принудительное управление конвейером тензоров в асинхронном блоке выполнения
- @param {Function} asyncCallable - Асинхронная функция бизнес-логики вывода
- @returns {Promise<*>} Конечный результат первичных данных, возвращаемый блоком выполнения
*/
async enforceScope(asyncCallable) {
try {
const outputResult = await asyncCallable(this);
if (Array.isArray(outputResult)) {
outputResult.forEach((item) => this.trackList.delete(item));
} else {
this.trackList.delete(outputResult);
}
return outputResult;
} finally {
this.disposeAll();
}
}
/**
- Окончательное освобождение всех активных нативных тензоров TFLite в области Wasm, связанных с менеджером
*/
disposeAll() {
for (const tensor of this.trackList) {
try {
tensor.delete();
} catch (err) {
console.error('An error occurred while cleaning the native Wasm tensor memory:', err);
}
}
this.trackList.clear();
}
}
`
`javascript
// Пример реализации безопасной обработки нескольких асинхронных выводов ИИ с использованием трекера области памяти
export async function runRobustVisionInference(rawPixelArray, compiledModel) {
const scopeTracker = new LiteRtScopeTracker();
try {
return await scopeTracker.enforceScope(async (scope) => {
const inputTensor = scope.register(new Tensor(rawPixelArray, [1, 3, 224, 224]));
const predictionResults = await compiledModel.run(inputTensor);
predictionResults.forEach((tensor) => scope.register(tensor));
const firstOutputTensor = predictionResults[0];
const wasmTransferTensor = scope.register(await firstOutputTensor.moveTo('wasm'));
const targetJsArray = wasmTransferTensor.toTypedArray();
return targetJsArray;
});
} catch (err) {
console.error('Fatal crash occurred during the model pipeline execution:', err);
throw err;
}
}
`
Условная загрузка тяжелых файлов Wasm
Нельзя игнорировать проблему большого размера загружаемых ресурсов. Чтобы дерево зависимостей корректно очищалось (tree-shaking) в сборщике, необходимо избегать статических ссылок в стиле CommonJS и писать код на основе синтаксиса модулей ES6 (import/export). Также следует проверить настройку sideEffects: false в инструментах сборки, чтобы сделать бандл легче.
Основной рантайм LiteRT.js, @litertjs/core, выборочно загружает одну из трех сборок ядра WebAssembly в зависимости от производительности устройства. В современных браузерах, таких как Chrome или Edge, выбирается модуль с поддержкой многопоточности и SIMD (litert_wasm_simd.wasm), а в устаревших средах, таких как Safari, используется базовый модуль-фоллбэк (litert_wasm.wasm). Если компиляция GPU не удается, в качестве вспомогательного механизма запускается рантайм XNNPACK, который переносит все аппаратные операторы в «песочницу» CPU Wasm.
Чтобы предотвратить задержку при начальной загрузке, необходимо динамически загружать модули, проверяя доступные ускорители для каждой спецификации.
`javascript
// litert-loader.js - Движок для определения устройства в рантайме и динамической привязки ускорителей
export async function bootstrapHighPerformanceInferenceEngine() {
const supportsWebGpu = 'gpu' in navigator;
let chosenAccelerator = 'wasm';
if (supportsWebGpu) {
try {
const gpuAdapter = await navigator.gpu.requestAdapter();
if (gpuAdapter) {
const info = await gpuAdapter.requestDevice();
if (info) {
chosenAccelerator = 'webgpu';
}
}
} catch (e) {
console.warn("GPU profile probe failed, resolving execution chain to fallback WASM.");
}
}
const { loadLiteRt, loadAndCompile } = await import('@litertjs/core');
const cdnWasmHostPath = 'https://cdn.jsdelivr.net/npm/@litertjs/core/wasm/';
await loadLiteRt(cdnWasmHostPath, {
jspi: chosenAccelerator === 'webnn'
});
return {
loadAndCompile,
chosenAccelerator
};
}
`
Возьмите за основу своей архитектуры перенос права собственности на память через Web Worker и явное удаление объектов в области Wasm. Начав напрямую контролировать потоки данных, вы сможете развертывать AI-сервисы on-device в production, не опасаясь «взрыва» браузера.