This is an automated email from the ASF dual-hosted git repository.

tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git


The following commit(s) were added to refs/heads/main by this push:
     new 4e9a099d15 [Web] Upload pass-through tensor-cache records directly to 
WebGPU (#20166)
4e9a099d15 is described below

commit 4e9a099d154d7c4644a40a1a9c00b8873226468e
Author: Akaash Parthasarathy <[email protected]>
AuthorDate: Sat Aug 22 21:29:02 2026 -0700

    [Web] Upload pass-through tensor-cache records directly to WebGPU (#20166)
    
    Building on #20156's borrowed tensor-cache record views, upload aligned
    records that require no decoding directly into their final WebGPU
    tensors. This avoids allocating a temporary CPU tensor, passing the
    record through the FFI decoder, and copying the CPU tensor to WebGPU.
    BF16, unaligned, and CPU records retain the existing decoder path.
    
    Also:
    - Validate exact tensor storage sizes, including packed sub-byte dtypes
    - Honor tensor byte offsets during WebGPU writes
    - Read `DLTensor::byte_offset` as an aligned unsigned 64-bit value and
    reject values outside JavaScript’s safe integer range
    - Dispose partially constructed tensors when loading fails
---
 web/src/memory.ts                          |  13 +
 web/src/runtime.ts                         | 132 +++++++---
 web/tests/node/test_memory.js              |  12 +
 web/tests/node/test_tensor_cache_webgpu.js | 372 +++++++++++++++++++++++++++++
 4 files changed, 499 insertions(+), 30 deletions(-)

diff --git a/web/src/memory.ts b/web/src/memory.ts
index 5da8b59640..290826d6a9 100644
--- a/web/src/memory.ts
+++ b/web/src/memory.ts
@@ -87,6 +87,19 @@ export class Memory {
     return this.viewI32[base];
   }
 
+  loadU64(ptr: Pointer): number {
+    if (this.buffer != this.memory.buffer) {
+      this.updateViews();
+    }
+    // WebAssembly is little-endian. Reject values JavaScript cannot represent 
exactly.
+    const base = ptr >> 2;
+    const value = this.viewU32[base] + this.viewU32[base + 1] * 0x100000000;
+    if (!Number.isSafeInteger(value)) {
+      throw new Error("Cannot represent uint64 value as a JavaScript number");
+    }
+    return value;
+  }
+
   loadF32(ptr: Pointer): number {
     if (this.buffer != this.memory.buffer) {
       this.updateViews();
diff --git a/web/src/runtime.ts b/web/src/runtime.ts
index 2cd5ea1827..1ce05b7dde 100644
--- a/web/src/runtime.ts
+++ b/web/src/runtime.ts
@@ -37,6 +37,7 @@ import {
   ArtifactCacheTemplate,
   ArtifactCacheType,
   TensorCacheAccessOptions,
+  TensorCacheEntry,
   TensorShardEntry,
   createArtifactCache,
   getTensorCacheRecordBytes,
@@ -546,7 +547,9 @@ export class Tensor extends TVMObject {
     const arrayOffsetDtypeLanes = arrayOffsetDtypeBits + SizeOf.U8;
     const arrayOffsetShape = arrayOffsetDtype + SizeOf.DLDataType;
     const arrayOffsetStrides = arrayOffsetShape + this.lib.sizeofPtr();
-    const arrayOffsetByteOffset = arrayOffsetStrides + this.lib.sizeofPtr();
+    const byteOffsetUnaligned = arrayOffsetStrides + this.lib.sizeofPtr();
+    const arrayOffsetByteOffset =
+      Math.ceil(byteOffsetUnaligned / SizeOf.I64) * SizeOf.I64;
     // dataPtr
     this.dataPtr = lib.memory.loadPointer(this.dltensor);
     // ndim
@@ -570,7 +573,7 @@ export class Tensor extends TVMObject {
     this.device = new DLDevice(deviceType, deviceId, lib);
 
     // byte_offset
-    this.byteOffset = lib.memory.loadI64(this.dltensor + 
arrayOffsetByteOffset);
+    this.byteOffset = lib.memory.loadU64(this.dltensor + 
arrayOffsetByteOffset);
   }
 
   /**
@@ -654,22 +657,50 @@ export class Tensor extends TVMObject {
    * @returns this
    */
   copyFromRawBytes(data: Uint8Array): this {
+    const nbytes = this.numStorageBytes();
+    if (nbytes != data.length) {
+      throw new Error("Expect the data's length equals nbytes=" + nbytes);
+    }
     // short cut for gpu copy
     if (this.device.deviceType === DeviceStrToEnum.webgpu) {
-      this.lib.webGPUContext?.copyRawBytesToBuffer(data, this.getDataPtr(), 0, 
data.length);
+      if (this.byteOffset % 4 != 0 || data.length % 4 != 0) {
+        throw new Error(
+          "WebGPU raw byte copies require four-byte-aligned offsets and sizes",
+        );
+      }
+      const webGPUContext = this.lib.webGPUContext;
+      if (webGPUContext === undefined) {
+        throw new Error("WebGPU context is not initialized");
+      }
+      webGPUContext.copyRawBytesToBuffer(
+        data,
+        this.getDataPtr(),
+        this.byteOffset,
+        data.length,
+      );
       return this;
     }
     // CPU copy
-    const size = this.shape.reduce((a, b) => {
-      return a * b;
-    }, 1);
-    const nbytes = this.dlDataType.numStorageBytes() * size;
-    if (nbytes != data.length) {
-      throw new Error("Expect the data's length equals nbytes=" + nbytes);
-    }
     this.ctx.tensorCopyFromJSBytes(this, data);
     return this;
   }
+
+  private numStorageBytes(): number {
+    let totalBits = this.dlDataType.bits * this.dlDataType.lanes;
+    if (!Number.isSafeInteger(totalBits) || totalBits < 0) {
+      throw new Error(`Invalid tensor dtype bit width: ${totalBits}`);
+    }
+    for (const dim of this.shape) {
+      if (!Number.isSafeInteger(dim) || dim < 0) {
+        throw new Error(`Invalid tensor dimension: ${dim}`);
+      }
+      totalBits *= dim;
+      if (!Number.isSafeInteger(totalBits)) {
+        throw new Error("Tensor storage size exceeds JavaScript's safe integer 
range");
+      }
+    }
+    return Math.ceil(totalBits / 8);
+  }
   /**
    * Return a copied Uint8Array of the raw bytes in the Tensor.
    * @returns The result array.
@@ -1312,6 +1343,36 @@ export class Instance implements Disposable {
     this.cacheMetadata = { ...this.cacheMetadata, ...(list["metadata"] as 
Record<string, any>) };
   }
 
+  /**
+   * Consume a tensor-cache record synchronously.
+   *
+   * Keeping the borrowed record view local to this non-async helper avoids
+   * capturing it across the caller's WebGPU synchronization point.
+   */
+  private loadTensorCacheRecordData(
+    shardBytes: Uint8Array,
+    rec: TensorCacheEntry,
+    cpuArray?: Tensor,
+    gpuArray?: Tensor,
+  ): void {
+    const recBytes = getTensorCacheRecordBytes(shardBytes, rec);
+    if (cpuArray !== undefined) {
+      this.ctx.arrayDecodeStorage(
+        cpuArray,
+        recBytes,
+        rec.format,
+        rec.dtype,
+      );
+    }
+    if (gpuArray !== undefined) {
+      if (cpuArray === undefined) {
+        gpuArray.copyFromRawBytes(recBytes);
+      } else {
+        gpuArray.copyFrom(cpuArray);
+      }
+    }
+  }
+
 
   /**
    * Fetch list of Tensor into the TensorCache.
@@ -1422,32 +1483,40 @@ export class Instance implements Disposable {
         buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
       const shardRecords = shard.records;
       for (let j = 0; j < shardRecords.length; ++j) {
+        let cpu_arr: Tensor | undefined;
+        let gpu_arr: Tensor | undefined;
         try {
           const rec = shardRecords[j];
-          const recSource = getTensorCacheRecordBytes(shardBytes, rec);
-          const cpu_arr = this.withNewScope(() => {
-            return this.detachFromCurrentScope(
-              this.empty(rec.shape, rec.dtype, this.cpu())
-            )
-          });
-          // first sync copy to cpu.
-          this.ctx.arrayDecodeStorage(cpu_arr, recSource, rec.format, 
rec.dtype);
-          // then async stream into GPU if needed
-          if (device.deviceType === DeviceStrToEnum.cpu) {
-            this.tensorCacheUpdate(rec.name, cpu_arr, false);
-            cpu_arr.dispose();
-          } else {
-            // allocate a gpu arr and async copy to it.
-            const gpu_arr = this.withNewScope(() => {
+          const requiresDecode =
+            rec.format === "f32-to-bf16" && rec.dtype === "float32";
+          const directToWebGPU =
+            device.deviceType === DeviceStrToEnum.webgpu &&
+            !requiresDecode &&
+            rec.nbytes % 4 === 0;
+
+          if (!directToWebGPU) {
+            cpu_arr = this.withNewScope(() => {
+              return this.detachFromCurrentScope(
+                this.empty(rec.shape, rec.dtype, this.cpu()),
+              );
+            });
+          }
+
+          if (device.deviceType !== DeviceStrToEnum.cpu) {
+            gpu_arr = this.withNewScope(() => {
               return this.detachFromCurrentScope(
-                this.empty(rec.shape, rec.dtype, device)
-              )
+                this.empty(rec.shape, rec.dtype, device),
+              );
             });
-            gpu_arr.copyFrom(cpu_arr);
+          }
+
+          this.loadTensorCacheRecordData(shardBytes, rec, cpu_arr, gpu_arr);
+
+          if (device.deviceType === DeviceStrToEnum.cpu) {
+            this.tensorCacheUpdate(rec.name, cpu_arr!, false);
+          } else {
             await device.sync();
             this.tensorCacheUpdate(rec.name, gpu_arr, false);
-            cpu_arr.dispose();
-            gpu_arr.dispose();
           }
         } catch (err) {
           this.env.logger(
@@ -1455,6 +1524,9 @@ export class Instance implements Disposable {
             "Error: " + err
           );
           throw err;
+        } finally {
+          cpu_arr?.dispose();
+          gpu_arr?.dispose();
         }
       }
       fetchedBytes += shard.nbytes;
diff --git a/web/tests/node/test_memory.js b/web/tests/node/test_memory.js
index 2daa9ff9b4..1d39179d8e 100644
--- a/web/tests/node/test_memory.js
+++ b/web/tests/node/test_memory.js
@@ -18,6 +18,18 @@
  */
 const { CachedCallStack, Memory } = require("../../src/memory");
 
+test("loadU64 reads unsigned values and rejects unsafe integers", () => {
+  const wasmMemory = new WebAssembly.Memory({ initial: 1 });
+  const memory = new Memory(wasmMemory);
+  const words = new Uint32Array(wasmMemory.buffer);
+
+  words.set([0x80000001, 1], 2);
+  expect(memory.loadU64(8)).toBe(0x180000001);
+
+  words.set([0, 0x200000], 2);
+  expect(() => memory.loadU64(8)).toThrow("Cannot represent uint64 value");
+});
+
 test("loadRawBytes returns an owned copy", () => {
   const wasmMemory = new WebAssembly.Memory({ initial: 1 });
   const memory = new Memory(wasmMemory);
diff --git a/web/tests/node/test_tensor_cache_webgpu.js 
b/web/tests/node/test_tensor_cache_webgpu.js
new file mode 100644
index 0000000000..9df60f4230
--- /dev/null
+++ b/web/tests/node/test_tensor_cache_webgpu.js
@@ -0,0 +1,372 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+const fs = require("fs");
+const path = require("path");
+const tvmjs = require("../../dist/tvmjs.bundle");
+
+global.GPUBufferUsage = {
+  MAP_READ: 1 << 0,
+  COPY_DST: 1 << 1,
+  COPY_SRC: 1 << 2,
+  STORAGE: 1 << 3,
+  UNIFORM: 1 << 4,
+};
+
+const wasmSource = fs.readFileSync(
+  path.join(__dirname, "../../dist/wasm/tvmjs_runtime.wasm"),
+);
+
+function createInstance() {
+  return new tvmjs.Instance(
+    new WebAssembly.Module(wasmSource),
+    tvmjs.createPolyfillWASI(),
+  );
+}
+
+function createMockGPUDevice({ detachWriteSources = false } = {}) {
+  const buffers = [];
+  const writes = [];
+  const queue = {
+    submit: jest.fn(),
+    onSubmittedWorkDone: jest.fn(() => Promise.resolve()),
+    writeBuffer: jest.fn(
+      (buffer, bufferOffset, data, dataOffset = 0, size = data.byteLength) => {
+        const source = new Uint8Array(data.buffer, data.byteOffset, 
data.byteLength);
+        const snapshot = source.slice(dataOffset, dataOffset + size);
+        if (bufferOffset + snapshot.byteLength > buffer.contents.byteLength) {
+          throw new RangeError(
+            `write of ${snapshot.byteLength} bytes at ${bufferOffset} exceeds 
` +
+            `${buffer.contents.byteLength}-byte mock buffer`,
+          );
+        }
+        new Uint8Array(buffer.contents).set(snapshot, bufferOffset);
+        writes.push({ buffer, bufferOffset, data, dataOffset, size, snapshot 
});
+        if (detachWriteSources) {
+          structuredClone(data.buffer, { transfer: [data.buffer] });
+        }
+      },
+    ),
+  };
+  const device = {
+    queue,
+    lost: new Promise(() => {}),
+    addEventListener: jest.fn(),
+    pushErrorScope: jest.fn(),
+    popErrorScope: jest.fn(() => Promise.resolve(null)),
+    createBuffer: jest.fn((descriptor) => {
+      const buffer = {
+        size: descriptor.size,
+        contents: new ArrayBuffer(descriptor.size),
+        destroy: jest.fn(),
+      };
+      buffers.push(buffer);
+      return buffer;
+    }),
+    destroy: jest.fn(),
+  };
+  return { device, buffers, writes };
+}
+
+function createArtifactCache(manifest, shard) {
+  return {
+    hasAllKeys: async () => true,
+    addToCache: async () => {},
+    deleteInCache: async () => {},
+    fetchWithCache: async (_url, storeType) => {
+      return storeType === "json" ? manifest : shard;
+    },
+  };
+}
+
+test("WebGPU tensor cache uploads pass-through records directly", async () => {
+  const tvm = createInstance();
+  const gpu = createMockGPUDevice();
+  tvm.initWebGPU(gpu.device);
+
+  const records = [
+    {
+      name: "test.direct.raw_uint32",
+      shape: [2],
+      dtype: "uint32",
+      format: "raw",
+      byteOffset: 0,
+      nbytes: 8,
+    },
+    {
+      name: "test.direct.passthrough_float16",
+      shape: [2],
+      dtype: "float16",
+      format: "f32-to-bf16",
+      byteOffset: 8,
+      nbytes: 4,
+    },
+    {
+      name: "test.direct.raw_float32",
+      shape: [1],
+      dtype: "float32",
+      format: "raw",
+      byteOffset: 12,
+      nbytes: 4,
+    },
+    {
+      name: "test.decode.packed_bf16",
+      shape: [2],
+      dtype: "float32",
+      format: "f32-to-bf16",
+      byteOffset: 16,
+      nbytes: 4,
+    },
+  ];
+  const shard = Uint8Array.from([
+    1, 2, 3, 4, 5, 6, 7, 8,
+    9, 10, 11, 12,
+    13, 14, 15, 16,
+    0x80, 0x3f, 0x00, 0xc0,
+  ]).buffer;
+  const manifest = {
+    metadata: {},
+    records: [{
+      dataPath: "params.bin",
+      format: "raw-shard",
+      nbytes: shard.byteLength,
+      records,
+    }],
+  };
+
+  const originalDecode = tvm.ctx.arrayDecodeStorage;
+  const decode = jest.fn((...args) => originalDecode(...args));
+  tvm.ctx.arrayDecodeStorage = decode;
+  try {
+    await tvm.fetchTensorCache(
+      "https://example.test/model/";,
+      tvm.webgpu(),
+      { artifactCache: createArtifactCache(manifest, shard) },
+    );
+
+    expect(decode).toHaveBeenCalledTimes(1);
+    expect(decode.mock.calls[0][2]).toBe("f32-to-bf16");
+    expect(decode.mock.calls[0][3]).toBe("float32");
+    expect(gpu.writes.map((write) => Array.from(write.snapshot))).toEqual([
+      [1, 2, 3, 4, 5, 6, 7, 8],
+      [9, 10, 11, 12],
+      [13, 14, 15, 16],
+      [0, 0, 128, 63, 0, 0, 0, 192],
+    ]);
+  } finally {
+    tvm.ctx.arrayDecodeStorage = originalDecode;
+    tvm.tensorCacheClear();
+    tvm.dispose();
+  }
+});
+
+test("direct upload snapshots a borrowed shard view before synchronization", 
async () => {
+  const tvm = createInstance();
+  const gpu = createMockGPUDevice({ detachWriteSources: true });
+  tvm.initWebGPU(gpu.device);
+
+  const shard = new Uint8Array([1, 2, 3, 4]).buffer;
+  const manifest = {
+    metadata: {},
+    records: [{
+      dataPath: "params.bin",
+      format: "raw-shard",
+      nbytes: shard.byteLength,
+      records: [{
+        name: "test.direct.release_before_sync",
+        shape: [1],
+        dtype: "uint32",
+        format: "raw",
+        byteOffset: 0,
+        nbytes: 4,
+      }],
+    }],
+  };
+
+  try {
+    await tvm.fetchTensorCache(
+      "https://example.test/model/";,
+      tvm.webgpu(),
+      { artifactCache: createArtifactCache(manifest, shard) },
+    );
+
+    expect(gpu.writes).toHaveLength(1);
+    expect(gpu.writes[0].data.buffer).toBe(shard);
+    expect(gpu.writes[0].data.byteLength).toBe(0);
+    expect(Array.from(gpu.writes[0].snapshot)).toEqual([1, 2, 3, 4]);
+  } finally {
+    tvm.tensorCacheClear();
+    tvm.dispose();
+  }
+});
+
+test("direct tensor-cache upload rejects a record with the wrong size", async 
() => {
+  const log = jest.spyOn(console, "log").mockImplementation(() => {});
+  const tvm = createInstance();
+  const gpu = createMockGPUDevice();
+  tvm.initWebGPU(gpu.device);
+  const shard = new Uint8Array([1, 2, 3, 4]).buffer;
+  const manifest = {
+    metadata: {},
+    records: [{
+      dataPath: "params.bin",
+      format: "raw-shard",
+      nbytes: shard.byteLength,
+      records: [{
+        name: "test.direct.invalid_size",
+        shape: [2],
+        dtype: "uint32",
+        format: "raw",
+        byteOffset: 0,
+        nbytes: 4,
+      }],
+    }],
+  };
+
+  try {
+    await expect(tvm.fetchTensorCache(
+      "https://example.test/model/";,
+      tvm.webgpu(),
+      { artifactCache: createArtifactCache(manifest, shard) },
+    )).rejects.toThrow("nbytes=8");
+    expect(gpu.writes).toHaveLength(0);
+    expect(gpu.buffers).toHaveLength(1);
+    expect(gpu.buffers[0].destroy).toHaveBeenCalledTimes(1);
+  } finally {
+    tvm.tensorCacheClear();
+    tvm.dispose();
+    log.mockRestore();
+  }
+});
+
+test("CPU tensor-cache loading keeps the decoder path", async () => {
+  const tvm = createInstance();
+  const shard = new Uint8Array([
+    1, 2, 3, 4,
+    5, 6, 7, 8,
+  ]).buffer;
+  const manifest = {
+    metadata: {},
+    records: [{
+      dataPath: "params.bin",
+      format: "raw-shard",
+      nbytes: shard.byteLength,
+      records: [
+        {
+          name: "test.cpu.raw",
+          shape: [4],
+          dtype: "uint8",
+          format: "raw",
+          byteOffset: 0,
+          nbytes: 4,
+        },
+        {
+          name: "test.cpu.passthrough",
+          shape: [4],
+          dtype: "uint8",
+          format: "f32-to-bf16",
+          byteOffset: 4,
+          nbytes: 4,
+        },
+      ],
+    }],
+  };
+  const originalDecode = tvm.ctx.arrayDecodeStorage;
+  const decode = jest.fn((...args) => originalDecode(...args));
+  tvm.ctx.arrayDecodeStorage = decode;
+
+  try {
+    await tvm.fetchTensorCache(
+      "https://example.test/model/";,
+      tvm.cpu(),
+      { artifactCache: createArtifactCache(manifest, shard) },
+    );
+
+    expect(decode).toHaveBeenCalledTimes(2);
+    tvm.withNewScope(() => {
+      expect(Array.from(tvm.tensorCacheGet("test.cpu.raw").toRawBytes()))
+        .toEqual([1, 2, 3, 4]);
+      
expect(Array.from(tvm.tensorCacheGet("test.cpu.passthrough").toRawBytes()))
+        .toEqual([5, 6, 7, 8]);
+    });
+  } finally {
+    tvm.ctx.arrayDecodeStorage = originalDecode;
+    tvm.tensorCacheClear();
+    tvm.dispose();
+  }
+});
+
+test("copyFromRawBytes uses packed tensor size and tensor byte offset", () => {
+  const tvm = createInstance();
+  const gpu = createMockGPUDevice();
+  tvm.initWebGPU(gpu.device);
+
+  tvm.withNewScope(() => {
+    const packed = tvm.empty([3], "uint4", tvm.webgpu());
+    expect(() => packed.copyFromRawBytes(new 
Uint8Array(3))).toThrow("nbytes=2");
+
+    const base = tvm.empty([2], "uint32", tvm.webgpu());
+    const view = tvm.ctx.tensorCreateView(
+      base,
+      tvm.ctx.makeShapeTuple(new tvmjs.Scalar(1, "int")),
+      "uint32",
+      new tvmjs.Scalar(4, "int"),
+    );
+    view.copyFromRawBytes(new Uint8Array([21, 22, 23, 24]));
+
+    expect(gpu.writes).toHaveLength(1);
+    expect(gpu.writes[0].bufferOffset).toBe(4);
+    expect(Array.from(gpu.writes[0].snapshot)).toEqual([21, 22, 23, 24]);
+  });
+  tvm.dispose();
+});
+
+test("Tensor parses byte offsets as checked unsigned 64-bit values", () => {
+  const tvm = createInstance();
+
+  tvm.withNewScope(() => {
+    const tensor = tvm.empty([1], "uint32", tvm.cpu());
+    // DLTensor::byte_offset starts at byte 32 in the wasm32 ABI.
+    const byteOffsetPtr = tensor.dltensor + 32;
+    const words = new Uint32Array(tvm.memory.memory.buffer);
+    const wordOffset = byteOffsetPtr >>> 2;
+    const original = [words[wordOffset], words[wordOffset + 1]];
+
+    try {
+      words.set([0x80000001, 1], wordOffset);
+      const parsed = new tvmjs.Tensor(
+        tensor.dltensor,
+        tensor.lib,
+        tensor.ctx,
+        true,
+      );
+      expect(parsed.byteOffset).toBe(0x180000001);
+
+      words.set([0, 0x200000], wordOffset);
+      expect(() => new tvmjs.Tensor(
+        tensor.dltensor,
+        tensor.lib,
+        tensor.ctx,
+        true,
+      )).toThrow("Cannot represent uint64 value");
+    } finally {
+      words.set(original, wordOffset);
+    }
+  });
+  tvm.dispose();
+});

Reply via email to