@tryhamster/gerbil 1.0.0-rc.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +247 -84
  3. package/dist/architectures-C1I5V3Dt.mjs +6070 -0
  4. package/dist/architectures-C1I5V3Dt.mjs.map +1 -0
  5. package/dist/browser/index.d.ts +264 -588
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +585 -2334
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/cli.mjs +625 -1098
  10. package/dist/cli.mjs.map +1 -1
  11. package/dist/defaults-9komdrbY.mjs +24 -0
  12. package/dist/defaults-9komdrbY.mjs.map +1 -0
  13. package/dist/frameworks/express.d.mts +1 -3
  14. package/dist/frameworks/express.d.mts.map +1 -1
  15. package/dist/frameworks/express.mjs +7 -7
  16. package/dist/frameworks/express.mjs.map +1 -1
  17. package/dist/frameworks/fastify.d.mts +1 -1
  18. package/dist/frameworks/fastify.d.mts.map +1 -1
  19. package/dist/frameworks/fastify.mjs +3 -3
  20. package/dist/frameworks/fastify.mjs.map +1 -1
  21. package/dist/frameworks/hono.d.mts +1 -1
  22. package/dist/frameworks/hono.d.mts.map +1 -1
  23. package/dist/frameworks/hono.mjs +4 -4
  24. package/dist/frameworks/hono.mjs.map +1 -1
  25. package/dist/frameworks/next.d.mts +3 -2
  26. package/dist/frameworks/next.d.mts.map +1 -1
  27. package/dist/frameworks/next.mjs +4 -4
  28. package/dist/frameworks/next.mjs.map +1 -1
  29. package/dist/frameworks/react.d.mts +1 -1
  30. package/dist/frameworks/trpc.d.mts +1 -1
  31. package/dist/frameworks/trpc.d.mts.map +1 -1
  32. package/dist/frameworks/trpc.mjs +4 -4
  33. package/dist/frameworks/trpc.mjs.map +1 -1
  34. package/dist/gerbil-BHrJJIa4.mjs +1656 -0
  35. package/dist/gerbil-BHrJJIa4.mjs.map +1 -0
  36. package/dist/gerbil-BT9fCydo.d.mts +488 -0
  37. package/dist/gerbil-BT9fCydo.d.mts.map +1 -0
  38. package/dist/gerbil-DomNfIr1.mjs +4 -0
  39. package/dist/gpu/hooks.d.mts +520 -0
  40. package/dist/gpu/hooks.d.mts.map +1 -0
  41. package/dist/gpu/hooks.mjs +1188 -0
  42. package/dist/gpu/hooks.mjs.map +1 -0
  43. package/dist/gpu/index.d.mts +2 -0
  44. package/dist/gpu/index.mjs +6 -0
  45. package/dist/gpu-33qCAtHW.mjs +3615 -0
  46. package/dist/gpu-33qCAtHW.mjs.map +1 -0
  47. package/dist/index-Dgmb2kE3.d.mts +245 -0
  48. package/dist/index-Dgmb2kE3.d.mts.map +1 -0
  49. package/dist/index-jEAL2s-A.d.mts +2022 -0
  50. package/dist/index-jEAL2s-A.d.mts.map +1 -0
  51. package/dist/index.d.mts +22 -487
  52. package/dist/index.d.mts.map +1 -1
  53. package/dist/index.mjs +13 -8
  54. package/dist/index.mjs.map +1 -1
  55. package/dist/indexeddb-store-BWIMtxxH.mjs +103 -0
  56. package/dist/indexeddb-store-BWIMtxxH.mjs.map +1 -0
  57. package/dist/indexeddb-store-ClH12Xnl.mjs +4 -0
  58. package/dist/integrations/ai-sdk.d.mts +75 -6
  59. package/dist/integrations/ai-sdk.d.mts.map +1 -1
  60. package/dist/integrations/ai-sdk.mjs +131 -15
  61. package/dist/integrations/ai-sdk.mjs.map +1 -1
  62. package/dist/integrations/langchain.d.mts +1 -1
  63. package/dist/integrations/langchain.d.mts.map +1 -1
  64. package/dist/integrations/langchain.mjs +5 -5
  65. package/dist/integrations/langchain.mjs.map +1 -1
  66. package/dist/integrations/llamaindex.d.mts +1 -1
  67. package/dist/integrations/llamaindex.d.mts.map +1 -1
  68. package/dist/integrations/llamaindex.mjs +5 -5
  69. package/dist/integrations/llamaindex.mjs.map +1 -1
  70. package/dist/integrations/mcp-client.mjs +3 -3
  71. package/dist/integrations/mcp-client.mjs.map +1 -1
  72. package/dist/integrations/mcp.d.mts +3 -2
  73. package/dist/integrations/mcp.d.mts.map +1 -1
  74. package/dist/integrations/mcp.mjs +5 -5
  75. package/dist/{mcp-BvbriaBy.mjs → mcp-1DaMsaBc.mjs} +4 -4
  76. package/dist/mcp-1DaMsaBc.mjs.map +1 -0
  77. package/dist/memory/index.d.mts +3 -0
  78. package/dist/memory/index.mjs +6 -0
  79. package/dist/memory-D1P7Tmda.mjs +4 -0
  80. package/dist/memory-DVN0MnIG.mjs +132 -0
  81. package/dist/memory-DVN0MnIG.mjs.map +1 -0
  82. package/dist/memory-Dj0J1v88.mjs +294 -0
  83. package/dist/memory-Dj0J1v88.mjs.map +1 -0
  84. package/dist/moonshine-stt-BLyVoRpB.mjs +4 -0
  85. package/dist/moonshine-stt-v_P_Ci_m.mjs +11936 -0
  86. package/dist/moonshine-stt-v_P_Ci_m.mjs.map +1 -0
  87. package/dist/{one-liner-s-lD8rCC.mjs → one-liner-DnQn7HJK.mjs} +14 -16
  88. package/dist/one-liner-DnQn7HJK.mjs.map +1 -0
  89. package/dist/repl-jV5gcJFA.mjs +9 -0
  90. package/dist/skills/index.d.mts +270 -320
  91. package/dist/skills/index.d.mts.map +1 -1
  92. package/dist/skills/index.mjs +5 -5
  93. package/dist/{skills-CD3Orlex.mjs → skills-DX8D59UH.mjs} +187 -32
  94. package/dist/skills-DX8D59UH.mjs.map +1 -0
  95. package/dist/{tools-Bi1P7Xoy.mjs → tools-DQ1mPUw5.mjs} +34 -22
  96. package/dist/tools-DQ1mPUw5.mjs.map +1 -0
  97. package/dist/{types-CiTc7ez3.d.mts → types-D6FiR_oh.d.mts} +106 -12
  98. package/dist/types-D6FiR_oh.d.mts.map +1 -0
  99. package/dist/types-DQBe2lFo.d.mts +165 -0
  100. package/dist/types-DQBe2lFo.d.mts.map +1 -0
  101. package/dist/{utils-CZBZ8dgR.mjs → utils-DKO55ZmZ.mjs} +1 -1
  102. package/dist/{utils-CZBZ8dgR.mjs.map → utils-DKO55ZmZ.mjs.map} +1 -1
  103. package/dist/vector-B0panuy6.mjs +95 -0
  104. package/dist/vector-B0panuy6.mjs.map +1 -0
  105. package/docs/PROJECT-STATE.md +321 -0
  106. package/docs/adding-a-model-family.md +280 -0
  107. package/docs/ai-sdk.md +70 -61
  108. package/docs/architecture/overview.md +17 -7
  109. package/docs/browser.md +203 -8
  110. package/docs/embeddings.md +156 -0
  111. package/docs/gerbil-site-native-migration.md +217 -0
  112. package/docs/gpu-engine/architectures.md +398 -0
  113. package/docs/gpu-engine/ir.md +372 -0
  114. package/docs/gpu-engine/kernels.md +718 -0
  115. package/docs/gpu-engine/paper.html +1759 -0
  116. package/docs/gpu-engine/paper.md +2109 -0
  117. package/docs/gpu-engine/safetensors.md +312 -0
  118. package/docs/gpu-engine/tokenizer.md +302 -0
  119. package/docs/memory-rag.md +91 -0
  120. package/docs/metal-safari-intel.md +190 -0
  121. package/docs/mobile-failure-diagnosis.md +124 -0
  122. package/docs/mobile.md +99 -0
  123. package/docs/observability.md +230 -0
  124. package/docs/onnx-removal-plan.md +339 -0
  125. package/docs/research/autoresearch-portable.md +904 -0
  126. package/docs/research/dispatch-reduction-hivemind.md +84 -0
  127. package/docs/research/ios-safari-model-caching.md +117 -0
  128. package/docs/research/mobile-webgpu-speed-fusion.md +135 -0
  129. package/docs/research/native-stt-model-selection.md +49 -0
  130. package/docs/research/native-tts-model-selection.md +90 -0
  131. package/docs/research/native-vs-chromium-decision.md +152 -0
  132. package/docs/research/nemotron-mamba2-inference.md +910 -0
  133. package/docs/research/qwen35-multimodal.md +293 -0
  134. package/docs/research/qwen36-gemma4-targets.md +337 -0
  135. package/docs/research/sota-embedding-models.md +179 -0
  136. package/docs/research/sota-mobile-models-2026.md +263 -0
  137. package/docs/research/sota-modality-models.md +202 -0
  138. package/docs/research/tps-baselines.md +71 -0
  139. package/docs/research/webgpu-m4-reference.md +104 -0
  140. package/docs/site-update-plan.md +155 -0
  141. package/docs/structured-output.md +123 -0
  142. package/docs/stt.md +63 -446
  143. package/docs/tts.md +77 -499
  144. package/docs/vision.md +100 -338
  145. package/package.json +22 -7
  146. package/dist/chrome-backend-CORwaIyC.mjs +0 -1212
  147. package/dist/chrome-backend-CORwaIyC.mjs.map +0 -1
  148. package/dist/chrome-backend-DIKYoWj-.mjs +0 -3
  149. package/dist/gerbil-CJ3ifloF.mjs +0 -4
  150. package/dist/gerbil-Dw4Qj77e.mjs +0 -1631
  151. package/dist/gerbil-Dw4Qj77e.mjs.map +0 -1
  152. package/dist/gerbil-qOTe1nl2.d.mts +0 -431
  153. package/dist/gerbil-qOTe1nl2.d.mts.map +0 -1
  154. package/dist/kokoro-BNTb6egA.mjs +0 -20210
  155. package/dist/kokoro-BNTb6egA.mjs.map +0 -1
  156. package/dist/kokoro-CMOGDSgT.js +0 -20212
  157. package/dist/kokoro-CMOGDSgT.js.map +0 -1
  158. package/dist/mcp-BvbriaBy.mjs.map +0 -1
  159. package/dist/one-liner-s-lD8rCC.mjs.map +0 -1
  160. package/dist/repl-DveXw36T.mjs +0 -9
  161. package/dist/skills-CD3Orlex.mjs.map +0 -1
  162. package/dist/stt-Bu-E23Sc.js +0 -433
  163. package/dist/stt-Bu-E23Sc.js.map +0 -1
  164. package/dist/stt-CpLYbGFd.mjs +0 -433
  165. package/dist/stt-CpLYbGFd.mjs.map +0 -1
  166. package/dist/stt-DRPLEEHB.mjs +0 -3
  167. package/dist/tools-Bi1P7Xoy.mjs.map +0 -1
  168. package/dist/transformers.web-DiD1gTwk.js +0 -44695
  169. package/dist/transformers.web-DiD1gTwk.js.map +0 -1
  170. package/dist/transformers.web-u34VxRFM.js +0 -3
  171. package/dist/tts-CqroPaSK.js +0 -724
  172. package/dist/tts-CqroPaSK.js.map +0 -1
  173. package/dist/tts-DXgsKGCe.mjs +0 -3
  174. package/dist/tts-DeGANMNV.mjs +0 -730
  175. package/dist/tts-DeGANMNV.mjs.map +0 -1
  176. package/dist/types-CiTc7ez3.d.mts.map +0 -1
  177. /package/dist/{auto-update-S9s5-g0C.mjs → auto-update-BVaLXcDE.mjs} +0 -0
  178. /package/dist/{chunk-CkXuGtQK.mjs → chunk-B9cbKln6.mjs} +0 -0
  179. /package/dist/{microphone-DaMZFRuR.mjs → microphone-Bqmoz9_K.mjs} +0 -0
