[Web] Avoid tensor-cache record copies (#20156)

Tensor-cache shards can contain many records. Using `ArrayBuffer.slice`
for each entry duplicates the record before it is decoded or uploaded.
Validate each record range and use a `Uint8Array` view into the shard
instead. Preserve the original byte offset when the cache already
returns a view.
diff --git a/web/src/artifact_cache.ts b/web/src/artifact_cache.ts
index da655da..4c29327 100644
--- a/web/src/artifact_cache.ts
+++ b/web/src/artifact_cache.ts
@@ -38,6 +38,35 @@
 }
 
 /**
+ * Return a borrowed view of one tensor record within a shard.
+ *
+ * The returned view aliases the shard and is only valid for as long as the
+ * shard data remains alive. Tensor-cache decoding consumes it synchronously.
+ */
+export function getTensorCacheRecordBytes(
+  shardData: ArrayBuffer | Uint8Array,
+  record: Pick<TensorCacheEntry, "byteOffset" | "nbytes">,
+): Uint8Array {
+  const shardBytes =
+    shardData instanceof Uint8Array ? shardData : new Uint8Array(shardData);
+  const { byteOffset, nbytes } = record;
+  if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
+    throw new Error(`Invalid tensor-cache byteOffset: ${byteOffset}`);
+  }
+  if (!Number.isSafeInteger(nbytes) || nbytes < 0) {
+    throw new Error(`Invalid tensor-cache nbytes: ${nbytes}`);
+  }
+  const endOffset = byteOffset + nbytes;
+  if (!Number.isSafeInteger(endOffset) || endOffset > shardBytes.byteLength) {
+    throw new Error(
+      `Tensor-cache record range [${byteOffset}, ${endOffset}) exceeds ` +
+      `shard size ${shardBytes.byteLength}`,
+    );
+  }
+  return shardBytes.subarray(byteOffset, endOffset);
+}
+
+/**
  *   Common Interface for the artifact cache
  */
 export interface ArtifactCacheTemplate {
diff --git a/web/src/runtime.ts b/web/src/runtime.ts
index 9c83a4d..2cd5ea1 100644
--- a/web/src/runtime.ts
+++ b/web/src/runtime.ts
@@ -39,6 +39,7 @@
   TensorCacheAccessOptions,
   TensorShardEntry,
   createArtifactCache,
+  getTensorCacheRecordBytes,
 } from "./artifact_cache";
 import * as compact from "./compact";
 import * as ctypes from "./ctypes";
@@ -1417,18 +1418,20 @@
         this.env.logger("Error: Cannot fetch " + dataUrl + " err= " + err);
         throw err;
       }
+      const shardBytes =
+        buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
       const shardRecords = shard.records;
       for (let j = 0; j < shardRecords.length; ++j) {
         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())
             )
           });
-          const recSource = buffer.slice(rec.byteOffset, rec.byteOffset + rec.nbytes);
           // first sync copy to cpu.
-          this.ctx.arrayDecodeStorage(cpu_arr, new Uint8Array(recSource), rec.format, rec.dtype);
+          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);
diff --git a/web/tests/node/test_tensor.js b/web/tests/node/test_tensor.js
index 6aadc70..2790bd1 100644
--- a/web/tests/node/test_tensor.js
+++ b/web/tests/node/test_tensor.js
@@ -54,3 +54,60 @@
     testArrayCopy("float64", Float64Array);
   });
 });
