volvoxai 0.1.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 (172) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/volvox.js +72 -0
  4. package/dist/v0.1.0/volvoxai.js +4664 -0
  5. package/dist/v0.1.0/volvoxai.min.js +1848 -0
  6. package/dist/v0.1.0/volvoxai.wasm +0 -0
  7. package/dist/volvoxai.js +4664 -0
  8. package/dist/volvoxai.min.js +1848 -0
  9. package/dist/volvoxai.wasm +0 -0
  10. package/docs/README.md +22 -0
  11. package/docs/browser-runtime.md +87 -0
  12. package/docs/efficientdet_tflite_vs_volvoxai.md +445 -0
  13. package/docs/microkernel_optimization_guide.md +153 -0
  14. package/docs/model-format.md +108 -0
  15. package/docs/models.md +103 -0
  16. package/docs/native-runtime.md +189 -0
  17. package/docs/operation_list.md +232 -0
  18. package/docs/operator_fusion_patterns.md +58 -0
  19. package/docs/quickstart.md +115 -0
  20. package/docs/roadmap.md +19 -0
  21. package/docs/testing.md +97 -0
  22. package/docs/textbook/01-foundations.md +233 -0
  23. package/docs/textbook/02-tinystories-language-model.md +300 -0
  24. package/docs/textbook/03-efficientdet-vision-model.md +281 -0
  25. package/docs/textbook/04-precision-and-quantization.md +208 -0
  26. package/docs/textbook/05-inside-the-engine.md +155 -0
  27. package/docs/textbook/06-native-engine-architecture.md +338 -0
  28. package/docs/textbook/07-glossary-and-next-steps.md +258 -0
  29. package/docs/textbook/README.md +85 -0
  30. package/docs/textbook/ko/01-foundations.md +231 -0
  31. package/docs/textbook/ko/02-tinystories-language-model.md +300 -0
  32. package/docs/textbook/ko/03-efficientdet-vision-model.md +277 -0
  33. package/docs/textbook/ko/04-precision-and-quantization.md +206 -0
  34. package/docs/textbook/ko/05-inside-the-engine.md +154 -0
  35. package/docs/textbook/ko/06-native-engine-architecture.md +333 -0
  36. package/docs/textbook/ko/07-glossary-and-next-steps.md +253 -0
  37. package/docs/textbook/ko/README.md +83 -0
  38. package/docs/xnnpack_optimization_guide.md +197 -0
  39. package/js/CPUEngine.js +241 -0
  40. package/js/Graph.js +49 -0
  41. package/js/GraphExecutor.js +1020 -0
  42. package/js/GraphLoader.js +282 -0
  43. package/js/ShaderLibrary.js +236 -0
  44. package/js/Tensor.js +25 -0
  45. package/js/Tokenizer.js +266 -0
  46. package/js/VolvoxAI.js +130 -0
  47. package/js/WasmEngine.js +378 -0
  48. package/js/WebNNEngine.js +169 -0
  49. package/js/index.js +11 -0
  50. package/js/ops/add.js +31 -0
  51. package/js/ops/argMax.js +33 -0
  52. package/js/ops/averagePool2D.js +38 -0
  53. package/js/ops/batchNorm2D.js +28 -0
  54. package/js/ops/cast.js +19 -0
  55. package/js/ops/clip.js +15 -0
  56. package/js/ops/concat2.js +18 -0
  57. package/js/ops/conv1D.js +35 -0
  58. package/js/ops/conv2D.js +70 -0
  59. package/js/ops/convTranspose2D.js +45 -0
  60. package/js/ops/crossAttention.js +69 -0
  61. package/js/ops/crossSDPA.js +41 -0
  62. package/js/ops/dequantizeLinear.js +9 -0
  63. package/js/ops/div.js +15 -0
  64. package/js/ops/embedding.js +14 -0
  65. package/js/ops/expand.js +24 -0
  66. package/js/ops/gELU.js +9 -0
  67. package/js/ops/gather.js +51 -0
  68. package/js/ops/gatherElements.js +33 -0
  69. package/js/ops/globalAveragePool.js +21 -0
  70. package/js/ops/hardSigmoid.js +12 -0
  71. package/js/ops/hardSwish.js +12 -0
  72. package/js/ops/interp1D.js +25 -0
  73. package/js/ops/layerNorm.js +25 -0
  74. package/js/ops/leakyReLU.js +10 -0
  75. package/js/ops/logSoftmax.js +15 -0
  76. package/js/ops/matMul.js +35 -0
  77. package/js/ops/maxPool2D.js +36 -0
  78. package/js/ops/meanHeight.js +17 -0
  79. package/js/ops/mul.js +31 -0
  80. package/js/ops/nonMaxSuppression.js +72 -0
  81. package/js/ops/pReLU.js +11 -0
  82. package/js/ops/pad.js +35 -0
  83. package/js/ops/profileX.js +22 -0
  84. package/js/ops/profileY.js +22 -0
  85. package/js/ops/rMSNorm.js +14 -0
  86. package/js/ops/reLU.js +8 -0
  87. package/js/ops/reduceMean.js +17 -0
  88. package/js/ops/reduceSum.js +19 -0
  89. package/js/ops/reshape.js +6 -0
  90. package/js/ops/resize.js +44 -0
  91. package/js/ops/sDPA.js +44 -0
  92. package/js/ops/siLU.js +8 -0
  93. package/js/ops/sigmoid.js +6 -0
  94. package/js/ops/slice.js +36 -0
  95. package/js/ops/softmax.js +18 -0
  96. package/js/ops/spatialSoftargmaxY.js +28 -0
  97. package/js/ops/split.js +24 -0
  98. package/js/ops/sub.js +11 -0
  99. package/js/ops/tanh.js +7 -0
  100. package/js/ops/transpose.js +34 -0
  101. package/js/ops/upsample2x.js +23 -0
  102. package/js/ops/where.js +15 -0
  103. package/package.json +33 -0
  104. package/shaders/add.wgsl +13 -0
  105. package/shaders/add3Relu.wgsl +23 -0
  106. package/shaders/addRelu.wgsl +22 -0
  107. package/shaders/averagePool2D.wgsl +24 -0
  108. package/shaders/batchNorm2D.wgsl +21 -0
  109. package/shaders/binaryBroadcast.wgsl +34 -0
  110. package/shaders/broadcastBinary.wgsl +26 -0
  111. package/shaders/clip.wgsl +10 -0
  112. package/shaders/concat2.wgsl +16 -0
  113. package/shaders/concatCopy.wgsl +10 -0
  114. package/shaders/concatSigmoidCopy.wgsl +16 -0
  115. package/shaders/conv1D.wgsl +37 -0
  116. package/shaders/conv2D.wgsl +80 -0
  117. package/shaders/conv2DDepthwise4.wgsl +74 -0
  118. package/shaders/conv2DDepthwise8.wgsl +66 -0
  119. package/shaders/conv2DPointwise16.wgsl +67 -0
  120. package/shaders/conv2DPointwise16Tile.wgsl +86 -0
  121. package/shaders/conv2DPointwise8.wgsl +85 -0
  122. package/shaders/conv2DPointwise8Vec2.wgsl +70 -0
  123. package/shaders/conv2DPointwise8Vec4.wgsl +65 -0
  124. package/shaders/conv2DRegularC3Out16.wgsl +75 -0
  125. package/shaders/convTranspose2D.wgsl +33 -0
  126. package/shaders/copy.wgsl +13 -0
  127. package/shaders/crossAttention.wgsl +140 -0
  128. package/shaders/crossAttentionF32.wgsl +98 -0
  129. package/shaders/crossSDPA.wgsl +74 -0
  130. package/shaders/dequantizeLinear.wgsl +14 -0
  131. package/shaders/div.wgsl +34 -0
  132. package/shaders/elementwise.wgsl +13 -0
  133. package/shaders/embedding.wgsl +22 -0
  134. package/shaders/expand.wgsl +18 -0
  135. package/shaders/gELU.wgsl +13 -0
  136. package/shaders/gather.wgsl +17 -0
  137. package/shaders/generalTranspose.wgsl +19 -0
  138. package/shaders/globalAveragePool.wgsl +19 -0
  139. package/shaders/hardSigmoid.wgsl +13 -0
  140. package/shaders/hardSwish.wgsl +13 -0
  141. package/shaders/interp1D.wgsl +28 -0
  142. package/shaders/layerNorm.wgsl +33 -0
  143. package/shaders/leakyReLU.wgsl +11 -0
  144. package/shaders/linearF32.wgsl +33 -0
  145. package/shaders/linearF32RowMajor.wgsl +24 -0
  146. package/shaders/linearInt8.wgsl +42 -0
  147. package/shaders/logSoftmax.wgsl +22 -0
  148. package/shaders/maxPool2D.wgsl +37 -0
  149. package/shaders/meanHeight.wgsl +18 -0
  150. package/shaders/mul.wgsl +32 -0
  151. package/shaders/nonMaxSuppression.wgsl +92 -0
  152. package/shaders/pReLU.wgsl +14 -0
  153. package/shaders/pad.wgsl +19 -0
  154. package/shaders/profileX.wgsl +28 -0
  155. package/shaders/profileY.wgsl +28 -0
  156. package/shaders/quantizeLinear.wgsl +69 -0
  157. package/shaders/rMSNorm.wgsl +21 -0
  158. package/shaders/reLU.wgsl +13 -0
  159. package/shaders/reduce.wgsl +17 -0
  160. package/shaders/resize.wgsl +52 -0
  161. package/shaders/sDPA.wgsl +71 -0
  162. package/shaders/siLU.wgsl +13 -0
  163. package/shaders/sigmoid.wgsl +13 -0
  164. package/shaders/slice.wgsl +26 -0
  165. package/shaders/softmax.wgsl +23 -0
  166. package/shaders/spatialSoftargmaxY.wgsl +32 -0
  167. package/shaders/split.wgsl +15 -0
  168. package/shaders/sub.wgsl +34 -0
  169. package/shaders/tanh.wgsl +13 -0
  170. package/shaders/upsample2x.wgsl +24 -0
  171. package/shaders/where.wgsl +12 -0
  172. package/volvoxai.wasm +0 -0