@@ -0,0 +1,190 @@
1
+ # Metal/Safari WebGPU Intel
2
+
3
+ Ongoing engineering log for Safari/Metal WebGPU compute shader debugging. Records findings, hypotheses, test results, and external references so we never retry failed approaches.
4
+
5
+ ---
6
+
7
+ ## Confirmed Facts (Hardware/Platform)
8
+
9
+ - **iPad jetsam limit**: ~1.5-2GB for Safari web content processes. Model weights (Qwen3.5-0.8B INT4) are ~404MB, leaving ~1-1.5GB headroom.
10
+ - **Desktop Chrome/Dawn**: All dispatch patterns work correctly (single pass, multi-pass, multi-submit). F32 and INT4 inference at 70-125 tok/s.
11
+ - **Safari WebGPU**: Available in Safari 26+ (iOS 26+). Uses Metal backend.
12
+
13
+ ## The Core Problem
14
+
15
+ Full forward pass (~400 dispatches per token) produces **all-zero logits starting at dispatch entry 2+** on Safari/Metal, while working perfectly on Chrome/Dawn.
16
+
17
+ ### Forward pass structure
18
+ ```
19
+ Entry 0: EmbeddingInt4 — reads input_ids + quantized weights → writes embed_out ✅
20
+ Entry 1: RMSNorm — reads embed_out + norm weights → writes layer0_norm1_out ✅
21
+ Entry 2: MatVecInt4 — reads layer0_norm1_out + q weights → writes layer0_qkv ❌ ZEROS
22
+ Entry 3+: ALL ZEROS
23
+ ```
24
+
25
+ Individual dispatches work correctly in isolation (separate queue.submit per test dispatch).
26
+
27
+ ---
28
+
29
+ ## Root Cause: Metal On-Chip Cache Coherence
30
+
31
+ ### WWDC25 Session 236 (Apple, "Unlock GPU computing with WebGPU")
32
+ > "Command buffer boundaries require synchronization between high-speed on-chip memory and unified on-device memory."
33
+ > "Unlike command buffers, **passes don't require synchronization with unified memory**."
34
+
35
+ **Translation**: On Metal/Safari:
36
+ - **Compute pass boundaries** (beginComputePass/end within one encoder) do NOT flush on-chip cache to unified memory
37
+ - **Command buffer boundaries** (separate queue.submit calls) DO flush on-chip cache
38
+ - Chrome/Dawn inserts barriers automatically between dispatches; Safari/Metal does NOT
39
+
40
+ This is NOT a bug — it's Metal's design for performance. Chrome/Dawn is more conservative.
41
+
42
+ ### Why Dispatch 0-1 Work But 2+ Don't
43
+ Likely a timing/size coincidence. Entry 0 (Embedding, large) and Entry 1 (RMSNorm, small) happen to have their writes flushed before Entry 2 reads them. Or their outputs overlap in on-chip cache lines that get evicted naturally.
44
+
45
+ ---
46
+
47
+ ## Approaches Tried & Results
48
+
49
+ ### ❌ FAILED: Separate compute passes, one encoder, one submit
50
+ ```
51
+ encoder = createCommandEncoder()
52
+ for each dispatch:
53
+ pass = encoder.beginComputePass()
54
+ pass.setPipeline(...)
55
+ pass.setBindGroup(...)
56
+ pass.dispatchWorkgroups(...)
57
+ pass.end() // ← Does NOT flush on-chip cache (per WWDC25)
58
+ queue.submit([encoder.finish()])
59
+ ```
60
+ **Result**: Same zeros. Pass boundaries don't provide Metal memory coherence.
61
+
62
+ ### ❌ FAILED: Atomic barrier passes between dispatches
63
+ Inserted atomic operations on separate buffers between dispatches to force synchronization.
64
+ **Result**: Same zeros. Atomic operations don't force cross-dispatch cache flushes on Metal.
65
+
66
+ ### ❌ FAILED: Multiple encoders, single queue.submit([~400 CBs])
67
+ ```
68
+ commandBuffers = []
69
+ for each dispatch:
70
+ encoder = createCommandEncoder()
71
+ // ... dispatch ...
72
+ commandBuffers.push(encoder.finish())
73
+ queue.submit(commandBuffers) // single submit with all CBs
74
+ ```
75
+ **Result**: Same zeros. All command buffers are in ONE submit, so Metal may process them without full barriers.
76
+
77
+ ### ❌ FAILED: Multiple encoders, one submit per encoder, DRAIN_EVERY=5
78
+ ```
79
+ for each dispatch:
80
+ encoder = createCommandEncoder()
81
+ // ... dispatch ...
82
+ queue.submit([encoder.finish()])
83
+ if (i % 5 === 0) await queue.onSubmittedWorkDone()
84
+ ```
85
+ **Result**: OOM on iPad (tab crash/reload).
86
+
87
+ ### ❌ FAILED: Per-dispatch submit with await onSubmittedWorkDone (DRAIN_EVERY=1)
88
+ ```
89
+ for each dispatch:
90
+ encoder = createCommandEncoder()
91
+ // ... dispatch ...
92
+ queue.submit([encoder.finish()])
93
+ await queue.onSubmittedWorkDone() // Full GPU drain after EVERY dispatch
94
+ ```
95
+ **Result**: OOM on iPad. The 400 JS↔GPU roundtrips + Promise overhead + command buffer allocations exceed memory.
96
+
97
+ ### ❌ DISPROVED: Shader variant alternation (argument buffer caching hypothesis)
98
+ Hypothesis: Metal caches argument buffers per compiled shader function. Alternating between variant 0/1 of each shader would force fresh argument buffers.
99
+ **Test Q**: Dispatched same shader with different bind groups and different output buffers in one compute pass. Both outputs were correct.
100
+ **Result**: Hypothesis wrong. Argument buffer caching is NOT the issue.
101
+
102
+ ### ❌ FAILED: Converting var<uniform> to var<storage, read>
103
+ Changed all uniform bindings to storage bindings to rule out uniform buffer caching.
104
+ **Result**: Same zeros.
105
+
106
+ ### ❌ FAILED: Using writeBuffer instead of mappedAtCreation
107
+ Changed all buffer initialization to writeBuffer.
108
+ **Result**: Same zeros.
109
+
110
+ ### ✅ WORKS (but OOMs): Per-dispatch submit without batching
111
+ Separate queue.submit() per dispatch does produce correct results (confirmed by probe tests), but causes OOM on iPad due to memory overhead.
112
+
113
+ ---
114
+
115
+ ## Untried Approaches
116
+
117
+ ### Per-dispatch submit WITHOUT await (fire-and-forget)
118
+ ```
119
+ for each dispatch:
120
+ encoder = createCommandEncoder()
121
+ // ... dispatch ...
122
+ queue.submit([encoder.finish()])
123
+ // NO await — let GPU pipeline command buffers
124
+ // Only await final readback
125
+ ```
126
+ **Rationale**: WebGPU spec guarantees queue.submit() ordering. Each submit provides a command buffer boundary (Metal cache flush). Without await, GPU can pipeline command buffers while JS continues. Avoids JS↔GPU roundtrip overhead. WebLLM uses this pattern.
127
+
128
+ ### Grouped submits by dependency level
129
+ Instead of 400 individual submits, group independent dispatches (e.g., all dispatches in one transformer layer) into one encoder, submit after each group.
130
+ **Rationale**: Reduces total submit count from ~400 to ~24-48 while maintaining barriers at dependency boundaries.
131
+
132
+ ---
133
+
134
+ ## Non-Metal Bugs Found
135
+
136
+ ### Double download (React 18 Strict Mode) — FIXED 2026-03-15
137
+ `useNativeEngine` hook used `isLoading` (React state) as the double-call guard in `load()`. React state updates are batched/async, so in strict mode (dev server), the `useEffect` fires twice and BOTH calls pass the guard before `setIsLoading(true)` is processed. Two concurrent 1.75GB downloads run simultaneously, fighting over the progress bar (progress goes backwards) and doubling memory usage (crashes Chrome).
138
+
139
+ **Fix**: Added `loadingRef` (a `useRef`) as a synchronous guard. Refs update immediately, blocking the second call.
140
+
141
+ ---
142
+
143
+ ## External References
144
+
145
+ - **WWDC25 Session 236**: "Unlock GPU computing with WebGPU" — Key source for Metal/WebGPU coherence model
146
+ - **WebLLM Issue #386**: "Models output is scrambled in Safari Technology Preview" — Same root cause, fixed with batched submits per operation. https://github.com/mlc-ai/web-llm/issues/386
147
+ - **wgpu Issue #9221**: Metal lacks full `coherent(device)` until Metal 3.2 — WebKit may under-sync storage buffers
148
+ - **gpuweb Issue #1842**: Discussion on writable storage buffer binding aliasing
149
+ - **gpuweb Discussion #4755**: WebGPU limitations discussion including Metal coherence
150
+
151
+ ## Diagnostic Tests (All Run on iPad Safari)
152
+
153
+ | Test | Description | Result |
154
+ |------|-------------|--------|
155
+ | Test J | Multi-dispatch, DIFFERENT pipelines, one compute pass | PASS (3,6,9,12) |
156
+ | Test P | 300 dispatches, SAME pipeline, one compute pass | PASS |
157
+ | Test N | Real RMSNorm kernel | PASS |
158
+ | Test O | Real MatVec kernel | PASS |
159
+ | Test Q | Same shader, different bind groups, different outputs | PASS |
160
+ | Probe[0] | Entry 0 (Embedding) output | Correct |
161
+ | Probe[1] | Entry 1 (RMSNorm) output | Correct |
162
+ | Probe[2+] | Entry 2+ outputs | ALL ZEROS |
163
+
164
+ ---
165
+
166
+ ## Key Nuances
167
+
168
+ 1. **Test J passes but forward fails**: Test J uses ~4 dispatches with tiny data. The forward pass uses ~400 dispatches with large buffers. On-chip cache behavior depends on buffer sizes and access patterns.
169
+
170
+ 2. **Same-shader dispatches work (Test P)**: 300 dispatches of the SAME shader in one pass work because Metal can optimize memory access patterns for repeated use of the same compiled function. Different shaders break this optimization.
171
+
172
+ 3. **selectivity matters**: Only dispatches with DIFFERENT pipelines that share storage buffers exhibit the zero-output bug. Independent dispatches (non-overlapping buffers) or same-pipeline dispatches work fine.
173
+
174
+ 4. **`select()` and `exp()` bugs**: Separate from the coherence issue. Safari/Metal has correctness bugs with WGSL `select()` (workaround: use if/else) and `exp(-1e30)` returns NaN instead of 0 (workaround: clamp to max(x, -80.0)). Both already fixed in our attention kernel.
175
+
176
+ ---
177
+
178
+ ## 2026-06-12: Multi-agent investigation — diagnosis + fixes (see docs/mobile-failure-diagnosis.md)
179
+
180
+ Full 18-agent investigation report: `docs/mobile-failure-diagnosis.md`. Headlines:
181
+
182
+ **The "one mobile bug" is FOUR bugs:**
183
+ 1. **jetsam-crash = local memory bug, NOT Metal.** Activation buffers were pre-allocated per-tensor at full maxSeqLen (~2.3GB at T=512, incl. a 485MB [T, vocab] logits buffer). Both DRAIN_EVERY=1 and =5 "OOMs" were buffer footprint, not roundtrip overhead — the log's "400 roundtrips + Promise overhead" explanation was wrong (a drained loop bounds in-flight CBs to ~1). FIXED: liveness-pooled activations (431 tensors → 20 buffers, 37MB at T=256) + SliceLastRow makes logits [1, vocab]. INT4@512 now ≈ 0.6-0.7GB total.
184
+ 2. **zero-logits = genuine threshold-dependent WebKit within-submission visibility bug.** NOT stale params (refuted: zeroed params produce *no* writes via the col<N guard, yet probes show zeros *overwriting* pre-seeded data), NOT silent alloc failure (refuted: failure is submit-strategy-dependent, allocation is not). Every passing diagnostic was tiny (≤2 pipelines, 16-byte buffers); production is ~441 dispatches/15+ pipelines/MB buffers. The "Metal has no cross-dispatch coherence by design" theory is contradicted by the WebGPU spec, by Tests J/P, and by llama.cpp running ~64 passes/CB on iOS 26.4 (WebKit bug 311598). Reduce + file upstream.
185
+ 3. **The recorded gibberish was zero-logits in disguise** (~276 tok/s = zero-work dispatches; paper.md:752 root-caused it). backlog.md's "unknown WGSL→MSL compiler bug" is stale. A REAL data race existed in all three attention variants' Q·K reduction (leader 0 reads smem[1..15] while leaders 1..15 write them, no barrier) — FIXED with two-phase reduction.
186
+ 4. **Desktop 161→19.7 tok/s regression**: isMetalBackend matched Dawn-on-Metal (vendor "apple"). FIXED: detection is now UA-based isWebKitWebGPU. Every desktop perf number recorded between 2f0cabc and this fix is poisoned.
187
+
188
+ **Also fixed:** variant alternation deleted (disproven by own Test Q yet still active, ~850-900 pipelines); fire-and-forget per-dispatch submit (unbounded in-flight CBs, the documented WebKit anti-pattern) replaced by grouped submits with ONE CB in flight, sweepable via `?group=N` (default 1 = proven-correct floor); error scopes + onuncapturederror added (was 100% silent); diagnostic page default model was BF16 (~6GB F32 graph!) → now mlx-community 4bit; iOS maxSeqLen default 512 (was 4096 = 18.9GB request); Cache API writes >64MB skipped on WebKit (slice(0) doubled the shard).
189
+
190
+ **Next on-device runs (in order):** B1 memory isolation (q4 + maxseq=64/512), B2 `?group=N` sweep {1,8,32,64,128}, B3 Test R bisect (pipelines/bindings/buffer-size axes) → minimal WebKit repro for upstream filing. Record results to .tmp-ipad-results.jsonl WITH location.href, iOS version, device.limits.
@@ -0,0 +1,124 @@
1
+ # Gerbil WebKit/Metal Inference Failure — Final Diagnosis Report
2
+
3
+ Date: 2026-06-12. Scope: Qwen3.5-0.8B INT4 on WebKit WebGPU (iPad/iPhone Safari 26+); collateral desktop regression on Dawn-on-Metal. HEAD = 38bc674 + uncommitted working tree. **Critical caveat applying to everything below: `scripts/engine/.tmp-ipad-results.jsonl` is 0 bytes — the current fire-and-forget strategy has never produced a recorded on-device result, and no past iPad run recorded its model URL, device limits, or params readback.**
4
+
5
+ ---
6
+
7
+ ## 1. Diagnosis
8
+
9
+ The four failure modes have four **different** root causes. Do not conflate them.
10
+
11
+ ### 1.1 jetsam-crash — ROOT CAUSE: memory budget violated at every tested configuration (H1, SUPPORTED, high confidence)
12
+
13
+ This is not a Metal bug. The footprint math, recomputed directly from `generateGraph()` with the real cached configs, exceeds the ~1.5–2GB iOS web-content budget (docs/metal-safari-intel.md:9) in every configuration ever run:
14
+
15
+ | Config | GPU footprint |
16
+ |---|---|
17
+ | INT4 @ maxSeqLen=512 (the diagnostic setting, examples/ipad-diagnostic.html:299) | **2.77GB** (2.30GB activations across ~430 buffers + 0.44GB weights) |
18
+ | — of which the logits buffer alone | **485MB** (`["T", vocab_size]` f32, src/gpu/architectures/qwen3_5.ts:932-937; only the last row ever read — src/gpu/executor.ts:386-393) |
19
+ | INT4 @ 256 | 1.61GB |
20
+ | Engine/React-hook default maxSeqLen=4096 (src/gpu/index.ts:168-172, src/browser/use-native-engine.ts:84) | 18.91GB |
21
+ | Diagnostic page **default URL** (no `?model=` → BF16 `Qwen/Qwen3.5-0.8B`, no dtype q4 — ipad-diagnostic.html:291, model-loader.ts:519-523) | **6.08GB** — any default-URL iPad run was memory-killed regardless of GPU correctness, while the page label said "INT4" |
22
+
23
+ Allocation is one buffer per activation tensor at full maxSeqLen with zero reuse (src/gpu/executor.ts:838-850; `"T"`/`"L_max"` → maxSeqLen at :841-845). On top: JS load transients — Cache-API `slice(0)` copy per range (model-loader.ts:127-128) and MLX zero-copy views pinning the whole download ArrayBuffer (mlx-adapter.ts:50).
24
+
25
+ **This fully explains why both DRAIN_EVERY=5 and DRAIN_EVERY=1 "OOM'd" identically** (intel.md:85, :87-95): drain frequency was never the dominant cost; the buffer footprint was. The log's "400 roundtrips + Promise overhead" explanation (intel.md:95) is wrong — a fully drained loop bounds in-flight CBs to ~1.
26
+
27
+ Secondary contributor (H2/H7): the current fire-and-forget path adds ~401 unbounded in-flight Metal command buffers per token (executor.ts:373-381, 489-498) — the exact anti-pattern Apple's Mike Wyrzykowski flags as "GPU resource exhaustion" in WebKit bug 311598 — and the still-active variant-alternation machinery compiles ~850-900 per-node shader modules/pipelines vs ~25 on Dawn (executor.ts:258-261, :1026-1073; device.ts:286-291).
28
+
29
+ ### 1.2 zero-logits — ROOT CAUSE: genuine, threshold-dependent WebKit within-submission visibility bug (H9, SUPPORTED, medium confidence) — NOT stale params, NOT silent allocation failure
30
+
31
+ The decisive evidence triangulation:
32
+
33
+ - **Strategy-dependence kills all local-bug theories.** CPU-side state (input_ids write, all ~400 params writes) is byte-identical between batched and per-dispatch paths — the writes all happen before the `needsMultiEncoder` branch (executor.ts:328-365 then :367). Only submit granularity differs, and that alone flips correct↔zeros (intel.md:49-75 vs :110-111). A stale-params bug (H3) or invalid-resource bug (H4) would corrupt both modes equally.
34
+ - **The zeros are computed, not residual.** The diagnostic ladder runs isolated entry 2 (which works, intel.md:25) immediately before the batched forward, pre-seeding `layer0_qkv` with correct nonzero data; `reset()` doesn't clear activations (executor.ts:539-553). Probe[2] then reads all zeros (intel.md:21, :160-162) — entry 2 **executed and overwrote correct data with zeros computed from a stale view of its input**. The MatVecInt4 write-guard (`col < params.N`, registry.ts:515-521) means zeroed params would have written nothing and left the seed visible. This is a read-side visibility failure, not a params-write failure.
35
+ - **The pass-table cannot refute a scale threshold.** Every passing single-submission diagnostic is tiny: Test J = 16-byte buffers, 2 pipelines, 3 dispatches (device.ts:980-1009); Test P = 300 dispatches but ONE pipeline, 16 bytes (device.ts:1445-1457); Test Q = 2 dispatches, one pipeline (device.ts:1495-1517). Production = ~441 dispatches, ~15+ distinct pipelines, 6-9 bindings, MB-scale buffers, workgroup_size(256). No diagnostic ever combined >2 pipelines, >2 bindings, or >64KB buffers in one submission. The intel doc's Key Nuances (:168 vs :172) are mutually inconsistent without a scale variable — Test J *is* different-pipelines-sharing-a-buffer and passes.
36
+ - **The "Metal-by-design no-coherence" theory (intel.md:31-46) is wrong.** The WebGPU spec guarantees cross-dispatch storage visibility within a submission (gpuweb #4433/#4434); Tests J and P pass (dependent chains in one submit); and llama.cpp's WebGPU backend computes correctly at ~64 passes/CB on iOS 26.4 (WebKit bug 311598). The WWDC25 quote at executor.ts:368-372 inverts the session's intent: it explains why CB boundaries are *expensive*, not why they're needed for coherence.
37
+
38
+ This is a filable WebKit bug once the threshold (pipeline count / binding count / resident-set size) is bisected — no public report covers zeros-within-one-submit. The bisect result also directly sets the grouped-submit CB size (H2 fix).
39
+
40
+ ### 1.3 gibberish — TWO components
41
+
42
+ **(a) The recorded 2026-03-11 gibberish was zero-logits in disguise (H6 REFUTED for the recorded event).** paper.md:752 root-causes it: writeBuffer during an active pass → stale zero params → all threads early-exit → degenerate output at ~276 tok/s. That throughput — 2-4x faster than desktop M4 Max — is the signature of zero-work dispatches, not miscompiled math. backlog.md:23's "unknown Safari WGSL→MSL compiler bug" is a stale snapshot predating diagnostics H-Q and d574cdb's two-phase fix. The packed-f16/loop-bound miscompile theory is additionally contradicted by Tests N/O (real RMSNorm/MatVec kernels with storage-params loop bounds) passing on iPad (intel.md:157-158), and the packed-f16 kernels only ever saw already-zero inputs in failing runs (zeros start at entry 2, before any KV/attention dispatch).
43
+
44
+ **(b) A real, confirmed WGSL data race remains as the live candidate for any FUTURE gibberish once coherence is fixed (H5 SUPPORTED, medium confidence).** All three attention variants contain identical spec-level UB in the Q·K score reduction: after the publish barrier, leader threads read `smem[tid..tid+15]` and write `smem[pos_in_tile]` (0..15) with no barrier between cross-simdgroup reads and writes — leader 0's read range is exactly the write target of leaders 1..15 (registry.ts:942-954 verified above; identically at :1884-1895 and :2144-2155). Every kvMode ships it (selection at executor.ts:185). No diagnostic exercises the production attention kernel at occupancy (the kernel sits at exactly 16384 bytes smem, the spec minimum, registry.ts:860-863). It stays benign on Tint/M-series; WebKit's independent WGSL→MSL compiler + A-series scheduling may not be so forgiving. It corrupts only the first key position of each 16-position tile → plausible-but-wrong tokens. Cheap to fix (one extra barrier), so fix it unconditionally.
45
+
46
+ ### 1.4 desktop-regression — ROOT CAUSE: predicate conflates Apple GPU with WebKit implementation (H8, SUPPORTED, high confidence, verified live)
47
+
48
+ `isMetalBackend = vendor === "apple" || vendor.includes("apple") || arch.startsWith("common")` (src/gpu/device.ts:136) matches dawn-node on the M4 Max (live probe: vendor "apple", arch "metal-3", device "apple-m4-max"). The UA fallback (device.ts:143-152) never runs in node because adapter.info exists. Consequence, verified by execution: node-dawn takes the full workaround path (`needsMultiEncoder = ctx.isMetalBackend`, executor.ts:133) including variant alternation ("reassigned 441/441 prefill pipelines" printed on desktop), producing the recorded 161.8 → 19.7 tok/s collapse at HEAD (results.jsonl, gitHash 24d444a vs final baseline entry) and a residual 169 → 125 tok/s regression in the working tree. Every desktop *performance* number since 2f0cabc is corrupted; post-commit "Chrome on Mac works" observations never tested the single-encoder path Safari fails on (correctness contrasts survive — the workaround path passes correctness on Dawn).
49
+
50
+ ---
51
+
52
+ ## 2. Conclusively ruled out — do not retry
53
+
54
+ 1. **H3 — stale/dropped params writes as the cause of zero-logits (REFUTED).** The staging-buffer "mitigation" in the class comment (executor.ts:95-103) is dead code — allocated (:307-312), destroyed (:820-823), never copied from, in any commit including d574cdb. The `lastParamsBytes` cache (:338-362) would make a drop sticky. All true — but the mechanism is refuted: zeroed params produce *no writes* (write-guard registry.ts:515-521), yet probe[2] shows zeros overwriting pre-seeded nonzero data; the "early writes dropped" hazard predicts entry 0 failing first, the observed pattern is the inverse; and the identical 400-write burst precedes the known-correct per-dispatch runs. Stop chasing params delivery. (Keep the dead-code cleanup and the on-device params readback as hygiene — §5.)
55
+ 2. **H4 — silent allocation/validation failure as the cause of zero-logits (REFUTED).** Invalid buffer → invalid bind group → skipped dispatch is submit-strategy-invariant; the observed failure is strategy-dependent with bind groups created once (executor.ts:166-215). Correct per-dispatch logits at maxSeqLen=512 prove the 485MB logits buffer was created and bound successfully on the test iPad, and the default-limits fallback (device.ts:105-115) did not fire there. The missing error scopes remain a real observability gap (zero `pushErrorScope` anywhere in src/gpu) — fix it, but it isn't the cause.
56
+ 3. **H6 — packed-f16 / dynamic-loop-bound miscompilation as the cause of the recorded gibberish (REFUTED).** See §1.3(a). Residual risk only for future multi-token decode at S>1 — closed by one cheap A/B (`?kvf32=1`), §5.
57
+ 4. **Shader variant alternation (2f0cabc).** Already disproven by the project's own Test Q (intel.md:97-100) and 38bc674's commit message — yet still active (executor.ts:258-261). Delete it; never reintroduce.
58
+ 5. **Atomic-barrier passes, uniform→storage conversion, writeBuffer-vs-mappedAtCreation, separate-passes/separate-encoders-one-submit** — all recorded failed (intel.md:49-108).
59
+ 6. **Drained per-dispatch loops (DRAIN_EVERY=1/5) as tested** — they failed for memory reasons (H1), not because draining is wrong; don't re-run them at maxSeqLen=512 expecting different results.
60
+ 7. **"Metal provides no cross-dispatch coherence by design, not a bug" (intel.md:31-46)** — contradicted by spec, by Tests J/P, and by llama.cpp on iOS 26.4. Stop designing around it as expected behavior; reduce and file it.
61
+
62
+ ---
63
+
64
+ ## 3. Fix plan
65
+
66
+ ### A. High-confidence fixes to make NOW (no iPad needed)
67
+
68
+ **A1. Fix the detection predicate (H8).** src/gpu/device.ts:123-152: drop the adapter.info vendor/arch test for `isMetalBackend`; compute it solely from WebKit-implementation detection (the existing fallback at :144-150 is nearly correct — promote it: `AppleWebKit && !Chrome/` or iOS UA; node-dawn with no userAgent → false). Rename to `isWebKitWebGPU`. Expected: node-dawn back to ~160-170 tok/s; verify with `node scripts/engine/benchmark.mjs` — no "[executor] Safari/Metal detected" line. **Do this first: it un-poisons every subsequent desktop measurement.**
69
+
70
+ **A2. Delete variant alternation (H7).** Remove executor.ts:258-261 and `assignMetalVariants` (:1026-1073); drop the per-node `uniqueKey` from the fused-SwiGLU pipeline call (executor.ts:941). Collapses ~850-900 Metal pipeline states to ~25, shortens 'engine:compiling-shaders', removes the decode/prefill shared-entry overwrite incoherence (executor.ts:245 aliasing).
71
+
72
+ **A3. Fix the attention race (H5).** Two-phase the leader reduction at registry.ts:947-953, :1888-1894, :2148-2154: hoist the sum into a local, `workgroupBarrier()` in *uniform* control flow, then a second guarded block writes `smem[pos_in_tile]`. (The barrier cannot go inside the existing `if` — non-uniform barrier is a WGSL validation error.) One extra barrier per KV tile; benchmark on desktop to confirm negligible cost.
73
+
74
+ **A4. Shrink logits to [1, vocab] (H1).** qwen3_5.ts:932-937 → shape `[1, vocab_size]`; slice the last row of `final_norm_out` (small copy or kernel) and run lm_head with M=1; readback offset → 0 in executor.ts:382-393 and :406-417. Saves 485MB at T=512 **and** removes the dominant prefill compute (full-vocab matmul over all T rows). Pure win on desktop too.
75
+
76
+ **A5. Activation buffer aliasing (H1; backlog.md:47-51 already calls for it).** In allocateActivationBuffers (executor.ts:838-850): last-use liveness over `graph.executionOrder`, pool by size class. ~430 buffers → ~a dozen live (~50-150MB at T=512). Combined with A4: INT4@512 total ≈ 0.44GB weights + ~0.15GB activations + ~0.03GB ssm/kv ≈ **0.6-0.7GB — inside the iPad budget**.
77
+
78
+ **A6. Fix the diagnostic default-model trap.** ipad-diagnostic.html:291: fallback repo → `mlx-community/Qwen3.5-0.8B-4bit`, or refuse non-quantized configs on iOS. The current default silently builds a 6.08GB F32 graph under an "INT4" label.
79
+
80
+ **A7. Add error scopes + limit logging.** Wrap allocateActivationBuffers / uploadWeights / initBindGroups in `pushErrorScope('validation')`+`('out-of-memory')`; register `device.onuncapturederror`; emit `device.limits.maxBufferSize`/`maxStorageBufferBindingSize` through the diagnostic stream. Currently every iPad observation is collected blind.
81
+
82
+ **A8. Make forwardArgmax consistent.** Split the argmax dispatch and the 4-byte readback copy (executor.ts:500-508) into two submits, mirroring forward()'s :382-395 — or keep one CB *deliberately* as a coherence-theory probe. Pick one intentionally; today the code contradicts itself.
83
+
84
+ **A9. Trim load transients.** model-loader.ts:127-128: skip the `slice(0)` cache copy for weight shards on iOS; mlx-adapter.ts:50: copy packed arrays out of the download buffer per-tensor so the multi-hundred-MB ArrayBuffer can be GC'd before upload.
85
+
86
+ ### B. Experiments requiring iPad validation (in order; all stream results into `.tmp-ipad-results.jsonl` including `location.href`, limits, and crash-phase — none of this exists today)
87
+
88
+ **B1. Memory isolation (H1 confirmation).** With A1-A9 in place isn't even required: run `?model=mlx-community/Qwen3.5-0.8B-4bit&maxseq=64` (footprint ≈0.8GB pre-fix) → expect survival; `&maxseq=512` → expect kill mid-run; default URL → expect kill during upload. Three runs, no code changes, finally separates memory from correctness.
89
+
90
+ **B2. Grouped-submit sweep (H2/H9 — the decisive correctness experiment).** Replace the per-dispatch loops (executor.ts:367-395, :487-509) with a parameterized grouped loop: N dispatches per CB, one compute pass per dispatch, `await onSubmittedWorkDone()` per group (exactly one CB in flight — Levine's stable iOS 26.4 recipe, WebKit bug 311598). Sweep N ∈ {1, 8, 32, 64, 128, 401} at q4 + maxSeqLen≤256, variants deleted, ~30 tokens each. Readouts: groupSize=1 is the correctness floor (known-correct per intel.md:110-111); if 32-64 is correct and stable → ship it and both zero-logits and crash modes close; if any N>1 zeros at the group's first MatVecInt4 → the WebKit bug bites below CB granularity on this OS version and N=1-with-await ships as the floor. Note the tension honestly: Gerbil's own log shows >1-dispatch-per-CB zeroing (intel.md:49-75), while llama.cpp's 64-passes/CB works — the sweep adjudicates.
91
+
92
+ **B3. Test R bisect (H9 → filable WebKit bug).** Add to runGPUDiagnostics (insert before the return at device.ts:~1545): single-submission dependent chains sweeping, one axis at a time from the Test-J baseline: buffer size 4KB→64MB, distinct pipelines 2→32, chain length 3→441, bindings 2→6, workgroup_size(256)+`var<workgroup>`. Each config run batched AND per-dispatch (ground truth). No model load, <700MB. First failing tuple = the minimal repro for bugs.webkit.org and the sizing constant for B2. If nothing fails up to production shape, the residual is local — revisit with the H3 discriminating probe.
93
+
94
+ **B4. H3/coherence discriminator (cheap, piggyback on B2).** Force the batched path once, pre-write sentinel 7777.0 into `layer0_qkv` via debugWriteBuffer (executor.ts:773-777), forward, then debugPipelineProbe(1) (executor.ts:686-771) recording `uniformParams` for entries 2-5. params correct + qkv=0.0 → coherence/visibility confirmed on-device (expected); params zeroed + qkv still 7777.0 → reopen H3.
95
+
96
+ **B5. Gibberish closure (H5/H6 residual).** After B2 lands a correct config: (i) 200x loop of the production ATTENTION_PACKED_F16 kernel at S=64 vs JS reference — nondeterminism confined to key positions 0/16/32/48 confirms the race manifests on WebKit; (ii) A/B the same >16-token generation pre/post the A3 barrier fix, and packed-f16 vs `?kvf32=1` (index.ts:123-124), diffing token IDs against node-dawn references. Also file the select()/exp(-1e30) miscompiles upstream (intel.md:174 — unreported; WebKit fixes this class in weeks).
97
+
98
+ ---
99
+
100
+ ## 4. Memory/crash mitigation (jetsam survival checklist)
101
+
102
+ 1. **Logits [1, vocab]** (A4): −485MB at T=512.
103
+ 2. **Activation aliasing** (A5): −~2.15GB at T=512.
104
+ 3. **Honor a hard iOS cap**: backend-selector's 512-token cap (src/browser/backend-selector.ts:43-53) is right; also clamp the engine/React defaults (index.ts:168-172, use-native-engine.ts:84) — 4096 must never reach an iOS device (18.9GB request).
105
+ 4. **Never load BF16 on iOS** (A6): refuse non-quantized configs or force q4.
106
+ 5. **Bound in-flight CBs**: grouped submits with one CB in flight (B2). Fire-and-forget's ~401 unbounded CBs/token is the documented WebKit resource-exhaustion pattern even after buffer fixes.
107
+ 6. **Keep the uncommitted JS-weight-free-after-upload** (executor.ts:147-157) and add the transient trims (A9).
108
+ 7. **Post-fix target**: ~0.6-0.7GB total GPU at T=512 — comfortable headroom under the ~1.5-2GB budget for the first time in the project's history.
109
+
110
+ ---
111
+
112
+ ## 5. Open questions (on-device only, minimal test each)
113
+
114
+ 1. **Does grouped submission (N=32-64/CB, one in flight) produce correct logits on this WebKit version?** — B2 sweep. This is the single highest-value unknown; it decides whether the WebKit bug is per-CB or sub-CB granularity.
115
+ 2. **What is the zeros threshold (pipelines/bindings/resident set)?** — B3 Test R. Output doubles as the WebKit bug filing. Note: the dependency-carrying buffer at the failing edge (norm1_out, decode T=1) is KB-scale, so sweep *shape*, not just size.
116
+ 3. **Do params arrive intact in batched mode on-device?** — B4 sentinel probe (closes the last sliver of H3; never recorded).
117
+ 4. **Does the attention race manifest on A-series WebKit?** — B5(i) 200x determinism loop.
118
+ 5. **Does packed-f16 KV survive realistic S on-device?** — B5(ii) `?kvf32=1` A/B (never recorded; grep finds kvf32 only at index.ts:123 and paper.md:507).
119
+ 6. **Which iOS version is the test iPad on?** — bug 311598 reports 26.4 markedly more stable than 26.3.1; log it in every result row. Also log `device.limits` (256MB-993MB maxBufferSize range across devices determines whether the 0.44GB weights blob needs sharding on smaller phones).
120
+ 7. **Is any crash actually JSC/WASM-side, not GPU?** — onnxruntime #26827 phenotype (WebKit 26 OMG/B3 recompilation loop, 400% CPU, runaway RAM). One run with WebGPU idle after tokenizer init, watching CPU/memory, rules it in or out.
121
+
122
+ ### Bottom line
123
+
124
+ Per failure mode: **jetsam-crash** = local memory bug (H1), fully fixable in src/gpu without touching dispatch strategy; **zero-logits** = genuine WebKit within-submission visibility bug at scale (H9), workaround granularity to be set by the B2/B3 sweeps (floor: 1 dispatch/CB with await, proven correct; target: 32-64/CB per llama.cpp's existence proof), and worth filing upstream; **gibberish** = historically a zeros artifact (refuted H6), prospectively the confirmed attention race (H5) — fix unconditionally; **desktop-regression** = local predicate bug (H8), one-line-class fix. The committed HEAD is known-broken on the target device, the working tree implements the documented WebKit anti-pattern, and nothing currently in the repo has a recorded iPad result — land A1-A9, then run B1/B2 before writing another line of workaround code.
package/docs/mobile.md ADDED
@@ -0,0 +1,99 @@
1
+ # Mobile (iOS / Android / Safari)
2
+
3
+ Gerbil runs entirely on-device via WebGPU, so it works on modern phones and
4
+ tablets — but mobile browsers impose two hard ceilings that desktop doesn't.
5
+ Understanding them (and one setup step for your users) is the difference between
6
+ a great mobile experience and a frustrating one.
7
+
8
+ ## The two ceilings
9
+
10
+ On-device models are large — the default Qwen3.5-0.8B 4-bit is ~404 MB; vision and
11
+ larger models are GBs. Mobile browsers, **iOS Safari especially**, wall a web
12
+ origin off from the device with two independent limits:
13
+
14
+ | Ceiling | What it limits | iOS Safari (uninstalled) | The fix |
15
+ |---|---|---|---|
16
+ | **Storage quota** | Disk for the model cache | ~1 GB, **evictable**, regardless of free disk | Install to Home Screen → persistent storage |
17
+ | **Tab memory** | RAM during load + inference | ~hundreds of MB | Smaller model / fewer dispatches |
18
+
19
+ A device with 40 GB free disk still only grants an *uninstalled* Safari tab ~1 GB,
20
+ and that 1 GB can be evicted between visits — so without the step below, a large
21
+ model **re-downloads every visit**.
22
+
23
+ ## Recommend "Add to Home Screen" (PWA) for the best mobile UX
24
+
25
+ The storage ceiling lifts when your site is **installed to the Home Screen** as a
26
+ PWA. Installed, iOS grants **persistent storage** tied to real disk (tens of GB,
27
+ never evicted), so models download **once** and stay cached forever. This is the
28
+ single biggest mobile UX win you can offer your users.
29
+
30
+ iOS Safari has **no programmatic install prompt** — installation is manual (Share →
31
+ Add to Home Screen) — so the right pattern is to *detect* the situation and nudge
32
+ the user before a large download.
33
+
34
+ ## The storage helpers
35
+
36
+ `@tryhamster/gerbil/browser` exposes helpers so you can surface this to users:
37
+
38
+ ```ts
39
+ import {
40
+ getStorageStatus,
41
+ canCacheModel,
42
+ requestPersistentStorage,
43
+ getInstallGuidance,
44
+ isStandalone,
45
+ } from "@tryhamster/gerbil/browser";
46
+
47
+ // Before downloading a large model on mobile, check whether it will cache.
48
+ const fit = await canCacheModel(404); // model size in MB
49
+ if (fit.recommendInstall) {
50
+ // Not installed (or won't fit the current quota) — nudge the user.
51
+ const { manual, steps } = getInstallGuidance();
52
+ // manual === true on iOS (show the Share → Add to Home Screen steps);
53
+ // on Android/Chrome you can capture `beforeinstallprompt` for a one-tap button.
54
+ showBanner(steps);
55
+ }
56
+
57
+ // Ask for eviction-exempt storage (best-effort; on iOS effectively granted only
58
+ // once installed to the Home Screen).
59
+ await requestPersistentStorage();
60
+
61
+ // Full snapshot for diagnostics / UI.
62
+ const s = await getStorageStatus();
63
+ // { quotaMB, usageMB, availableMB, persisted, installed, ios }
64
+ ```
65
+
66
+ | Helper | Returns |
67
+ |---|---|
68
+ | `getStorageStatus()` | `{ quotaMB, usageMB, availableMB, persisted, installed, ios }` |
69
+ | `canCacheModel(sizeMB)` | `{ fits, availableMB, recommendInstall }` |
70
+ | `requestPersistentStorage()` | `boolean` — is storage persistent now |
71
+ | `getInstallGuidance()` | `{ installed, manual, steps }` — platform-aware install steps |
72
+ | `isStandalone()` / `isIOS()` | `boolean` |
73
+
74
+ ## What Gerbil handles automatically
75
+
76
+ - **Per-device speed tuning.** On WebKit, each GPU dispatch is submitted and
77
+ drained separately, so decode is bound by CPU↔GPU round-trips. Gerbil batches
78
+ dispatches per command buffer and **auto-calibrates the safe batch size per
79
+ device** (a crash-surviving probe persisted in `localStorage`): tablets/desktop
80
+ Safari jump to a fast group automatically, phones stay at the safe floor. On a
81
+ modern iPad this is ~3–4× faster than the unbatched floor. You can override with
82
+ the `?group=N` URL parameter.
83
+ - **Durable, self-healing model cache.** Weights are cached per-tensor (OPFS where
84
+ available, else CacheStorage), and superseded cache namespaces are auto-evicted
85
+ on load so orphaned data can't fill the quota and block new caching.
86
+ - **`dtype: "auto"`** picks INT4 on mobile to keep both download size and memory
87
+ in budget.
88
+
89
+ ## Practical recommendations
90
+
91
+ 1. Default to a small 4-bit model on mobile (the ~404 MB Qwen3.5-0.8B is a good
92
+ baseline; avoid GB-scale models unless the user has installed the PWA).
93
+ 2. Make your site **installable** (web app manifest + service worker for the app
94
+ shell — *not* the model; Gerbil caches the model itself) and nudge mobile users
95
+ to install before a large download using the helpers above.
96
+ 3. Call `requestPersistentStorage()` early.
97
+ 4. Expect first load to download once; subsequent loads read from cache (instant)
98
+ — provided the model fits the quota (≈ install on iOS for anything approaching
99
+ 1 GB).
@@ -0,0 +1,230 @@
1
+ # Production Observability
2
+
3
+ Gerbil includes built-in support for production observability through telemetry hooks and request queuing.
4
+
5
+ ## Telemetry Hooks
6
+
7
+ Configure telemetry hooks to integrate with Sentry, DataDog, or any monitoring system:
8
+
9
+ ```typescript
10
+ import { Gerbil } from "@tryhamster/gerbil";
11
+ import * as Sentry from "@sentry/node";
12
+
13
+ const g = new Gerbil({
14
+ telemetry: {
15
+ // Called on any error (model load, generation, etc.)
16
+ onError: (error, context) => {
17
+ Sentry.captureException(error, {
18
+ extra: context,
19
+ tags: { operation: context.operation },
20
+ });
21
+ },
22
+
23
+ // Called after successful generation
24
+ onGenerate: (event) => {
25
+ console.log(`Generated ${event.result.tokensGenerated} tokens`);
26
+ // Track in your metrics system
27
+ metrics.histogram("gerbil.tokens_generated", event.result.tokensGenerated);
28
+ metrics.histogram("gerbil.tokens_per_second", event.result.tokensPerSecond);
29
+ },
30
+
31
+ // Called after model loading (success or failure)
32
+ onModelLoad: (event) => {
33
+ if (event.success) {
34
+ console.log(`Loaded ${event.modelId} in ${event.loadTimeMs}ms on ${event.device}`);
35
+ } else {
36
+ console.error(`Failed to load ${event.modelId}: ${event.error}`);
37
+ }
38
+ },
39
+
40
+ // Called when requests wait in queue (>100ms)
41
+ onQueueWait: (waitTimeMs) => {
42
+ metrics.histogram("gerbil.queue_wait_ms", waitTimeMs);
43
+ },
44
+ },
45
+ });
46
+ ```
47
+
48
+ ### Telemetry Events
49
+
50
+ #### `onError(error, context)`
51
+
52
+ Called whenever an error occurs during Gerbil operations.
53
+
54
+ ```typescript
55
+ type ErrorContext = {
56
+ operation: "generate" | "load" | "embed" | "speak" | "transcribe" | "json";
57
+ modelId?: string;
58
+ extra?: Record<string, unknown>;
59
+ };
60
+ ```
61
+
62
+ #### `onGenerate(event)`
63
+
64
+ Called after successful text generation.
65
+
66
+ ```typescript
67
+ type GenerateEvent = {
68
+ modelId: string;
69
+ result: GenerateResult;
70
+ cached: boolean;
71
+ queueTimeMs?: number; // Only if waited >100ms
72
+ };
73
+ ```
74
+
75
+ #### `onModelLoad(event)`
76
+
77
+ Called after model loading completes (success or failure).
78
+
79
+ ```typescript
80
+ type ModelLoadEvent = {
81
+ modelId: string;
82
+ loadTimeMs: number;
83
+ fromCache: boolean;
84
+ device: "webgpu" | "cpu" | "wasm";
85
+ success: boolean;
86
+ error?: string;
87
+ };
88
+ ```
89
+
90
+ #### `onQueueWait(waitTimeMs)`
91
+
92
+ Called when a request waits in the queue for more than 100ms. Useful for detecting congestion.
93
+
94
+ ## Request Queue
95
+
96
+ Gerbil uses a request queue to prevent GPU OOM errors under concurrent load. LLM inference can only run one request at a time on the GPU.
97
+
98
+ ### Default Behavior
99
+
100
+ - **Concurrency**: 1 (single request at a time)
101
+ - **Timeout**: 5 minutes (300,000ms)
102
+ - Requests are processed in FIFO order
103
+ - Timeout errors are thrown if exceeded
104
+
105
+ ### Custom Configuration
106
+
107
+ ```typescript
108
+ const g = new Gerbil({
109
+ concurrency: {
110
+ maxConcurrent: 1, // Max parallel requests (default: 1)
111
+ timeout: 300_000, // Request timeout in ms (default: 5 min)
112
+ },
113
+ });
114
+ ```
115
+
116
+ ### Why Queue?
117
+
118
+ LLM inference on GPU is:
119
+ 1. **Memory-bound**: Models consume most of GPU VRAM
120
+ 2. **Non-concurrent**: Running multiple inferences simultaneously causes OOM
121
+ 3. **Variable duration**: Generation time depends on output length
122
+
123
+ The queue ensures:
124
+ - Predictable memory usage
125
+ - No OOM crashes under load
126
+ - Fair request ordering
127
+
128
+ ## Rate Limiting
129
+
130
+ Gerbil does **not** include rate limiting. This is intentional—rate limiting is best handled at the application layer using middleware specific to your framework:
131
+
132
+ ```typescript
133
+ // Express
134
+ import rateLimit from "express-rate-limit";
135
+ import { gerbil } from "@tryhamster/gerbil/express";
136
+
137
+ app.use("/ai", rateLimit({ windowMs: 60000, max: 10 }));
138
+ app.use("/ai", gerbil());
139
+
140
+ // Next.js
141
+ import { Ratelimit } from "@upstash/ratelimit";
142
+ import { Redis } from "@upstash/redis";
143
+
144
+ const ratelimit = new Ratelimit({
145
+ redis: Redis.fromEnv(),
146
+ limiter: Ratelimit.slidingWindow(10, "60s"),
147
+ });
148
+
149
+ export async function POST(req: Request) {
150
+ const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
151
+ const { success } = await ratelimit.limit(ip);
152
+ if (!success) return Response.json({ error: "Rate limited" }, { status: 429 });
153
+
154
+ // Continue with Gerbil...
155
+ }
156
+ ```
157
+
158
+ ## Example: Full Production Setup
159
+
160
+ ```typescript
161
+ import { Gerbil } from "@tryhamster/gerbil";
162
+ import * as Sentry from "@sentry/node";
163
+
164
+ Sentry.init({ dsn: process.env.SENTRY_DSN });
165
+
166
+ const g = new Gerbil({
167
+ model: "qwen3-0.6b",
168
+
169
+ telemetry: {
170
+ onError: (error, context) => {
171
+ Sentry.captureException(error, { extra: context });
172
+ },
173
+
174
+ onGenerate: ({ result, queueTimeMs }) => {
175
+ // Log slow generations
176
+ if (result.totalTime > 10000) {
177
+ console.warn(`Slow generation: ${result.totalTime}ms`);
178
+ }
179
+
180
+ // Track queue congestion
181
+ if (queueTimeMs && queueTimeMs > 5000) {
182
+ Sentry.captureMessage("High queue wait time", {
183
+ level: "warning",
184
+ extra: { queueTimeMs },
185
+ });
186
+ }
187
+ },
188
+
189
+ onModelLoad: (event) => {
190
+ if (!event.success) {
191
+ Sentry.captureMessage(`Model load failed: ${event.error}`, {
192
+ level: "error",
193
+ extra: event,
194
+ });
195
+ }
196
+ },
197
+ },
198
+
199
+ concurrency: {
200
+ maxConcurrent: 1,
201
+ timeout: 120_000, // 2 minute timeout for your use case
202
+ },
203
+ });
204
+
205
+ // Preload model on startup
206
+ await g.loadModel();
207
+ console.log("Gerbil ready for production");
208
+ ```
209
+
210
+ ## Health Checks
211
+
212
+ For production deployments, implement a health check endpoint:
213
+
214
+ ```typescript
215
+ // Express
216
+ app.get("/health", async (req, res) => {
217
+ try {
218
+ const info = g.getInfo();
219
+ res.json({
220
+ status: "ok",
221
+ model: info.model?.id,
222
+ device: info.device.backend,
223
+ ready: info.device.status === "ready",
224
+ });
225
+ } catch (error) {
226
+ res.status(503).json({ status: "error", message: String(error) });
227
+ }
228
+ });
229
+ ```
230
+