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 5ac9a7f5f9 [Web] Give callers their own reference to cached shape 
tuples (#20501)
5ac9a7f5f9 is described below

commit 5ac9a7f5f910cd9800e5b33806fdbafd15f13ccd
Author: Akaash Parthasarathy <[email protected]>
AuthorDate: Thu Oct 1 21:40:09 2026 -0700

    [Web] Give callers their own reference to cached shape tuples (#20501)
    
    `makeShapeTuple` returns the object the shape cache owns. The cache is a
    256-entry LRU that disposes what it evicts, so once a prefill touches
    more than 256 distinct shapes a caller can hold a tuple that has already
    been freed. WebLLM hits this on a Radeon iGPU as `Object has already
    been disposed` followed by a device hang (mlc-ai/web-llm#844). #20130
    kept evicted tuples alive until teardown, whereas here the tuple is
    owned by the caller.
    
    1. `makeShapeTuple` takes a reference on the cached handle and returns a
    new object attached to the current scope, so eviction only drops the
    cache's reference. Like every other function that returns a TVM object,
    it now requires an open scope.
    2. `setDeviceLostAutoDispose(false)` lets an owner that already disposes
    the instance itself keep it from disposing on the device-lost promise
    
    A cache hit costs 15 to 30 ns more per call. Tests cover eviction while
    a caller holds the tuple and deferred disposal after device loss.
    
    ---------
    
    Signed-off-by: Akaash Parthasarathy <[email protected]>
---
 web/src/cache_state.ts                     |  7 ++---
 web/src/ctypes.ts                          |  5 ++++
 web/src/runtime.ts                         | 30 +++++++++++++++++++--
 web/tests/node/test_object.js              | 18 +++++++++++++
 web/tests/node/test_tensor_cache_webgpu.js | 42 ++++++++++++++++++++++++++++--
 5 files changed, 95 insertions(+), 7 deletions(-)

diff --git a/web/src/cache_state.ts b/web/src/cache_state.ts
index c571a071b8..ab7c5fcabf 100644
--- a/web/src/cache_state.ts
+++ b/web/src/cache_state.ts
@@ -124,8 +124,8 @@ export class LRUCache<K, V> {
  *     the JS→WASM FFI boundary each time. During LLM decode, the same shapes
  *     repeat every token (e.g. [1,32,128]), so caching avoids thousands of
  *     redundant FFI round-trips.
- *   - Invalidation: Never. Shape tuples are immutable value objects that
- *     remain valid for the lifetime of the TVM instance.
+ *   - Invalidation: Cache entries may be evicted, but returned shape tuples
+ *     hold independent references and remain valid for their caller's scope.
  *
  * Future additions (follow-up PR):
  * - **uniformCache**: Caches GPU uniform buffers keyed by content hash.
@@ -142,7 +142,8 @@ export class CacheState {
    * Key: comma-separated dimension string, e.g. "1,32,128"
    * Value: TVM ShapeTuple object (Disposable)
    *
-   * Invalidation rule: None required — shape tuples are immutable.
+   * Eviction releases only the cache's reference. Shape tuples returned to
+   * callers have independent references and normal scope-managed lifetimes.
    */
   readonly shapeCache: LRUCache<string, Disposable>;
 
diff --git a/web/src/ctypes.ts b/web/src/ctypes.ts
index 1f91779692..0333f056dd 100644
--- a/web/src/ctypes.ts
+++ b/web/src/ctypes.ts
@@ -176,6 +176,11 @@ export type FTVMFFIWasmFunctionCreate = (
  */
 export type FTVMFFIWasmFunctionDeleter = (self: Pointer) => void;
 
+/**
+ * int TVMFFIObjectIncRef(TVMFFIObjectHandle obj);
+ */
+export type FTVMFFIObjectIncRef = (obj: Pointer) => number;
+
 /**
  * int TVMFFIObjectDecRef(TVMFFIObjectHandle obj);
  */
diff --git a/web/src/runtime.ts b/web/src/runtime.ts
index abc49be594..996d7adc33 100644
--- a/web/src/runtime.ts
+++ b/web/src/runtime.ts
@@ -921,6 +921,7 @@ export class Instance implements Disposable {
   private initProgressCallback: Array<InitProgressCallback> = [];
   private rng: LinearCongruentialGenerator;
   private deviceLostIsError = true;  // whether device.lost is due to actual 
error or dispose()
+  private autoDisposeOnDeviceLost = true;
   private cacheState: CacheState = new CacheState();
 
   /**
@@ -1868,13 +1869,27 @@ export class Instance implements Disposable {
    */
   makeShapeTuple(shape: Array<number>): TVMObject {
     const key = CacheState.computeShapeKey(shape);
-    return this.cacheState.shapeCache.get(key, () => {
+    const cachedTuple = this.cacheState.shapeCache.get(key, () => {
       const shapeArray = shape.map((value) => new Scalar(value, "int"));
       const tuple = this.ctx.makeShapeTuple(...shapeArray);
       // Detach from scope so the cached object survives across scopes.
       this.detachFromCurrentScope(tuple);
       return tuple;
     }) as TVMObject;
+
+    // The cache owns its wrapper and may release it on eviction. Give the
+    // caller an independent strong reference with the usual scope lifetime.
+    const handle = cachedTuple.getHandle();
+    this.lib.checkCall(
+      (this.lib.exports.TVMFFIObjectIncRef as 
ctypes.FTVMFFIObjectIncRef)(handle)
+    );
+    const callerTuple = new TVMObject(handle, this.lib, this.ctx);
+    try {
+      return this.attachToCurrentScope(callerTuple);
+    } catch (err) {
+      callerTuple.dispose();
+      throw err;
+    }
   }
   /**
    * Get type index from type key.
@@ -2066,7 +2081,7 @@ export class Instance implements Disposable {
     });
 
     device.lost.then((info: any) => {
-      if (this.deviceLostIsError) {
+      if (this.deviceLostIsError && this.autoDisposeOnDeviceLost) {
         console.error("Device lost, calling Instance.dispose(). Please 
initialize again. ", info);
         this.dispose();
       }
@@ -2094,6 +2109,17 @@ export class Instance implements Disposable {
     this.lib.webGPUContext = webGPUContext;
   }
 
+  /**
+   * Configure automatic disposal after WebGPU device loss.
+   *
+   * External owners should disable automatic disposal after initialization if
+   * they serialize disposal with active runtime calls.
+   * @param enabled Whether device loss should immediately dispose this 
instance.
+   */
+  setDeviceLostAutoDispose(enabled: boolean): void {
+    this.autoDisposeOnDeviceLost = enabled;
+  }
+
   /** Register all object factory */
   private registerObjectFactoryFuncs(): void {
     this.registerObjectConstructor("ffi.Array",
diff --git a/web/tests/node/test_object.js b/web/tests/node/test_object.js
index 0e9e43097a..f797be4466 100644
--- a/web/tests/node/test_object.js
+++ b/web/tests/node/test_object.js
@@ -43,3 +43,21 @@ test("object", () => {
     assert(t1.getHandle() == t.getHandle());
   });
 });
+
+test("shape cache does not invalidate caller-owned tuples", () => {
+  tvm.beginScope();
+  const disposedTuple = tvm.makeShapeTuple([987654321, -1]);
+  disposedTuple.dispose();
+  const cachedTuple = tvm.makeShapeTuple([987654321, -1]);
+  assert.doesNotThrow(() => cachedTuple.typeKey());
+
+  const evictedTuple = tvm.makeShapeTuple([987654321, 0]);
+  for (let i = 1; i <= 256; ++i) {
+    tvm.makeShapeTuple([987654321, i]);
+  }
+  assert.doesNotThrow(() => evictedTuple.typeKey());
+
+  tvm.endScope();
+  assert.throws(() => cachedTuple.getHandle(), /already been disposed/);
+  assert.throws(() => evictedTuple.getHandle(), /already been disposed/);
+});
diff --git a/web/tests/node/test_tensor_cache_webgpu.js 
b/web/tests/node/test_tensor_cache_webgpu.js
index 3383a2e41e..b09250eb7c 100644
--- a/web/tests/node/test_tensor_cache_webgpu.js
+++ b/web/tests/node/test_tensor_cache_webgpu.js
@@ -39,7 +39,18 @@ function createInstance() {
   );
 }
 
-function createMockGPUDevice({ detachWriteSources = false } = {}) {
+function createDeferred() {
+  let resolve;
+  const promise = new Promise((resolvePromise) => {
+    resolve = resolvePromise;
+  });
+  return { promise, resolve };
+}
+
+function createMockGPUDevice({
+  detachWriteSources = false,
+  lost = new Promise(() => {}),
+} = {}) {
   const buffers = [];
   const writes = [];
   const queue = {
@@ -65,7 +76,7 @@ function createMockGPUDevice({ detachWriteSources = false } = 
{}) {
   };
   const device = {
     queue,
-    lost: new Promise(() => {}),
+    lost,
     addEventListener: jest.fn(),
     pushErrorScope: jest.fn(),
     popErrorScope: jest.fn(() => Promise.resolve(null)),
@@ -94,6 +105,33 @@ function createArtifactCache(manifest, shard) {
   };
 }
 
+test("an external owner can defer disposal after device loss", async () => {
+  const lost = createDeferred();
+  const tvm = createInstance();
+  const dispose = jest.spyOn(tvm, "dispose");
+  const gpu = createMockGPUDevice({ lost: lost.promise });
+  const log = jest.spyOn(console, "error").mockImplementation(() => {});
+  try {
+    tvm.initWebGPU(gpu.device);
+    tvm.setDeviceLostAutoDispose(false);
+
+    lost.resolve({ reason: "unknown", message: "test device loss" });
+    await lost.promise;
+    await Promise.resolve();
+
+    expect(dispose).not.toHaveBeenCalled();
+
+    tvm.dispose();
+    expect(dispose).toHaveBeenCalledTimes(1);
+  } finally {
+    if (dispose.mock.calls.length === 0) {
+      tvm.dispose();
+    }
+    dispose.mockRestore();
+    log.mockRestore();
+  }
+});
+
 test("WebGPU tensor cache uploads pass-through records and decodes BF16 in 
place", async () => {
   const tvm = createInstance();
   const gpu = createMockGPUDevice();

Reply via email to