@@ -0,0 +1,281 @@
1
+ # Chapter 3 — A Vision Model, op by op (EfficientDet-Lite0)
2
+
3
+ *Goal: follow a **320×320 photo** through an object detector until it outputs labeled boxes
4
+ ("dog at (x0,y0,x1,y1)"). Different ops than Chapter 2 — but the same idea: a graph of small
5
+ kernels on tensors.*
6
+
7
+ The model lives in `models/efficientdet_lite0_*/`. **EfficientDet-Lite0** is a compact
8
+ [object detector](https://arxiv.org/abs/1911.09070): given an image, it finds *what* objects are
9
+ present and *where*. It ships here in three numeric precisions — **fp32, fp16, int8** — that
10
+ compute the same thing at different size/speed trade-offs. This chapter traces the **fp32**
11
+ version (ops named `Conv2D`); Chapter 4 explains how int8 swaps in `QConv2D`.
12
+
13
+ ## 3.1 What the model consumes and produces
14
+
15
+ ```
16
+ INPUT input0 : shape [1, 320, 320, 3] one 320×320 RGB image (NHWC)
17
+ (int8 variant takes uint8 pixels 0–255; fp32 takes normalized floats)
18
+
19
+ OUTPUT scores : shape [1, 19206, 90] for each of 19206 candidate boxes, 90 class scores
20
+ boxes : shape [1, 19206, 4] for each candidate box, 4 coordinates
21
+ ```
22
+
23
+ The network proposes **19,206 candidate boxes** covering the image at many positions and sizes,
24
+ scores each against **90 object classes** (the COCO label set — person, car, dog, …), and you
25
+ keep the few confident, non-overlapping ones. Where does 19,206 come from? Five detection grids
26
+ of decreasing resolution, 9 candidate boxes ("anchors") per cell:
27
+
28
+ ```
29
+ grid 40×40 × 9 = 14400 (finds small objects)
30
+ grid 20×20 × 9 = 3600
31
+ grid 10×10 × 9 = 900
32
+ grid 5× 5 × 9 = 225
33
+ grid 3× 3 × 9 = 81 (finds big objects)
34
+ ─────
35
+ total 19206
36
+ ```
37
+
38
+ The whole graph is **262 nodes** (265 for int8). Inventory:
39
+
40
+ ```
41
+ 182 Conv2D 42 Add 14 MaxPool2D 12 ResizeNearest2D 10 Reshape 2 Concat
42
+ └─ of the convs: 102 are "pointwise/regular", 80 are "depthwise" (see §3.3)
43
+ ```
44
+
45
+ ## 3.2 The pipeline at a glance
46
+
47
+ ```mermaid
48
+ flowchart TD
49
+ IMG["image [1,320,320,3]"] --> BB
50
+ subgraph BB[1 · Backbone · EfficientNet-Lite0]
51
+ direction TB
52
+ STEM[stem Conv2D<br/>320→160, 3→32 ch] --> MB[16 × MBConv blocks<br/>depthwise + pointwise + residual]
53
+ end
54
+ BB -->|features at 5 scales<br/>40,20,10,5,3| FPN
55
+ subgraph FPN[2 · BiFPN · multi-scale feature fusion]
56
+ direction TB
57
+ FUSE["Resize ↑ / MaxPool ↓ then weighted Add<br/>(repeated a few times)"]
58
+ end
59
+ FPN --> HEADS
60
+ subgraph HEADS[3 · Detection heads]
61
+ direction TB
62
+ CLS[class head Conv2D<br/>→ 90 scores per anchor]
63
+ BOX[box head Conv2D<br/>→ 4 coords per anchor]
64
+ end
65
+ HEADS --> DEC[Reshape + Concat<br/>flatten 5 grids into one list]
66
+ DEC --> OUT["scores [1,19206,90]<br/>boxes [1,19206,4]"]
67
+ OUT --> POST[Postprocess<br/>sigmoid · decode vs anchors · NMS]
68
+ POST --> RES["final: few labeled boxes"]
69
+ ```
70
+
71
+ Three learned stages (backbone → BiFPN → heads) produce raw numbers; a fixed postprocess turns
72
+ them into boxes you can draw. Let's take them in order — but first, the one op that dominates:
73
+ **convolution**.
74
+
75
+ ---
76
+
77
+ ## 3.3 The core op: convolution (`Conv2D`)
78
+
79
+ Where the language model leans on `MatMul`, a vision model leans on `Conv2D`. A convolution
80
+ slides a small **filter** (a little grid of weights, e.g. 3×3) across the image. At each
81
+ position it multiplies the filter by the pixels underneath and sums — a **dot product** — to
82
+ produce one output value. Slide it everywhere and you get a new image ("feature map") that lights
83
+ up wherever that filter's pattern (an edge, a texture, an eye) appears.
84
+
85
+ ```
86
+ input patch filter (3×3) one output pixel
87
+ ┌──────────┐ ┌──────────┐
88
+ │ a b c │ │ w1 w2 w3 │ out = a·w1 + b·w2 + c·w3
89
+ │ d e f │ ⊙ │ w4 w5 w6 │ = + d·w4 + e·w5 + f·w6
90
+ │ g h i │ │ w7 w8 w9 │ + g·w7 + h·w8 + i·w9
91
+ └──────────┘ └──────────┘ (then slide right by `stride` and repeat)
92
+ ```
93
+
94
+ The real reference kernel (`js/ops/conv2D.js`) is just those slides written as nested loops —
95
+ batch, output-row, output-col, output-channel, then the filter taps:
96
+
97
+ ```javascript
98
+ for (let oh = 0; oh < out_h; oh++)
99
+ for (let ow = 0; ow < out_w; ow++)
100
+ for (let oc = 0; oc < out_c; oc++) {
101
+ let sum = 0;
102
+ for (let ic = 0; ic < in_c; ic++) // over input channels
103
+ for (let kh = 0; kh < k_h; kh++) // over filter height
104
+ for (let kw = 0; kw < k_w; kw++) { // over filter width
105
+ const ih = oh*stride_y + kh*dil_y - pad; // which input pixel
106
+ const iw = ow*stride_x + kw*dil_x - pad;
107
+ if (in-bounds) sum += input[…ih,iw,ic…] * weight[…kh,kw,ic,oc…];
108
+ }
109
+ if (bias) sum += bias[oc];
110
+ if (relu) sum = clamp(sum, 0, 6); // fused ReLU6 activation
111
+ output[…oh,ow,oc…] = sum;
112
+ }
113
+ ```
114
+
115
+ Two flavors of convolution appear, and their combination is the whole efficiency trick of this
116
+ model family:
117
+
118
+ | | **Pointwise** (1×1) | **Depthwise** (3×3, `groups = channels`) |
119
+ |---|---|---|
120
+ | Filter | 1×1, mixes **channels** | 3×3, mixes **space**, each channel separately |
121
+ | Job | "recombine features" | "look at local patterns" |
122
+ | Cost | cheap per pixel, but all-to-all channels | very cheap — no channel mixing |
123
+
124
+ A regular conv does both at once (expensive). **Depthwise-separable** convolution splits it into
125
+ a depthwise (spatial) + pointwise (channel) pair that costs a fraction as much for nearly the
126
+ same power. That pairing is the `MBConv` block, the backbone's Lego brick — and why 80 of the
127
+ 182 convs are depthwise.
128
+
129
+ > **`groups`** in the code: `groups = in_c` means "each channel is convolved by its own filter"
130
+ > (depthwise). `groups = 1` means "every output channel sees every input channel" (regular). The
131
+ > same kernel handles both by looping over the right channel range.
132
+
133
+ ---
134
+
135
+ ## 3.4 Stage 1 — Backbone: image → features
136
+
137
+ The **backbone** (an *EfficientNet-Lite0*) is a feature extractor. It repeatedly:
138
+
139
+ 1. **Shrinks** the spatial size (via stride-2 convs and pooling) — 320→160→80→40→20→10→…
140
+ 2. **Grows** the channel count — 3→32→…→320+ — trading "where" for "what."
141
+
142
+ Early layers detect edges and colors; middle layers detect textures and parts (an eye, a wheel);
143
+ late layers detect whole objects. This hierarchy is *learned*, not programmed.
144
+
145
+ ```
146
+ stem: [1,320,320, 3] --Conv2D stride2--> [1,160,160, 32]
147
+ MBConv ×16: … depthwise + pointwise + residual Add … (channels grow, size shrinks)
148
+ outputs 5 feature maps at strides 8,16,32,64,128:
149
+ P3 [1,40,40,C] P4 [1,20,20,C] P5 [1,10,10,C] P6 [1,5,5,C] P7 [1,3,3,C]
150
+ ```
151
+
152
+ The **residual `Add`** inside each MBConv is the same trick as the transformer: add the block's
153
+ output back onto its input so deep stacks stay trainable. Same idea, different domain.
154
+
155
+ Why five feature maps instead of one? **Scale.** A 40×40 map has fine detail (good for small
156
+ objects); a 3×3 map sees huge receptive fields (good for big objects). Detecting at multiple
157
+ scales is how one network finds both a distant bird and a close-up bus.
158
+
159
+ ---
160
+
161
+ ## 3.5 Stage 2 — BiFPN: mix the scales together
162
+
163
+ A small feature map knows *"there's an object here"* but is spatially coarse; a large one is
164
+ precise but semantically shallow. The **BiFPN** (Bi-directional Feature Pyramid Network) lets the
165
+ five scales exchange information, top-down and bottom-up, so every scale gets both fine detail
166
+ and high-level meaning. It uses exactly three ops you already understand:
167
+
168
+ ```mermaid
169
+ flowchart TB
170
+ P7b[P7 3×3] -->|Resize ↑| u6
171
+ P6b[P6 5×5] --> u6((weighted Add)) -->|Resize ↑| u5
172
+ P5b[P5 10×10] --> u5((weighted Add)) -->|Resize ↑| u4
173
+ P4b[P4 20×20] --> u4((weighted Add)) -->|Resize ↑| u3
174
+ P3b[P3 40×40] --> u3((weighted Add))
175
+ u3 -->|MaxPool ↓| d4((weighted Add))
176
+ u4 --> d4 -->|MaxPool ↓| d5((weighted Add))
177
+ u5 --> d5 -->|MaxPool ↓| d6
178
+ style u6 fill:#eef
179
+ ```
180
+
181
+ - **`ResizeNearest2D`** upsamples a small map to a bigger one (top-down path). *12 of these.*
182
+ - **`MaxPool2D`** downsamples a big map to a smaller one (bottom-up path). *14 of these.* The
183
+ kernel (`js/ops/maxPool2D.js`) just keeps the max value in each window.
184
+ - **`Add`** (often *weighted* — learnable importance per input) fuses two aligned maps. *42
185
+ Adds* across the model do this fusion and the backbone residuals.
186
+
187
+ That's it — BiFPN is "resize until two maps are the same size, then add them," repeated. No new
188
+ math.
189
+
190
+ ---
191
+
192
+ ## 3.6 Stage 3 — Heads: features → per-anchor predictions
193
+
194
+ Two small conv stacks (shared across the five scales) read the fused features and, at **every**
195
+ grid cell, output predictions for that cell's **9 anchors** (9 reference box shapes of different
196
+ sizes/aspect ratios centered on the cell):
197
+
198
+ - **Class head** → `90` numbers per anchor: a raw score for each object class.
199
+ - **Box head** → `4` numbers per anchor: adjustments (dx, dy, dw, dh) to the anchor's position
200
+ and size.
201
+
202
+ > **Anchors** are the clever bit. Rather than predict boxes from nothing, the model predicts
203
+ > small *corrections* to a fixed grid of prior boxes. Predicting "shift this reference box a bit"
204
+ > is far easier to learn than "invent a box at (173, 92, 240, 210) from scratch."
205
+
206
+ ---
207
+
208
+ ## 3.7 Stage 4 — Reshape + Concat: flatten five grids into one list
209
+
210
+ Each of the 5 scales produced predictions in its own grid shape. `Reshape` flattens each grid to
211
+ a plain list of anchors, and `Concat` stacks all five lists into one (this is the tail of the
212
+ graph — see the real node shapes):
213
+
214
+ ```
215
+ class predictions: [1,40,40,…] → [1,14400,90] ┐
216
+ [1,20,20,…] → [1, 3600,90] │
217
+ [1,10,10,…] → [1, 900,90] ├─Concat─▶ scores [1,19206,90]
218
+ [1, 5, 5,…] → [1, 225,90] │
219
+ [1, 3, 3,…] → [1, 81,90] ┘
220
+ box predictions: … same five grids … ─Concat─▶ boxes [1,19206,4]
221
+ ```
222
+
223
+ `Reshape` doesn't move numbers around in memory at all — it just reinterprets the same flat
224
+ buffer with a new shape (recall §1.2). In this repo it's a copy-through op. **The network's job
225
+ is now done:** two tensors, 19,206 scored candidate boxes.
226
+
227
+ ---
228
+
229
+ ## 3.8 Stage 5 — Postprocess: 19,206 candidates → a few boxes
230
+
231
+ The raw outputs aren't drawable yet. Three fixed (non-learned) steps finish the job:
232
+
233
+ 1. **Sigmoid** the class scores → probabilities in `[0,1]`. (In the int8 export this `Sigmoid`
234
+ was folded away for speed, so `scores` are raw logits; apply sigmoid yourself, or just compare
235
+ them — bigger is still more confident.)
236
+ 2. **Decode boxes**: turn each anchor's 4 deltas into real pixel corners `(x0,y0,x1,y1)` by
237
+ applying them to that anchor's reference box.
238
+ 3. **Non-Max Suppression (NMS)**: the same object usually fires several overlapping anchors. NMS
239
+ keeps the highest-scoring box and deletes others that overlap it too much (high *IoU*,
240
+ intersection-over-union), per class. VolvoxAI has this as an op — `js/ops/nonMaxSuppression.js`.
241
+
242
+ ```
243
+ before NMS: ▢▢▢ three overlapping "dog" boxes, scores 0.91, 0.88, 0.72
244
+ after NMS: ▢ keep the 0.91; suppress the two that overlap it > 50%
245
+ ```
246
+
247
+ The native `detect` command (`native/main.c`, `print_detections`) uses a **simplified** version
248
+ of step 3 for the demo: it takes the top-`max_det` boxes by their best class score and prints
249
+ them as a ranked table (label lookup via `labels.txt`):
250
+
251
+ ```
252
+ rank index score class x0 y0 x1 y1
253
+ 1 4213 0.91 17 0.31 0.44 0.62 0.88 ← "dog"
254
+ 2 991 0.86 2 0.05 0.10 0.40 0.95 ← "bicycle"
255
+ ```
256
+
257
+ Draw those rectangles on the original photo and you have object detection.
258
+
259
+ ---
260
+
261
+ ## 3.9 The two models, side by side
262
+
263
+ You've now traced both worlds. Notice how much they share:
264
+
265
+ | | TinyStories (language) | EfficientDet-Lite0 (vision) |
266
+ |---|---|---|
267
+ | Input tensor | tokens `[1,256]` | image `[1,320,320,3]` |
268
+ | Dominant op | `MatMul` | `Conv2D` |
269
+ | "Mixing" mechanism | attention (`SDPA`) across tokens | convolution across pixels |
270
+ | Nonlinearity | `GELU` | `ReLU6` (fused into conv) |
271
+ | Deep-stack trick | residual `Add` + `LayerNorm` | residual `Add` (in MBConv) |
272
+ | Output | logits `[…,50257]` → next word | scores/boxes `[…,19206,…]` → objects |
273
+ | Postprocess | argmax / sampling | sigmoid + decode + NMS |
274
+
275
+ Same skeleton — *a graph of small tensor ops with learned weights* — solving text and pixels.
276
+ That transfer is the whole point: learn the skeleton once and every model becomes readable.
277
+
278
+ The last big question is the one the three EfficientDet folders raise: **fp32 vs fp16 vs int8.**
279
+ What are those, and why ship the same model three times? That's Chapter 4.
280
+
281
+ **Next:** [Chapter 4 — Precision & Quantization →](04-precision-and-quantization.md)
@@ -0,0 +1,208 @@
1
+ # Chapter 4 — Precision & Quantization (fp32 / fp16 / int8)
2
+
3
+ *Goal: understand why the same detector ships in three folders, how numbers are stored as bits,
4
+ and the exact integer math that makes the int8 model 4× smaller — with almost no accuracy loss.*
5
+
6
+ Look at the three EfficientDet folders. Same architecture (Chapter 3), same 262-ish nodes,
7
+ **very different file sizes**:
8
+
9
+ | Folder | Weights store each number as… | `model.safetensors` | Relative |
10
+ |---|---|---|---|
11
+ | `efficientdet_lite0_fp32` | 32-bit float | **12.67 MB** | 1.0× |
12
+ | `efficientdet_lite0_fp16` | 16-bit float | **6.34 MB** | 0.50× |
13
+ | `efficientdet_lite0_int8` | 8-bit integer | **3.39 MB** | 0.27× |
14
+
15
+ The *model* is identical. Only the **number format** — the **precision** — changed. Smaller
16
+ numbers → smaller downloads, less memory, and (with the right hardware) faster math. This chapter
17
+ is about that trade.
18
+
19
+ ---
20
+
21
+ ## 4.1 How a computer stores a number
22
+
23
+ A neural net weight like `0.10125` has to become a fixed pattern of bits. There are two families.
24
+
25
+ ### Floating-point (fp32, fp16): "scientific notation in binary"
26
+
27
+ A float splits its bits into a **sign**, an **exponent** (how big), and a **mantissa** (the
28
+ precise digits). More bits → more precision and more range.
29
+
30
+ ```
31
+ fp32 (4 bytes) [S][ 8-bit exponent ][ 23-bit mantissa ] ~7 decimal digits
32
+ fp16 (2 bytes) [S][ 5-bit exp ][ 10-bit mantissa ] ~3 decimal digits
33
+ ```
34
+
35
+ - **fp32** ("single precision") is the default everywhere — huge range, ~7 digits of precision.
36
+ It's what training uses and what VolvoxAI uses for activations.
37
+ - **fp16** ("half precision") halves the storage. Plenty precise for inference, but its range is
38
+ smaller (big/small values can overflow/underflow), and — crucially — a GPU must *support*
39
+ fp16 math to get a speedup. (See §4.5 for why Volvox is cautious with it in browsers.)
40
+
41
+ ### Integer (int8): "a ruler with 256 evenly-spaced ticks"
42
+
43
+ An **int8** stores a whole number from **−128 to 127** — just 256 possible values, 1 byte. That's
44
+ far too coarse to hold `0.10125` directly. The trick — **quantization** — is to store a cheap
45
+ integer plus a *recipe* for turning it back into a float.
46
+
47
+ ---
48
+
49
+ ## 4.2 Quantization: the scale + zero-point recipe
50
+
51
+ Real weights in a layer live in some range, say `−0.4 … +0.4`. Quantization stretches the int8
52
+ ruler (`−128 … 127`) across that range with two numbers:
53
+
54
+ - **`scale`** — the size of one tick (real units per integer step).
55
+ - **`zero_point`** — which integer represents real `0.0`.
56
+
57
+ ```
58
+ real value r ≈ (q − zero_point) × scale ← DEQUANTIZE (int → float)
59
+ integer q = round(r / scale) + zero_point ← QUANTIZE (float → int)
60
+
61
+ real: -0.4 -0.2 0.0 0.2 0.4
62
+ │ │ │ │ │
63
+ int8: -128 -64 0 64 127 (scale ≈ 0.4/127)
64
+ ```
65
+
66
+ Both formulas are *in the codebase, verbatim*. Dequantize (`js/ops/dequantizeLinear.js`):
67
+
68
+ ```javascript
69
+ out[i] = (in[i] - zero_point) * scale; // int8 → float
70
+ ```
71
+
72
+ Quantize (`native/quant_cpu_opt.c`, `quantize_scalar_i8`):
73
+
74
+ ```c
75
+ q = clamp_i8( lrintf(x / scale) + zero_point ); // float → int8, clamped to [-128,127]
76
+ ```
77
+
78
+ That's the whole idea. An int8 weight is a *ticket*; `scale` and `zero_point` tell you what it's
79
+ worth. Storing the ticket costs 1 byte; the recipe is shared across a whole channel, so it's
80
+ nearly free.
81
+
82
+ > **Per-channel scales.** A single scale for a whole layer would be crude — one big weight would
83
+ > stretch the ruler and crush everyone else's precision. So each output **channel** gets its own
84
+ > `weight_scale` (you saw `weight_scale: "w1"` as a *tensor* input to `QConv2D`). This
85
+ > "per-channel quantization" is what keeps int8 accuracy within ~1% of fp32.
86
+
87
+ ---
88
+
89
+ ## 4.3 A worked example (real numbers from the model)
90
+
91
+ The int8 EfficientDet's first node is `QuantizeLinear` with `input_scale = 0.0078125` (which is
92
+ exactly `1/128`) — it turns incoming pixels into int8. Then the first `QConv2D` has
93
+ `output_scale = 0.0235294`, `output_zero_point = -128`. Let's quantize one weight and one output.
94
+
95
+ ```
96
+ Quantize a weight r = 0.101, scale = 0.008, zero_point = 0:
97
+ q = round(0.101 / 0.008) + 0 = round(12.625) = 13 → stored as the byte 13
98
+
99
+ Later, recover it:
100
+ r ≈ (13 - 0) × 0.008 = 0.104 → 0.104 vs 0.101, error 0.003
101
+ ```
102
+
103
+ The 0.003 error is **quantization noise**. Spread across a big dot product, these tiny rounding
104
+ errors mostly cancel — which is why an 8-bit model still detects dogs correctly.
105
+
106
+ ---
107
+
108
+ ## 4.4 How int8 convolution actually runs (`QConv2D`)
109
+
110
+ Here's the payoff. A quantized conv does its heavy multiply-accumulate loop in **cheap integer
111
+ arithmetic**, and only converts back to a real number once at the very end. The pipeline for one
112
+ output value (`native/quant_cpu_opt.c`):
113
+
114
+ ```mermaid
115
+ flowchart LR
116
+ A["int8 inputs<br/>(−128…127)"] --> B["int32 accumulate<br/>Σ (in_q − in_zp) × w_q<br/>(all integer math)"]
117
+ B --> C["to float:<br/>v = acc × in_scale × w_scale + bias"]
118
+ C --> D["ReLU6 clamp<br/>(fused activation)"]
119
+ D --> E["requantize:<br/>q = round(v / out_scale) + out_zp"]
120
+ E --> F["int8 output<br/>feeds next layer"]
121
+ ```
122
+
123
+ 1. **Integer accumulate.** Multiply int8×int8, sum into an **int32** accumulator. Integer
124
+ multiply-add is fast and cheap on every CPU (and vectorizes 8–32 lanes wide with AVX2/NEON).
125
+ 2. **Requantize.** Convert the int32 sum back to the layer's int8 scale in one step. The real
126
+ code composes all the scales into one multiply:
127
+
128
+ ```c
129
+ // acc (int32) → float → relu6 → int8, for one output element:
130
+ float v = acc * (input_scale * weight_scale) + bias; // combined rescale
131
+ int8 q = requantize_i8(v, output_scale, output_zp, relu);
132
+ // = clamp_i8( round( relu6(v) / output_scale ) + output_zp );
133
+ ```
134
+
135
+ The key insight: **activations stay int8 from layer to layer** ("a quantized island"), so the
136
+ whole backbone runs in bytes. Only at the very end does `DequantizeLinear` turn the final
137
+ `scores`/`boxes` back into floats you can read. This is exactly what VolvoxAI's native CPU path
138
+ does (`native/quant_cpu_opt.c`); the browser tiers instead fold int8 conv weights back to fp32 at
139
+ load time (simpler, still small on disk).
140
+
141
+ ---
142
+
143
+ ## 4.5 Why each format exists — the trade-off
144
+
145
+ ```
146
+ SMALLER / FASTER ◀───────────────────────────────▶ MORE ACCURATE / SIMPLER
147
+ int8 fp16 fp32
148
+ 1 byte/weight 2 bytes/weight 4 bytes/weight
149
+ integer math needs fp16 HW works everywhere
150
+ ~4× smaller ~2× smaller reference accuracy
151
+ tiny accuracy dip ~lossless lossless
152
+ ```
153
+
154
+ | Question | fp32 | fp16 | int8 |
155
+ |---|---|---|---|
156
+ | Disk / memory | biggest | half | quarter |
157
+ | Accuracy vs fp32 | reference | ~identical | usually within ~1% |
158
+ | Needs special hardware? | no | **yes** (fp16 units) | no (integer is universal) |
159
+ | Best when… | max accuracy, or you'll quantize later | GPU has fp16 & you want easy 2× | edge/mobile/browser, size & speed matter |
160
+
161
+ **Why VolvoxAI leans on fp32 + int8 and is wary of fp16 (from the README):**
162
+
163
+ - **fp16** needs the WebGPU `shader-f16` extension, which isn't universal on consumer devices —
164
+ so a browser engine can't rely on it. (The fp16 folder here is mainly for platforms/formats
165
+ that do support it.)
166
+ - **int8** gives a 4× size cut with near-zero accuracy loss, and integer math runs fast on *any*
167
+ CPU/GPU — the sweet spot for portable inference. Volvox skips **int4** because 4-bit needs
168
+ fiddly bit-unpacking that hurts low-end mobile GPUs.
169
+
170
+ ---
171
+
172
+ ## 4.6 The three configs, side by side
173
+
174
+ Because precision is a *storage* choice, the graphs are nearly identical — only the conv op and a
175
+ little quant bookkeeping differ:
176
+
177
+ ```
178
+ fp32 / fp16 graph: int8 graph:
179
+ input (float) input (uint8)
180
+ │ │ QuantizeLinear ← float/uint8 → int8 (once)
181
+ Conv2D ─┐ QConv2D ─┐ ← integer conv, int8 in/out
182
+ Conv2D │ 182 float convs QConv2D │ 182 int8 convs (stays int8 the whole way)
183
+ … │ … │
184
+ Add / MaxPool / Resize Add / MaxPool / Resize (int8-aware)
185
+ │ │ DequantizeLinear ← int8 → float (twice, at the end)
186
+ scores, boxes (float) scores, boxes (float)
187
+ ```
188
+
189
+ That's why the int8 config has **3 extra nodes** (1 `QuantizeLinear` + 2 `DequantizeLinear`) and
190
+ its 182 convs are `QConv2D` instead of `Conv2D`. Same detector, three sizes — you pick the point
191
+ on the curve your device needs.
192
+
193
+ ---
194
+
195
+ ## 4.7 What you just learned
196
+
197
+ - **Precision** is how many bits each number gets: fp32 (4 B), fp16 (2 B), int8 (1 B).
198
+ - **Quantization** maps floats onto the 256-value int8 ruler with a `scale` + `zero_point`; the
199
+ formulas `(q−zp)×scale` and `round(r/scale)+zp` are the whole trick, and they're in the repo.
200
+ - **int8 conv** accumulates in cheap int32 and requantizes once — activations stay int8 layer to
201
+ layer, giving ~4× smaller + faster with ~1% accuracy cost.
202
+ - You **choose** the format per deployment: fp32 for fidelity, int8 for the edge, fp16 when the
203
+ hardware supports it.
204
+
205
+ Next: how VolvoxAI takes any of these graphs and runs it *fast* — the four hardware tiers, the
206
+ jump from a naive kernel to an optimized one, and operator fusion.
207
+
208
+ **Next:** [Chapter 5 — Inside the Engine →](05-inside-the-engine.md)
@@ -0,0 +1,155 @@
1
+ # Chapter 5 — Inside the Engine
2
+
3
+ *Goal: understand how VolvoxAI turns "a list of ops" into something that runs **fast** on real
4
+ hardware — the four tiers, the leap from a naive kernel to an optimized one, and operator fusion.*
5
+
6
+ You now know *what* the models compute. This chapter is about *making it fast* — the engineering
7
+ that separates a textbook implementation from a shippable one. This is where a lot of real AI
8
+ systems work lives.
9
+
10
+ ---
11
+
12
+ ## 5.1 One graph, four engines (the tiers)
13
+
14
+ Recall from Chapter 1: VolvoxAI can run the same graph four ways in the browser (plus a native
15
+ binary). They differ only in **who does the arithmetic** and **where the tensors live**.
16
+
17
+ ```mermaid
18
+ flowchart TD
19
+ G["Graph + weights"] --> I["VolvoxAI.init(backend)"]
20
+ I --> T1["Tier 1 · WebNN<br/>hand graph to browser ML API → NPU/GPU/CPU"]
21
+ I --> T2["Tier 2 · WebGPU<br/>one compute pipeline per node, all on GPU"]
22
+ I --> T3["Tier 3 · WASM SIMD<br/>compiled C kernels over linear memory"]
23
+ I --> T4["Tier 4 · Pure JS<br/>reference kernels — slow, always correct"]
24
+ T1 -.fallback.-> T3
25
+ T3 -.fallback.-> T4
26
+ ```
27
+
28
+ - **Tier 4 — Pure JS** (`js/ops/*.js`): the naive kernels we read all book. Slow but simple and
29
+ dependency-free. **It is the ground truth**: every faster tier is checked against it.
30
+ - **Tier 3 — WASM** (`js/WasmEngine.js` + `native/kernels/*.c`): the *same* ops compiled to
31
+ WebAssembly with SIMD. A bump allocator drops every tensor into one flat block of linear
32
+ memory; `execute()` calls a compiled C kernel per node. Often 5–50× faster than pure JS.
33
+ - **Tier 2 — WebGPU** (`js/GraphExecutor.js` + `shaders/*.wgsl`): each op becomes a GPU **compute
34
+ shader**. At compile time it uploads all weights to VRAM and builds *one pipeline per node*;
35
+ `execute()` replays them in a single command stream, **with no CPU round-trip between nodes**,
36
+ so the whole model stays resident on the GPU. If a needed shader is missing, the current
37
+ executor warns and skips that node; use WASM/CPU for models that require unsupported ops.
38
+ - **Tier 1 — WebNN** (`js/WebNNEngine.js`): hands the graph to the browser's own neural-network
39
+ API, which may dispatch to a dedicated **NPU**. Falls through to a lower tier for any op it
40
+ doesn't support.
41
+
42
+ The design principle: **choose a viable backend up front.** WebNN can fall through when graph
43
+ compilation fails, and WASM can fall back to pure JS helpers. WebGPU should be used for graphs
44
+ whose ops are covered by shaders.
45
+
46
+ ---
47
+
48
+ ## 5.2 Why the naive kernel is slow
49
+
50
+ Reread the naive `Conv2D` from Chapter 3: seven nested loops, one multiply at a time. It is
51
+ *correct*, but a modern CPU hates it, for two reasons:
52
+
53
+ 1. **It wastes the SIMD units.** A CPU core can multiply 8 (AVX2) or more floats in a *single*
54
+ instruction. The naive loop does them one… at… a… time, using a fraction of the silicon.
55
+ 2. **It thrashes the cache.** RAM is ~100× slower than the CPU. Cores keep recently used data in
56
+ a tiny fast **cache**. The naive conv jumps all over memory (strided image reads), so it
57
+ constantly waits on RAM instead of computing.
58
+
59
+ The result: the naive kernel might use **2–5%** of the chip's real throughput. Optimization is
60
+ about feeding the SIMD units and respecting the cache.
61
+
62
+ ---
63
+
64
+ ## 5.3 From naive to fast: the same math, rearranged
65
+
66
+ Optimized kernels never change *what* is computed (the output is identical to Tier 4) — they
67
+ change *the order and layout* of the work. VolvoxAI's hot paths live in `native/conv_f32_opt.c`
68
+ (fp32) and `native/quant_cpu_opt.c` (int8). The main techniques:
69
+
70
+ | Technique | Idea | Payoff |
71
+ |---|---|---|
72
+ | **im2col + GEMM** | Unfold conv patches into a big matrix, then call a fast matrix-multiply | reuses decades of tuned matmul; feeds SIMD |
73
+ | **Pointwise GEMM** | 1×1 convs *are* a matrix multiply — treat them as one directly | biggest single win (most convs are 1×1) |
74
+ | **Weight prepacking** | Re-lay-out weights once at load into the exact order the inner loop reads | turns cache-miss reads into sequential reads |
75
+ | **Blocking / tiling** | Process small tiles that fit in cache before moving on | data is reused while it's still hot |
76
+ | **SIMD (AVX2/NEON)** | Do 8–32 multiply-adds per instruction | ~8–32× the arithmetic per cycle |
77
+ | **Multithreading** | Split output rows across CPU cores | ~Ncores× on top of everything |
78
+
79
+ ```
80
+ naive conv optimized conv (im2col + GEMM)
81
+ for each output pixel: [1] unfold input patches → one big matrix (once)
82
+ for each filter tap: [2] one big, cache-friendly, SIMD, threaded
83
+ one scalar multiply matrix-multiply against prepacked weights
84
+ (scattered memory, 1 lane) (sequential memory, many lanes, many cores)
85
+ ```
86
+
87
+ > **A real result from this repo.** The memory notes for this project record that adding an
88
+ > *arena buffer-reuse planner* (reusing scratch memory instead of allocating fresh per node) cut
89
+ > peak memory from **88.8 MB → 18.8 MB** and, combined with a pointwise-GEMM path, sped the
90
+ > EfficientDet forward pass up meaningfully — closing the gap with Google's TFLite/XNNPACK to
91
+ > ~1.16× (warm). Same numbers out; dramatically less memory and time. *That* is kernel
92
+ > engineering.
93
+
94
+ The lesson: **correctness lives in the naive kernel; performance lives in memory layout.**
95
+ Read `js/ops/conv2D.js`, then diff it against `native/conv_f32_opt.c` to see the two
96
+ halves of the craft.
97
+
98
+ ---
99
+
100
+ ## 5.4 Operator fusion: stop touching memory so much
101
+
102
+ Between ops, results get written to memory and read back by the next op. For big feature maps,
103
+ *moving* the data can cost more than the math. **Operator fusion** merges adjacent ops so the
104
+ data is touched once. VolvoxAI runs a compile-time fusion pass (`native/graph_opt_fusion.c`):
105
+
106
+ ```
107
+ before fusion: Conv2D ─▶ [write feature map] ─▶ ReLU6 ─▶ [write again]
108
+ after fusion: Conv2D+ReLU6 ─▶ [write once, already activated]
109
+ ```
110
+
111
+ Fusion patterns it applies (see `docs/operator_fusion_patterns.md`):
112
+
113
+ - **Conv + ReLU6** → clamp right inside the conv's requantize step (you saw `relu` folded into the
114
+ kernel in Chapters 3–4).
115
+ - **Chained `Add`** → sum several residuals in one pass.
116
+ - **Depthwise → Pointwise** → run the MBConv pair back-to-back without spilling the intermediate.
117
+ - **Concat + Sigmoid**, **alias elision** (drop no-op copies like some `Reshape`s).
118
+
119
+ Each fused pair is one fewer full read+write of a big tensor. Across 262 nodes, that adds up.
120
+
121
+ ---
122
+
123
+ ## 5.5 Memory: tensors are temporary
124
+
125
+ A subtle but important point: most tensors in a forward pass are **scratch** — needed briefly,
126
+ then never again (e.g. `ln1_0` is dead once the attention MatMul consumed it). A naive engine
127
+ allocates a fresh buffer for every node (simple, but wasteful). A smart one **plans** which
128
+ buffers can share memory, because their lifetimes don't overlap — the **arena buffer-reuse
129
+ planner** mentioned above. This is why VolvoxAI can run a model whose tensors *sum* to hundreds of
130
+ MB in a fraction of that peak RAM: the same physical bytes are recycled node after node.
131
+
132
+ ```
133
+ node lifetimes (─ = alive): buffer reuse:
134
+ A: ─── A and C never overlap → give them the SAME buffer
135
+ B: ───── B and D never overlap → share a buffer
136
+ C: ────
137
+ D: ─── 4 tensors, 2 physical buffers
138
+ ```
139
+
140
+ ---
141
+
142
+ ## 5.6 The whole engine, in one sentence
143
+
144
+ > **VolvoxAI walks the graph's node list, dispatches each node to the fastest available backend's
145
+ > kernel, and does so while reusing memory buffers and fusing adjacent ops — producing the exact
146
+ > same numbers as the naive reference, just far faster and smaller.**
147
+
148
+ That's the entire system. Everything else is one more op, one more backend, or one more
149
+ optimization on this skeleton.
150
+
151
+ > **This chapter covered the four *browser* tiers.** VolvoxAI also ships a full **native** engine
152
+ > (a freestanding C binary spanning CPU + Vulkan / OpenGL / Metal / Android NNAPI) that runs the
153
+ > *same blueprint*. That's the whole next chapter.
154
+
155
+ **Next:** [Chapter 6 — The Native Engine →](06-native-engine-architecture.md)