#844·web-llm

[Bug] shapeCache LRU eviction disposes in-use ShapeTuples → "Object has already been disposed" → GPU device hang (regression introduced in 0.2.83)

Author: YuWenYang1Created Aug 6, 2026Updated Aug 18, 2026

Body

markdown
## Summary

Starting in **0.2.83**, generation reliably fails on a low-end integrated GPU with
`Error: Object has already been disposed`, which then takes down the entire page's
WebGPU device (`DXGI_ERROR_DEVICE_HUNG`). A page reload is not enough to recover —
the browser has to be fully restarted.

**0.2.82 is unaffected.** Same machine, same model, same weights, same prompt.

The trigger correlates with prompt length: short prompts work, and past roughly
120 tokens it fails every time.

I believe the cause is the `shapeCache` added in 0.2.83, whose LRU eviction callback
calls `dispose()` on cached `ShapeTuple` objects that callers may still be holding.

## Environment

| | |
|---|---|
| `@mlc-ai/web-llm` | **0.2.83, 0.2.84 fail** / 0.2.82 works |
| GPU | AMD Radeon(TM) Graphics (integrated), `VENDOR=0x1002 DEVICE=0x15e7` |
| Driver | `31.0.12042.2002` |
| OS / Browser | Windows 11, Chrome (`chrome://gpu` reports WebGPU: Hardware accelerated) |
| Models | `Qwen3-1.7B-q4f16_1-MLC`, `Qwen3-0.6B-q4f16_1-MLC`, `Qwen3.5-2B-q4f16_1-MLC` |
| Engine | `WebWorkerMLCEngine` (also reproduces with `ServiceWorkerMLCEngine`) |

## Reproduction

```js
import * as webllm from "@mlc-ai/web-llm";

const engine = new webllm.WebWorkerMLCEngine(
  new Worker(new URL("./worker.js", import.meta.url), { type: "module" }),
);
await engine.reload("Qwen3-1.7B-q4f16_1-MLC", { context_window_size: 4096 });

// Works: prompts up to ~100 characters (~90 tokens)
// Fails: ~130 characters (~120 tokens) and above — every time
await engine.chat.completions.create({
  messages: [{ role: "user", content: LONG_PROMPT }],
  stream: true,
});
```

Result on 0.2.84, ramping prompt length within a **single** engine and a **single**
`reload()` (prompt length is the only variable):

```
 20 chars ✓ 14.4s     40 chars ✓ 11.4s     60 chars ✓ 5.6s
 80 chars ✓  2.7s    100 chars ✓  4.4s    130 chars ✗ Object has already been disposed
```

The exact same ramp on 0.2.82, same machine, same model:

```
100 ✓ 5.8s   130 ✓ 5.9s   160 ✓ 6.2s   200 ✓ 6.7s
240 ✓ 7.0s   280 ✓ 9.7s   327 ✓ 10.8s  500 ✓ 10.9s   800 ✓ 10.0s
```

Two things worth noting about the failing case:

* **Latency does not grow with prompt length** (the 20-char case is the *slowest*
  at 14.4s). Decode happily emits hundreds of tokens. Only prefill breaks.
* It is **not** about the `system` role: `system` + 16 chars succeeds, while
  327 chars stuffed into a single `user` message fails.

Once it fails, the GPU device is gone for that page:

```
ID3D12Device::GetDeviceRemovedReason failed with DXGI_ERROR_DEVICE_HUNG (0x887A0006)
Failed to execute 'requestDevice' on 'GPUAdapter': D3D12 create command queue failed
  with DXGI_ERROR_DEVICE_REMOVED (0x887A0005)
```

## Bisect

I diffed the built `lib/index.js` from unpkg across versions:

| Version | occurrences of `shapeCache` | Published | Result |
|---|---|---|---|
| 0.2.80 | 0 | 2025-11-24 | works |
| 0.2.81 | 0 | 2026-02-17 | works |
| 0.2.82 | 0 | 2026-03-13 | **works** |
| **0.2.83** | **7** | 2026-04-29 | **fails** |
| 0.2.84 | 7 | 2026-05-27 | fails |