+
+test("tensor cache loads adjacent records from a Uint8Array shard", async () => {
+  const backing = new Uint8Array([90, 91, 1, 2, 3, 4, 5, 6, 7, 8, 92]);
+  const shard = backing.subarray(2, 10);
+  const manifest = {
+    metadata: {},
+    records: [{
+      dataPath: "params.bin",
+      format: "raw-shard",
+      nbytes: shard.byteLength,
+      records: [
+        {
+          name: "test.record_view.first",
+          shape: [4],
+          dtype: "uint8",
+          format: "raw",
+          byteOffset: 0,
+          nbytes: 4,
+        },
+        {
+          name: "test.record_view.second",
+          shape: [4],
+          dtype: "uint8",
+          format: "raw",
+          byteOffset: 4,
+          nbytes: 4,
+        },
+      ],
+    }],
+  };
+  const artifactCache = {
+    hasAllKeys: async () => true,
+    addToCache: async () => {},
+    deleteInCache: async () => {},
+    fetchWithCache: async (_url, storeType) => {
+      return storeType === "json" ? manifest : shard;
+    },
+  };
+
+  await tvm.fetchTensorCache(
+    "https://example.test/model/",
+    tvm.cpu(),
+    { artifactCache },
+  );
+
+  tvm.withNewScope(() => {
+    assert.deepStrictEqual(
+      Array.from(tvm.tensorCacheGet("test.record_view.first").toArray()),
+      [1, 2, 3, 4],
+    );
+    assert.deepStrictEqual(
+      Array.from(tvm.tensorCacheGet("test.record_view.second").toArray()),
+      [5, 6, 7, 8],
+    );
+  });
+  tvm.tensorCacheClear();
+});
diff --git a/web/tests/node/test_tensor_cache.js b/web/tests/node/test_tensor_cache.js
new file mode 100644
index 0000000..5ab92e3
--- /dev/null
+++ b/web/tests/node/test_tensor_cache.js
@@ -0,0 +1,86 @@
+/*
+ * 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 { getTensorCacheRecordBytes } = require("../../src/artifact_cache");
+
+test("tensor-cache record is a borrowed ArrayBuffer view", () => {
+  const shard = new Uint8Array([1, 2, 3, 4, 5, 6]).buffer;
+
+  const record = getTensorCacheRecordBytes(shard, {
+    byteOffset: 2,
+    nbytes: 3,
+  });
+
+  expect(Array.from(record)).toEqual([3, 4, 5]);
+  expect(record.buffer).toBe(shard);
+  expect(record.byteOffset).toBe(2);
+});
+
+test("tensor-cache record respects a Uint8Array shard offset", () => {
+  const backing = new Uint8Array([90, 91, 1, 2, 3, 4, 92]);
+  const shard = backing.subarray(2, 6);
+
+  const record = getTensorCacheRecordBytes(shard, {
+    byteOffset: 1,
+    nbytes: 2,
+  });
+
+  expect(Array.from(record)).toEqual([2, 3]);
+  expect(record.buffer).toBe(backing.buffer);
+  expect(record.byteOffset).toBe(shard.byteOffset + 1);
+});
+
+test("tensor-cache record may cover the full shard", () => {
+  const shard = new Uint8Array([1, 2, 3, 4]);
+
+  const record = getTensorCacheRecordBytes(shard, {
+    byteOffset: 0,
+    nbytes: shard.byteLength,
+  });
+
+  expect(record).toEqual(shard);
+  expect(record.buffer).toBe(shard.buffer);
+});
+
+test("tensor-cache record may be empty at the end of the shard", () => {
+  const shard = new Uint8Array([1, 2, 3, 4]);
+
+  const record = getTensorCacheRecordBytes(shard, {
+    byteOffset: shard.byteLength,
+    nbytes: 0,
+  });
+
+  expect(record.byteLength).toBe(0);
+  expect(record.byteOffset).toBe(shard.byteOffset + shard.byteLength);
+});
+
+test.each([
+  [{ byteOffset: -1, nbytes: 1 }, "byteOffset"],
+  [{ byteOffset: 0.5, nbytes: 1 }, "byteOffset"],
+  [{ byteOffset: Number.MAX_SAFE_INTEGER + 1, nbytes: 1 }, "byteOffset"],
+  [{ byteOffset: 0, nbytes: -1 }, "nbytes"],
+  [{ byteOffset: 0, nbytes: 0.5 }, "nbytes"],
+  [{ byteOffset: 0, nbytes: Number.MAX_SAFE_INTEGER + 1 }, "nbytes"],
+  [{ byteOffset: 5, nbytes: 0 }, "exceeds shard size"],
+  [{ byteOffset: 3, nbytes: 2 }, "exceeds shard size"],
+  [{ byteOffset: Number.MAX_SAFE_INTEGER, nbytes: 1 }, "exceeds shard size"],
+])("tensor-cache record rejects invalid range %j", (range, message) => {
+  expect(() => getTensorCacheRecordBytes(new ArrayBuffer(4), range)).toThrow(
+    message,
+  );
+});