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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/volvox.js +72 -0
- package/dist/v0.1.0/volvoxai.js +4664 -0
- package/dist/v0.1.0/volvoxai.min.js +1848 -0
- package/dist/v0.1.0/volvoxai.wasm +0 -0
- package/dist/volvoxai.js +4664 -0
- package/dist/volvoxai.min.js +1848 -0
- package/dist/volvoxai.wasm +0 -0
- package/docs/README.md +22 -0
- package/docs/browser-runtime.md +87 -0
- package/docs/efficientdet_tflite_vs_volvoxai.md +445 -0
- package/docs/microkernel_optimization_guide.md +153 -0
- package/docs/model-format.md +108 -0
- package/docs/models.md +103 -0
- package/docs/native-runtime.md +189 -0
- package/docs/operation_list.md +232 -0
- package/docs/operator_fusion_patterns.md +58 -0
- package/docs/quickstart.md +115 -0
- package/docs/roadmap.md +19 -0
- package/docs/testing.md +97 -0
- package/docs/textbook/01-foundations.md +233 -0
- package/docs/textbook/02-tinystories-language-model.md +300 -0
- package/docs/textbook/03-efficientdet-vision-model.md +281 -0
- package/docs/textbook/04-precision-and-quantization.md +208 -0
- package/docs/textbook/05-inside-the-engine.md +155 -0
- package/docs/textbook/06-native-engine-architecture.md +338 -0
- package/docs/textbook/07-glossary-and-next-steps.md +258 -0
- package/docs/textbook/README.md +85 -0
- package/docs/textbook/ko/01-foundations.md +231 -0
- package/docs/textbook/ko/02-tinystories-language-model.md +300 -0
- package/docs/textbook/ko/03-efficientdet-vision-model.md +277 -0
- package/docs/textbook/ko/04-precision-and-quantization.md +206 -0
- package/docs/textbook/ko/05-inside-the-engine.md +154 -0
- package/docs/textbook/ko/06-native-engine-architecture.md +333 -0
- package/docs/textbook/ko/07-glossary-and-next-steps.md +253 -0
- package/docs/textbook/ko/README.md +83 -0
- package/docs/xnnpack_optimization_guide.md +197 -0
- package/js/CPUEngine.js +241 -0
- package/js/Graph.js +49 -0
- package/js/GraphExecutor.js +1020 -0
- package/js/GraphLoader.js +282 -0
- package/js/ShaderLibrary.js +236 -0
- package/js/Tensor.js +25 -0
- package/js/Tokenizer.js +266 -0
- package/js/VolvoxAI.js +130 -0
- package/js/WasmEngine.js +378 -0
- package/js/WebNNEngine.js +169 -0
- package/js/index.js +11 -0
- package/js/ops/add.js +31 -0
- package/js/ops/argMax.js +33 -0
- package/js/ops/averagePool2D.js +38 -0
- package/js/ops/batchNorm2D.js +28 -0
- package/js/ops/cast.js +19 -0
- package/js/ops/clip.js +15 -0
- package/js/ops/concat2.js +18 -0
- package/js/ops/conv1D.js +35 -0
- package/js/ops/conv2D.js +70 -0
- package/js/ops/convTranspose2D.js +45 -0
- package/js/ops/crossAttention.js +69 -0
- package/js/ops/crossSDPA.js +41 -0
- package/js/ops/dequantizeLinear.js +9 -0
- package/js/ops/div.js +15 -0
- package/js/ops/embedding.js +14 -0
- package/js/ops/expand.js +24 -0
- package/js/ops/gELU.js +9 -0
- package/js/ops/gather.js +51 -0
- package/js/ops/gatherElements.js +33 -0
- package/js/ops/globalAveragePool.js +21 -0
- package/js/ops/hardSigmoid.js +12 -0
- package/js/ops/hardSwish.js +12 -0
- package/js/ops/interp1D.js +25 -0
- package/js/ops/layerNorm.js +25 -0
- package/js/ops/leakyReLU.js +10 -0
- package/js/ops/logSoftmax.js +15 -0
- package/js/ops/matMul.js +35 -0
- package/js/ops/maxPool2D.js +36 -0
- package/js/ops/meanHeight.js +17 -0
- package/js/ops/mul.js +31 -0
- package/js/ops/nonMaxSuppression.js +72 -0
- package/js/ops/pReLU.js +11 -0
- package/js/ops/pad.js +35 -0
- package/js/ops/profileX.js +22 -0
- package/js/ops/profileY.js +22 -0
- package/js/ops/rMSNorm.js +14 -0
- package/js/ops/reLU.js +8 -0
- package/js/ops/reduceMean.js +17 -0
- package/js/ops/reduceSum.js +19 -0
- package/js/ops/reshape.js +6 -0
- package/js/ops/resize.js +44 -0
- package/js/ops/sDPA.js +44 -0
- package/js/ops/siLU.js +8 -0
- package/js/ops/sigmoid.js +6 -0
- package/js/ops/slice.js +36 -0
- package/js/ops/softmax.js +18 -0
- package/js/ops/spatialSoftargmaxY.js +28 -0
- package/js/ops/split.js +24 -0
- package/js/ops/sub.js +11 -0
- package/js/ops/tanh.js +7 -0
- package/js/ops/transpose.js +34 -0
- package/js/ops/upsample2x.js +23 -0
- package/js/ops/where.js +15 -0
- package/package.json +33 -0
- package/shaders/add.wgsl +13 -0
- package/shaders/add3Relu.wgsl +23 -0
- package/shaders/addRelu.wgsl +22 -0
- package/shaders/averagePool2D.wgsl +24 -0
- package/shaders/batchNorm2D.wgsl +21 -0
- package/shaders/binaryBroadcast.wgsl +34 -0
- package/shaders/broadcastBinary.wgsl +26 -0
- package/shaders/clip.wgsl +10 -0
- package/shaders/concat2.wgsl +16 -0
- package/shaders/concatCopy.wgsl +10 -0
- package/shaders/concatSigmoidCopy.wgsl +16 -0
- package/shaders/conv1D.wgsl +37 -0
- package/shaders/conv2D.wgsl +80 -0
- package/shaders/conv2DDepthwise4.wgsl +74 -0
- package/shaders/conv2DDepthwise8.wgsl +66 -0
- package/shaders/conv2DPointwise16.wgsl +67 -0
- package/shaders/conv2DPointwise16Tile.wgsl +86 -0
- package/shaders/conv2DPointwise8.wgsl +85 -0
- package/shaders/conv2DPointwise8Vec2.wgsl +70 -0
- package/shaders/conv2DPointwise8Vec4.wgsl +65 -0
- package/shaders/conv2DRegularC3Out16.wgsl +75 -0
- package/shaders/convTranspose2D.wgsl +33 -0
- package/shaders/copy.wgsl +13 -0
- package/shaders/crossAttention.wgsl +140 -0
- package/shaders/crossAttentionF32.wgsl +98 -0
- package/shaders/crossSDPA.wgsl +74 -0
- package/shaders/dequantizeLinear.wgsl +14 -0
- package/shaders/div.wgsl +34 -0
- package/shaders/elementwise.wgsl +13 -0
- package/shaders/embedding.wgsl +22 -0
- package/shaders/expand.wgsl +18 -0
- package/shaders/gELU.wgsl +13 -0
- package/shaders/gather.wgsl +17 -0
- package/shaders/generalTranspose.wgsl +19 -0
- package/shaders/globalAveragePool.wgsl +19 -0
- package/shaders/hardSigmoid.wgsl +13 -0
- package/shaders/hardSwish.wgsl +13 -0
- package/shaders/interp1D.wgsl +28 -0
- package/shaders/layerNorm.wgsl +33 -0
- package/shaders/leakyReLU.wgsl +11 -0
- package/shaders/linearF32.wgsl +33 -0
- package/shaders/linearF32RowMajor.wgsl +24 -0
- package/shaders/linearInt8.wgsl +42 -0
- package/shaders/logSoftmax.wgsl +22 -0
- package/shaders/maxPool2D.wgsl +37 -0
- package/shaders/meanHeight.wgsl +18 -0
- package/shaders/mul.wgsl +32 -0
- package/shaders/nonMaxSuppression.wgsl +92 -0
- package/shaders/pReLU.wgsl +14 -0
- package/shaders/pad.wgsl +19 -0
- package/shaders/profileX.wgsl +28 -0
- package/shaders/profileY.wgsl +28 -0
- package/shaders/quantizeLinear.wgsl +69 -0
- package/shaders/rMSNorm.wgsl +21 -0
- package/shaders/reLU.wgsl +13 -0
- package/shaders/reduce.wgsl +17 -0
- package/shaders/resize.wgsl +52 -0
- package/shaders/sDPA.wgsl +71 -0
- package/shaders/siLU.wgsl +13 -0
- package/shaders/sigmoid.wgsl +13 -0
- package/shaders/slice.wgsl +26 -0
- package/shaders/softmax.wgsl +23 -0
- package/shaders/spatialSoftargmaxY.wgsl +32 -0
- package/shaders/split.wgsl +15 -0
- package/shaders/sub.wgsl +34 -0
- package/shaders/tanh.wgsl +13 -0
- package/shaders/upsample2x.wgsl +24 -0
- package/shaders/where.wgsl +12 -0
- 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)
|