The cache appears in exactly the release where the failure appears.

(As a sanity check on the "is it really the library?" question: I also fetched the
deployed bundle from `chat.webllm.ai`, which works fine on this same machine, and
confirmed it contains **0** occurrences of `shapeCache` / `CacheState` / `LRUCache`.)

## Suspected cause

In 0.2.84's bundled TVM.js runtime:

```js
class CacheState {
    constructor(shapeCacheSize = 256) {
        this.shapeCache = new LRUCache(shapeCacheSize, (_key, value) => value.dispose());
    }
    // …
}
```

and the only consumer:

```js
makeShapeTuple(shape) {
    const key = CacheState.computeShapeKey(shape);
    return 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;
    });
}
```

Two properties combine badly here:

1. `makeShapeTuple()` **returns the cached object itself** to the caller — a borrowed
   reference. The cache has no way to know whether a caller still holds it.
2. The tuple is deliberately **detached from scope management**, so the cache is its
   sole owner and the eviction callback is the only thing that frees it.

So when a 257th distinct shape is requested, `LRUCache.get()` evicts the
least-recently-used entry and immediately `dispose()`s it — even if an earlier call
handed that same object to a caller that is still using it. The next use throws
`Object has already been disposed`, and on this driver the resulting bad dispatch
hangs the device rather than failing cleanly.

**The docstring on `CacheState` states the intended invariant, and it is the opposite
of what the code does:**

```
 * - **shapeCache**: Caches TVM ShapeTuple objects keyed by dimension string.
 *   - Invalidation: Never. Shape tuples are immutable value objects that
 *     remain valid for the lifetime of the TVM instance.
```

"Invalidation: Never" — but a bounded LRU with a disposing eviction callback
invalidates as soon as it is full. I think the `dispose()` callback was added by
analogy with the `uniformCache` described just below it in the same comment (which
*does* need invalidation), and the mismatch was not caught because on faster GPUs
the window between "handed out" and "used" is short enough to rarely lose the race.

### Why prompt length is the trigger

Longer prefills exercise more *distinct* shapes (varying chunk/sequence dimensions),
so they churn through the 256-entry budget faster and start evicting while earlier
tuples are still live. Short prompts and the decode loop reuse the same handful of
shapes (`[1,32,128]` etc.) and never fill the cache — which matches the docstring's
own stated motivation. This also explains why the 0.6B model tolerates longer prompts
than the 1.7B one: fewer layers means fewer distinct shapes per forward pass.

This is the same class of bug as #571 (`[Fix][Grammar] Detach token table to prevent
disposing it`) — an object being freed while a live reference to it still exists.

## Suggested fix

The minimal change that matches the documented intent is to **stop disposing on
eviction**:

```js
// Shape tuples are immutable and tiny; the cache cannot know whether a caller
// still holds the borrowed reference it handed out.
this.shapeCache = new LRUCache(shapeCacheSize);
```

`CacheState.dispose()` already walks every cached object and disposes it at teardown,
so nothing is leaked for the lifetime of the TVM instance — which is exactly the
lifetime the docstring says these objects should have.

If bounded memory really is required, then `makeShapeTuple()` cannot keep handing out
borrowed references — it would need refcounting, or to return a copy.

Happy to test a patch on the affected hardware; this reproduces 100% of the time here.

## Workaround (for anyone hitting this)

Pin to the last good version — note the **exact** version, `^0.2.82` would resolve to
0.2.84 and reintroduce the bug:

```json
{ "dependencies": { "@mlc-ai/web-llm": "0.2.82" } }
```

The tradeoff is that models added to the built-in list in 0.2.83+ (Qwen3.5 among them)
aren't available on 0.2.82, so you're stuck on the older model lineup until this is
fixed. On 0.2.82 we run `Qwen3-1.7B-q4f16_1-MLC` with `context_window_size: 4096`
and prompts up to 800 characters with no failures.