TheREZOR commited on
Commit
aa745b7
·
verified ·
1 Parent(s): 4d03824

esp32: 2.4x faster on the ESP32-S3 (quick-start 2.52 s -> 1.06 s on a Cardputer)

Browse files

Measured on an M5Stack Cardputer: 67 tokens 2514 -> 1056 ms, 126 tokens 5687 -> 2281 ms, 12 tokens 528 -> 245 ms; same heap. Bit-identical: 4-token PIE kernel with the tile loop in assembly, attention loop restructuring, lrintf-free rounding, LayerNorm/quantisation on both cores, 64-byte-aligned embedded model. Within float rounding (outputs move <= 0.0035): fc2 in 256-neuron groups, Cephes exp in the attention softmax, heads read each matrix once (u = W^T q). Host conformance builds with -ffp-contract=fast like the device GCC: max|dp| 0.0163, no pick or span flips.

README.md CHANGED
@@ -178,7 +178,7 @@ const char* msg = "Book a table for 4 at an Italian place near the station on Fr
178
  td::answer(msg, qs, 2, a); // a[0].pick == 3, msg[a[1].start, a[1].end) == "7:30"
179
  ```
180
 
181
- On the ESP32-S3, the PIE vector unit runs the 4-bit matmuls on both cores. On an M5Stack Cardputer the quick-start request takes 2.5 s. It has four questions and 67 tokens, and the answers match the table above. That timing comes from the ESP-IDF project in `esp32/examples/basic`. The Arduino sketch in `esp32/tinydecide/examples/Basic` builds, but it has not run on a device yet. Each folder's README has the details.
182
 
183
  ## Files
184
 
@@ -228,7 +228,7 @@ Calibration error (ECE) is 0.02 to 0.15 on the tiers with true labels.
228
  - English only.
229
  - Every engine reads the first 127 tokens of a message and sets `truncated` when it cuts.
230
  - It answers best on short, everyday messages. Questions that need two-step reasoning, such as natural language inference, are close to chance.
231
- - On a Cardputer the quick-start request takes 2.5 s on both cores. [Pocket Notes](https://github.com/therezor/pocket-notes) asks one choice question about a short note and takes about 1.8 s. A request needs about 2.2 KB of heap per token. The bare ESP32 example has 371 KB free, enough for about 150 tokens. An app with a display and Wi-Fi has less.
232
  - The Decision Index numbers are self-scored with the kit. The board's maintainers have not verified them.
233
  - The 13.8M build's 6.31 is not a clean out-of-sample number. It had two board runs, and one data change came after the first run. The 10.4M build's 5.77 came from a single run.
234
 
 
178
  td::answer(msg, qs, 2, a); // a[0].pick == 3, msg[a[1].start, a[1].end) == "7:30"
179
  ```
180
 
181
+ On the ESP32-S3, the PIE vector unit runs the 4-bit matmuls on both cores. On an M5Stack Cardputer the quick-start request takes 1.1 s. It has four questions and 67 tokens, and the answers match the table above. That timing comes from the ESP-IDF project in `esp32/examples/basic`. The Arduino sketch in `esp32/tinydecide/examples/Basic` builds, but it has not run on a device yet. Each folder's README has the details.
182
 
183
  ## Files
184
 
 
228
  - English only.
229
  - Every engine reads the first 127 tokens of a message and sets `truncated` when it cuts.
230
  - It answers best on short, everyday messages. Questions that need two-step reasoning, such as natural language inference, are close to chance.
231
+ - On a Cardputer the quick-start request takes 1.1 s on both cores. [Pocket Notes](https://github.com/therezor/pocket-notes) asks one choice question about a short note and takes about 1.8 s with its own, older kernels. A request needs about 2.2 KB of heap per token. The bare ESP32 example has 371 KB free, enough for about 150 tokens. An app with a display and Wi-Fi has less.
232
  - The Decision Index numbers are self-scored with the kit. The board's maintainers have not verified them.
233
  - The 13.8M build's 6.31 is not a clean out-of-sample number. It had two board runs, and one data change came after the first run. The 10.4M build's 5.77 came from a single run.
234
 
esp32/README.md CHANGED
@@ -17,7 +17,7 @@ the conformance test below runs it.
17
  | host build, 84 requests | same token ids, errors, picks and spans; largest probability difference about 0.02 |
18
  | ESP-IDF 5.5 build for ESP32-S3 with the model embedded (pioarduino 55.03.312) | compiles; the app image is 6.6 MB |
19
  | Arduino core 3.x build for ESP32-S3 with the model in a partition (same platform) | compiles; the app is 329 KB |
20
- | `examples/basic` on an M5Stack Cardputer (ESP32-S3, 8 MB flash, no PSRAM) | the four answers match the reference (restaurants 0.98 vs 0.97, 0.22, 0.52, "7:30"); 2.52 s per request; 371 KB heap free |
21
  | the Arduino sketch on a device | not run |
22
 
23
  The probabilities differ by up to about 0.02 because the PIE kernel needs activations rounded to
 
17
  | host build, 84 requests | same token ids, errors, picks and spans; largest probability difference about 0.02 |
18
  | ESP-IDF 5.5 build for ESP32-S3 with the model embedded (pioarduino 55.03.312) | compiles; the app image is 6.6 MB |
19
  | Arduino core 3.x build for ESP32-S3 with the model in a partition (same platform) | compiles; the app is 329 KB |
20
+ | `examples/basic` on an M5Stack Cardputer (ESP32-S3, 8 MB flash, no PSRAM) | the four answers match the reference (restaurants 0.98 vs 0.97, 0.22, 0.52, "7:30"); 1.06 s per request; 371 KB heap free |
21
  | the Arduino sketch on a device | not run |
22
 
23
  The probabilities differ by up to about 0.02 because the PIE kernel needs activations rounded to
esp32/host/conformance.mjs CHANGED
@@ -21,8 +21,9 @@ const exp = JSON.parse(readFileSync(conf + "expected.S768.json", "utf8"));
21
 
22
  const tmp = mkdtempSync(tmpdir() + "/td_host_"), bin = tmp + "/td_host";
23
  process.on("exit", () => rmSync(tmp, { recursive: true, force: true }));
24
- // TD_CXXFLAGS adds flags, e.g. TD_CXXFLAGS="-fsanitize=address,undefined -g"
25
- execFileSync("c++", ["-O2", "-std=c++17", "-Wall", "-Wextra", "-DTD_TEST", ...(process.env.TD_CXXFLAGS || "").split(" ").filter(Boolean), "-I" + here + "tinydecide/src",
 
26
  here + "host/td_host.cpp", here + "tinydecide/src/engine.cpp", "-o", bin], { stdio: "inherit" });
27
 
28
  const hex = (s) => (s === "" ? "-" : Buffer.from(s, "utf8").toString("hex"));
 
21
 
22
  const tmp = mkdtempSync(tmpdir() + "/td_host_"), bin = tmp + "/td_host";
23
  process.on("exit", () => rmSync(tmp, { recursive: true, force: true }));
24
+ // -ffp-contract=fast: fuse a*b+c as the device GCC does, so results do not depend on how the host
25
+ // compiler happens to contract each loop. TD_CXXFLAGS adds flags, e.g. TD_CXXFLAGS="-fsanitize=address,undefined -g"
26
+ execFileSync("c++", ["-O2", "-std=c++17", "-ffp-contract=fast", "-Wall", "-Wextra", "-DTD_TEST", ...(process.env.TD_CXXFLAGS || "").split(" ").filter(Boolean), "-I" + here + "tinydecide/src",
27
  here + "host/td_host.cpp", here + "tinydecide/src/engine.cpp", "-o", bin], { stdio: "inherit" });
28
 
29
  const hex = (s) => (s === "" ? "-" : Buffer.from(s, "utf8").toString("hex"));
esp32/tinydecide/src/dot_q4_pie.S CHANGED
@@ -71,4 +71,143 @@ dot_q4q8_pie:
71
  rfr a2, f0
72
  retw.n
73
  .size dot_q4q8_pie, . - dot_q4q8_pie
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  #endif // CONFIG_IDF_TARGET_ESP32S3
 
71
  rfr a2, f0
72
  retw.n
73
  .size dot_q4q8_pie, . - dot_q4q8_pie
74
+
75
+ // The same dot product for tiles of four tokens: each weight block is unpacked once and applied to
76
+ // all four, and the loop over tiles runs here rather than in C.
77
+ //
78
+ // void dot4_q4q8_pie(const uint8_t* nib, // a2: 16B-aligned nibble plane
79
+ // const uint8_t* scales, // a3: bf16 weight scales
80
+ // const int8_t* xq, // a4: token 0's int8 activations; 16B-aligned
81
+ // const float* xs, // a5: token 0's block scales
82
+ // int nb, // a6: number of 32-weight blocks
83
+ // int xqs, // a7: bytes from one token's xq to the next;
84
+ // // its xs are xqs / 32 floats further on
85
+ // float* out, // stack: 4 * ntile sums, token order
86
+ // int ntile); // stack: tiles of four tokens, >= 1
87
+ //
88
+ // Each token's sum is accumulated exactly as dot_q4q8_pie does it, so the results are identical.
89
+ //
90
+ // Frame: a1+0 tiles left, +4 nib, +8 scales, +12 nb, +16 xs stride (bytes), +20 xq of the tile,
91
+ // +24 xs of the tile, +28 xqs.
92
+
93
+ .text
94
+ .align 4
95
+ .global dot4_q4q8_pie
96
+ .type dot4_q4q8_pie, @function
97
+ dot4_q4q8_pie:
98
+ entry a1, 48
99
+ l32i a15, a1, 48 // out (7th argument, on the caller's stack)
100
+ l32i a8, a1, 52 // ntile (8th)
101
+ s32i a8, a1, 0
102
+ s32i a2, a1, 4
103
+ s32i a3, a1, 8
104
+ s32i a6, a1, 12
105
+ srai a8, a7, 3 // xs stride in bytes: xqs / 32 floats
106
+ s32i a8, a1, 16
107
+ s32i a4, a1, 20
108
+ s32i a5, a1, 24
109
+ s32i a7, a1, 28
110
+ movi a8, .Lconst
111
+ ee.vldbc.8 q7, a8 // q7 = 0x0F splat
112
+ addi a8, a8, 1
113
+ ee.vldbc.8 q6, a8 // q6 = 8 splat
114
+ ssai 4
115
+ .Ltile:
116
+ l32i a4, a1, 20 // this tile's token pointers
117
+ l32i a7, a1, 28
118
+ add a9, a4, a7
119
+ add a10, a9, a7
120
+ add a11, a10, a7
121
+ l32i a5, a1, 24
122
+ l32i a8, a1, 16
123
+ add a12, a5, a8
124
+ add a13, a12, a8
125
+ add a14, a13, a8
126
+ l32i a2, a1, 4 // back to the start of the weight row
127
+ l32i a3, a1, 8
128
+ l32i a6, a1, 12
129
+ movi a8, 0
130
+ wfr f0, a8
131
+ wfr f1, a8
132
+ wfr f2, a8
133
+ wfr f3, a8
134
+ // Per block: unpack the weights once, then token k's MACs overlap token k-1's scalar tail.
135
+ // Token k's sum is still acc_k += (float)isum_k * (wscale * xs_k[b]), as in dot_q4q8_pie.
136
+ loopnez a6, .Lloop4_end
137
+ ee.vld.128.ip q0, a2, 16 // 16 packed nibble bytes
138
+ l16ui a8, a3, 0 // bf16 weight scale bits
139
+ lsip f5, a5, 4 // xs[b] of tokens 0..3
140
+ lsip f6, a12, 4
141
+ lsip f7, a13, 4
142
+ lsip f8, a14, 4
143
+ ee.vsr.32 q1, q0
144
+ ee.andq q0, q0, q7 // low nibbles -> q[0..15]
145
+ slli a8, a8, 16
146
+ ee.andq q1, q1, q7 // high nibbles -> q[16..31]
147
+ wfr f4, a8 // wscale
148
+ ee.vsubs.s8 q0, q0, q6
149
+ ee.vsubs.s8 q1, q1, q6
150
+ mul.s f5, f4, f5 // wscale * xscale, per token
151
+ mul.s f6, f4, f6
152
+ mul.s f7, f4, f7
153
+ mul.s f8, f4, f8
154
+ addi a3, a3, 2
155
+ // token 0
156
+ ee.vld.128.ip q2, a4, 16
157
+ ee.vld.128.ip q3, a4, 16
158
+ ee.zero.accx
159
+ ee.vmulas.s8.accx q0, q2
160
+ ee.vld.128.ip q4, a9, 16 // token 1's activations
161
+ ee.vmulas.s8.accx q1, q3
162
+ ee.vld.128.ip q5, a9, 16
163
+ rur.accx_0 a7
164
+ // token 1
165
+ ee.zero.accx
166
+ ee.vmulas.s8.accx q0, q4
167
+ ee.vld.128.ip q2, a10, 16 // token 2's
168
+ ee.vmulas.s8.accx q1, q5
169
+ ee.vld.128.ip q3, a10, 16
170
+ float.s f9, a7, 0
171
+ rur.accx_0 a8
172
+ // token 2
173
+ ee.zero.accx
174
+ ee.vmulas.s8.accx q0, q2
175
+ ee.vld.128.ip q4, a11, 16 // token 3's
176
+ ee.vmulas.s8.accx q1, q3
177
+ ee.vld.128.ip q5, a11, 16
178
+ madd.s f0, f9, f5
179
+ float.s f10, a8, 0
180
+ rur.accx_0 a7
181
+ // token 3
182
+ ee.zero.accx
183
+ ee.vmulas.s8.accx q0, q4
184
+ ee.vmulas.s8.accx q1, q5
185
+ madd.s f1, f10, f6
186
+ float.s f11, a7, 0
187
+ rur.accx_0 a8
188
+ madd.s f2, f11, f7
189
+ float.s f12, a8, 0
190
+ madd.s f3, f12, f8
191
+ .Lloop4_end:
192
+ ssi f0, a15, 0
193
+ ssi f1, a15, 4
194
+ ssi f2, a15, 8
195
+ ssi f3, a15, 12
196
+ addi a15, a15, 16
197
+ l32i a7, a1, 28 // next tile: four tokens on
198
+ slli a7, a7, 2
199
+ l32i a8, a1, 20
200
+ add a8, a8, a7
201
+ s32i a8, a1, 20
202
+ l32i a7, a1, 16
203
+ slli a7, a7, 2
204
+ l32i a8, a1, 24
205
+ add a8, a8, a7
206
+ s32i a8, a1, 24
207
+ l32i a8, a1, 0
208
+ addi a8, a8, -1
209
+ s32i a8, a1, 0
210
+ bnez a8, .Ltile
211
+ retw.n
212
+ .size dot4_q4q8_pie, . - dot4_q4q8_pie
213
  #endif // CONFIG_IDF_TARGET_ESP32S3
esp32/tinydecide/src/engine.cpp CHANGED
@@ -2,9 +2,11 @@
2
  //
3
  // Memory plan ("streamed kernel"): one scratch area per call, ~2.2 KB per token. Each layer keeps
4
  // the fp32 residual x plus an int8 copy of the layer input; attention runs one head at a time with
5
- // W_o accumulated straight into x, and the FFN runs in 64-neuron chunks with fc2 accumulated into x.
6
- // Every weight row is read once per layer and applied to all tokens while it is hot in cache; rows
7
- // are split across both cores on dual-core chips.
 
 
8
  #include "tinydecide.h"
9
  #include "td_meta.h"
10
  #include "td_unicode.h"
@@ -276,14 +278,25 @@ static inline float dotq(const uint8_t* nib, const int8_t* xq, const uint8_t* sc
276
  #endif
277
  }
278
 
 
 
 
 
279
  // Symmetric int8 per 32-element block, one fp32 scale each (llama.cpp Q8_0).
 
 
 
 
280
  static void quant8(const float* x, int8_t* xq, float* xs, int n) {
 
 
 
281
  for (int b = 0; b < n / 32; b++) {
282
  float mx = 0.0f;
283
  for (int i = 0; i < 32; i++) { float a = fabsf(x[i]); if (a > mx) mx = a; }
284
  float s = mx / 127.0f, inv = s > 0 ? 1.0f / s : 0.0f;
285
  xs[b] = s;
286
- for (int i = 0; i < 32; i++) xq[i] = (int8_t)lrintf(x[i] * inv);
287
  x += 32; xq += 32;
288
  }
289
  }
@@ -308,7 +321,22 @@ static void mmRows(const MM& a, int ra, int rb) {
308
  const uint8_t* sc = g_m + a.w->sc + ((size_t)r * nbRow + a.blk0) * 2;
309
  const float b = a.bias ? a.bias[r] : 0.0f;
310
  float* o = a.out + (r - a.r0);
311
- for (int t = 0; t < a.T; t++) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  float v = dotq(nib, a.xq + (size_t)t * a.xqs, sc, a.xs + (size_t)t * a.xss, a.nbk) + b;
313
  if (a.acc) o[(size_t)t * a.os] += v; else o[(size_t)t * a.os] = v;
314
  }
@@ -415,6 +443,38 @@ static void mvI8(const tdm::I8& w, const float* v, float* y) {
415
  }
416
  }
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  // ============================================================================
419
  // Request layout (encodeRequest in tinydecide.js)
420
  // ============================================================================
@@ -516,15 +576,28 @@ static bool allocScratch(Scratch& m, int T) {
516
  return false;
517
  }
518
 
519
- // GELU over one FFN chunk for tokens [a, b), then int8 for the fc2 slice.
520
- struct GeluCtx { float* Hc; int8_t* hq; float* hs; };
 
521
  static void geluRange(void* ctx, int a, int b) {
522
  const GeluCtx& g = *(const GeluCtx*)ctx;
523
  const int DHd = tdm::DHEAD;
524
  for (int t = a; t < b; t++) {
525
  float* hrow = g.Hc + (size_t)t * DHd;
526
  for (int c = 0; c < DHd; c++) hrow[c] = gelu(hrow[c]);
527
- quant8(hrow, g.hq + (size_t)t * DHd, g.hs + (size_t)t * (DHd / 32), DHd);
 
 
 
 
 
 
 
 
 
 
 
 
528
  }
529
  }
530
 
@@ -551,8 +624,24 @@ static void attnRange(void* ctx, int a, int b) {
551
  const float* q = c.Qh + (size_t)i * DHd;
552
  float mx = -1e30f;
553
  int n = 0;
554
- for (const auto& rg : ranges)
555
- for (int j = rg[0]; j < rg[1]; j++) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  const float* kk = c.Kh + (size_t)j * DHd;
557
  float s = 0;
558
  for (int d = 0; d < DHd; d++) s += q[d] * kk[d];
@@ -560,17 +649,25 @@ static void attnRange(void* ctx, int a, int b) {
560
  sc[n++] = s;
561
  if (s > mx) mx = s;
562
  }
 
563
  float z = 0;
564
- for (int j = 0; j < n; j++) { sc[j] = expf(sc[j] - mx); z += sc[j]; }
565
  const float iz = 1.0f / z;
566
- for (int d = 0; d < DHd; d++) out[d] = 0;
567
- n = 0;
568
- for (const auto& rg : ranges)
569
- for (int j = rg[0]; j < rg[1]; j++) {
570
- const float p = sc[n++] * iz;
571
- const float* v = c.Vh + (size_t)j * DHd;
572
- for (int d = 0; d < DHd; d++) out[d] += p * v[d];
573
- }
 
 
 
 
 
 
 
574
  quant8(out, c.hq + (size_t)i * DHd, c.hs + (size_t)i * (DHd / 32), DHd);
575
  }
576
  }
@@ -615,7 +712,10 @@ static void encode(const Req& r, const Scratch& m) {
615
  const float scale = 1.0f / sqrtf((float)DHd);
616
  for (int l = 0; l < tdm::LAYERS; l++) {
617
  const tdm::Block& B = tdm::BLOCKS[l];
618
- for (int t = 0; t < T; t++) quant8(x + (size_t)t * D, xq + (size_t)t * D, xs + (size_t)t * (D / 32), D);
 
 
 
619
 
620
  // ---- attention, one head at a time; W_o slice accumulated into the residual
621
  for (int h = 0; h < tdm::HEADS; h++) {
@@ -627,18 +727,26 @@ static void encode(const Req& r, const Scratch& m) {
627
  parallel(attnRange, &ac, T);
628
  mm({&B.o, r0 / 32, DHd / 32, hq, DHd, hs, DHd / 32, T, x, D, h == 0 ? F(B.ob) : nullptr, true, 0}, D);
629
  }
630
- for (int t = 0; t < T; t++) layernorm(x + (size_t)t * D, D, F(B.ln1w), F(B.ln1b), tdm::LN_EPS);
 
631
 
632
- // ---- FFN in 64-neuron chunks; fc2 slice accumulated into the residual
633
- for (int t = 0; t < T; t++) quant8(x + (size_t)t * D, xq + (size_t)t * D, xs + (size_t)t * (D / 32), D);
 
 
634
  float* Hc = Qh;
635
- for (int c0 = 0; c0 < tdm::FFN; c0 += DHd) {
636
- mm({&B.fc, 0, D / 32, xq, D, xs, D / 32, T, Hc, DHd, F(B.fcb), false, c0}, c0 + DHd);
637
- GeluCtx gc{Hc, hq, hs};
638
- parallel(geluRange, &gc, T);
639
- mm({&B.fc2, c0 / 32, DHd / 32, hq, DHd, hs, DHd / 32, T, x, D, c0 == 0 ? F(B.fc2b) : nullptr, true, 0}, D);
 
 
 
 
640
  }
641
- for (int t = 0; t < T; t++) layernorm(x + (size_t)t * D, D, F(B.ln2w), F(B.ln2b), tdm::LN_EPS);
 
642
  }
643
  }
644
 
@@ -688,16 +796,16 @@ static float protoTerm(const Protos& p, const float* qvec, const float* c, int i
688
  static void headChoice(const Req& r, const float* x, int k, const Question& q, Answer& out) {
689
  const int sel = q.type == SCORE ? 1 : 0, n = q.n_options, DH = tdm::DH;
690
  const float Tt = tdm::TEMP[sel ? 2 : 0], s = expf(F(tdm::H_SCALE)[sel]);
691
- float h[tdm::D], qa[tdm::DH], ov[tdm::DH], L[MAX_OPTIONS];
692
  headRow(x, r.ans[k], h);
693
  mvI8(tdm::H_A[sel], h, qa);
694
  mvI8(tdm::H_P, h, out.qvec);
 
695
  float cbuf[QDIM];
696
  const float* c = q.protos ? centreOf(*q.protos, n, cbuf) : nullptr;
697
  for (int i = 0; i < n; i++) {
698
  headRow(x, r.opt[k][i], h);
699
- mvI8(tdm::H_O[sel], h, ov);
700
- out.z0[i] = s * dotf(qa, ov, DH) / sqrtf((float)DH) / Tt;
701
  L[i] = out.z0[i] + (q.bias ? q.bias[i] : 0.0f);
702
  if (q.protos) L[i] += protoTerm(*q.protos, out.qvec, c, i) / Tt;
703
  }
@@ -742,18 +850,18 @@ static void headNoul(const Req& r, const float* x, int k, const Question& q, Ans
742
  static void headSpan(const Req& r, const float* x, int k, const char* state, Answer& out) {
743
  const int S = r.S - 1, DH = tdm::DH; // state text tokens at rows 1..S
744
  const float Tt = tdm::TEMP[3], isq = 1.0f / sqrtf((float)DH);
745
- float h[tdm::D], hAns[tdm::D], qS[tdm::DH], kk[tdm::DH], qe[tdm::DH];
746
  headRow(x, r.ans[k], hAns);
747
  mvI8(tdm::H_P, hAns, out.qvec);
748
  mvI8(tdm::H_SQ, hAns, qS);
 
749
  // start logits: null, then each state token; log-softmax at temperature Tt
750
  const float zNull = dotf(F(tdm::H_SNULL_W), hAns, tdm::D) + F(tdm::H_SNULL_B)[0];
751
  static float zs[tdm::TS_MAX];
752
  float mx = zNull;
753
  for (int t = 0; t < S; t++) {
754
  headRow(x, 1 + t, h);
755
- mvI8(tdm::H_SK, h, kk);
756
- zs[t] = dotf(qS, kk, DH) * isq;
757
  if (zs[t] > mx) mx = zs[t];
758
  }
759
  float Z = expf((zNull - mx) / Tt);
@@ -774,12 +882,12 @@ static void headSpan(const Req& r, const float* x, int k, const char* state, Ans
774
  headRow(x, 1 + best, h);
775
  mvI8(tdm::H_ES, h, es);
776
  for (int i = 0; i < DH; i++) qe[i] += es[i];
 
777
  const int e1 = best + tdm::SPAN_MAX < S ? best + tdm::SPAN_MAX : S;
778
  float en[tdm::SPAN_MAX], emx = -1e30f;
779
  for (int t = best; t < e1; t++) {
780
  headRow(x, 1 + t, h);
781
- mvI8(tdm::H_EK, h, kk);
782
- en[t - best] = dotf(qe, kk, DH) * isq;
783
  if (en[t - best] > emx) emx = en[t - best];
784
  }
785
  float EZ = 0;
 
2
  //
3
  // Memory plan ("streamed kernel"): one scratch area per call, ~2.2 KB per token. Each layer keeps
4
  // the fp32 residual x plus an int8 copy of the layer input; attention runs one head at a time with
5
+ // W_o accumulated straight into x, and the FFN runs fc in 64-neuron chunks into 256-neuron int8
6
+ // groups (in the then idle K and V buffers), each group's fc2 slice accumulated into x. Every
7
+ // weight row is read once per layer and applied to all tokens while it is hot in cache, four
8
+ // tokens per unpacked weight block on the ESP32-S3; rows are split across both cores on dual-core
9
+ // chips.
10
  #include "tinydecide.h"
11
  #include "td_meta.h"
12
  #include "td_unicode.h"
 
278
  #endif
279
  }
280
 
281
+ // lrintf for |v| < 2^22 under round-to-nearest-even, without the libm call: adding 1.5 * 2^23
282
+ // leaves no fraction bits, so the FPU rounds v to an integer exactly as lrintf does.
283
+ static inline int roundEven(float v) { return (int)((v + 12582912.0f) - 12582912.0f); }
284
+
285
  // Symmetric int8 per 32-element block, one fp32 scale each (llama.cpp Q8_0).
286
+ // No a*b+c fusion here: x * inv must be rounded to float before roundEven, as lrintf would see it.
287
+ #if defined(__GNUC__) && !defined(__clang__)
288
+ __attribute__((optimize("fp-contract=off")))
289
+ #endif
290
  static void quant8(const float* x, int8_t* xq, float* xs, int n) {
291
+ #if defined(__clang__)
292
+ #pragma clang fp contract(off)
293
+ #endif
294
  for (int b = 0; b < n / 32; b++) {
295
  float mx = 0.0f;
296
  for (int i = 0; i < 32; i++) { float a = fabsf(x[i]); if (a > mx) mx = a; }
297
  float s = mx / 127.0f, inv = s > 0 ? 1.0f / s : 0.0f;
298
  xs[b] = s;
299
+ for (int i = 0; i < 32; i++) xq[i] = (int8_t)roundEven(x[i] * inv);
300
  x += 32; xq += 32;
301
  }
302
  }
 
321
  const uint8_t* sc = g_m + a.w->sc + ((size_t)r * nbRow + a.blk0) * 2;
322
  const float b = a.bias ? a.bias[r] : 0.0f;
323
  float* o = a.out + (r - a.r0);
324
+ int t = 0;
325
+ #if defined(TD_PIE)
326
+ // tiles of four tokens, up to eight tiles per call: each weight block is unpacked once per
327
+ // tile (xss == xqs / 32)
328
+ float vt[32];
329
+ while (t + 4 <= a.T) {
330
+ const int nt = (a.T - t) / 4 < 8 ? (a.T - t) / 4 : 8;
331
+ dot4_q4q8_pie(nib, sc, a.xq + (size_t)t * a.xqs, a.xs + (size_t)t * a.xss, a.nbk, a.xqs, vt, nt);
332
+ for (int j = 0; j < 4 * nt; j++) {
333
+ const float v = vt[j] + b;
334
+ if (a.acc) o[(size_t)(t + j) * a.os] += v; else o[(size_t)(t + j) * a.os] = v;
335
+ }
336
+ t += 4 * nt;
337
+ }
338
+ #endif
339
+ for (; t < a.T; t++) {
340
  float v = dotq(nib, a.xq + (size_t)t * a.xqs, sc, a.xs + (size_t)t * a.xss, a.nbk) + b;
341
  if (a.acc) o[(size_t)t * a.os] += v; else o[(size_t)t * a.os] = v;
342
  }
 
443
  }
444
  }
445
 
446
+ // u = W^T v for an int8 head matrix: q . (W h) == u . h, so a head that scores many tokens against
447
+ // one query reads W once instead of once per token. u has w.cols entries.
448
+ static void mtvI8(const tdm::I8& w, const float* v, float* u) {
449
+ const int8_t* q = (const int8_t*)(g_m + w.q);
450
+ const float* s = F(w.sc);
451
+ for (int c = 0; c < w.cols; c++) u[c] = 0;
452
+ for (int r = 0; r < w.rows; r++) {
453
+ const float k = v[r] * s[r];
454
+ const int8_t* row = q + (size_t)r * w.cols;
455
+ for (int c = 0; c < w.cols; c++) u[c] += k * (float)row[c];
456
+ }
457
+ }
458
+
459
+ // exp(x) for the attention softmax, x <= 0: Cephes expf (within about 1 ulp of expf) at a tenth of
460
+ // newlib's cost. Returns 0 below -87, where expf is denormal.
461
+ static inline float expNeg(float x) {
462
+ if (x < -87.0f) return 0.0f;
463
+ const float n = (x * 1.44269504089f + 12582912.0f) - 12582912.0f; // round(x / ln 2)
464
+ const float r = (x - n * 0.693359375f) - n * -2.12194440e-4f;
465
+ float p = 1.9875691500e-4f;
466
+ p = p * r + 1.3981999507e-3f;
467
+ p = p * r + 8.3334519073e-3f;
468
+ p = p * r + 4.1665795894e-2f;
469
+ p = p * r + 1.6666665459e-1f;
470
+ p = p * r + 5.0000001201e-1f;
471
+ p = p * r * r + r + 1.0f;
472
+ const uint32_t bits = (uint32_t)((int)n + 127) << 23;
473
+ float sc;
474
+ memcpy(&sc, &bits, 4);
475
+ return p * sc;
476
+ }
477
+
478
  // ============================================================================
479
  // Request layout (encodeRequest in tinydecide.js)
480
  // ============================================================================
 
576
  return false;
577
  }
578
 
579
+ // GELU over one 64-neuron FFN chunk for tokens [a, b), then int8 into columns of hq (row stride
580
+ // qs bytes, scales qs / 32 floats) for fc2.
581
+ struct GeluCtx { float* Hc; int8_t* hq; float* hs; int qs; };
582
  static void geluRange(void* ctx, int a, int b) {
583
  const GeluCtx& g = *(const GeluCtx*)ctx;
584
  const int DHd = tdm::DHEAD;
585
  for (int t = a; t < b; t++) {
586
  float* hrow = g.Hc + (size_t)t * DHd;
587
  for (int c = 0; c < DHd; c++) hrow[c] = gelu(hrow[c]);
588
+ quant8(hrow, g.hq + (size_t)t * g.qs, g.hs + (size_t)t * (g.qs / 32), DHd);
589
+ }
590
+ }
591
+
592
+ // Optional LayerNorm (w != nullptr), then int8 of x for the next matmuls, for tokens [a, b).
593
+ struct NormCtx { float* x; int8_t* xq; float* xs; const float* w; const float* b; };
594
+ static void normRange(void* ctx, int a, int b) {
595
+ const NormCtx& n = *(const NormCtx*)ctx;
596
+ const int D = tdm::D;
597
+ for (int t = a; t < b; t++) {
598
+ float* row = n.x + (size_t)t * D;
599
+ if (n.w) layernorm(row, D, n.w, n.b, tdm::LN_EPS);
600
+ quant8(row, n.xq + (size_t)t * D, n.xs + (size_t)t * (D / 32), D);
601
  }
602
  }
603
 
 
624
  const float* q = c.Qh + (size_t)i * DHd;
625
  float mx = -1e30f;
626
  int n = 0;
627
+ for (const auto& rg : ranges) {
628
+ int j = rg[0];
629
+ // four keys at a time: four independent sums, each in the order of the one-key loop
630
+ for (; j + 4 <= rg[1]; j += 4) {
631
+ const float* k0 = c.Kh + (size_t)j * DHd;
632
+ const float *k1 = k0 + DHd, *k2 = k1 + DHd, *k3 = k2 + DHd;
633
+ float s0 = 0, s1 = 0, s2 = 0, s3 = 0;
634
+ for (int d = 0; d < DHd; d++) {
635
+ const float qd = q[d];
636
+ s0 += qd * k0[d]; s1 += qd * k1[d]; s2 += qd * k2[d]; s3 += qd * k3[d];
637
+ }
638
+ for (float s : {s0, s1, s2, s3}) {
639
+ s *= c.scale;
640
+ sc[n++] = s;
641
+ if (s > mx) mx = s;
642
+ }
643
+ }
644
+ for (; j < rg[1]; j++) {
645
  const float* kk = c.Kh + (size_t)j * DHd;
646
  float s = 0;
647
  for (int d = 0; d < DHd; d++) s += q[d] * kk[d];
 
649
  sc[n++] = s;
650
  if (s > mx) mx = s;
651
  }
652
+ }
653
  float z = 0;
654
+ for (int j = 0; j < n; j++) { sc[j] = expNeg(sc[j] - mx); z += sc[j]; }
655
  const float iz = 1.0f / z;
656
+ for (int j = 0; j < n; j++) sc[j] *= iz;
657
+ // out = sum_j p_j v_j, eight dimensions at a time so the sums stay in registers
658
+ for (int d0 = 0; d0 < DHd; d0 += 8) {
659
+ float o0 = 0, o1 = 0, o2 = 0, o3 = 0, o4 = 0, o5 = 0, o6 = 0, o7 = 0;
660
+ n = 0;
661
+ for (const auto& rg : ranges)
662
+ for (int j = rg[0]; j < rg[1]; j++) {
663
+ const float p = sc[n++];
664
+ const float* v = c.Vh + (size_t)j * DHd + d0;
665
+ o0 += p * v[0]; o1 += p * v[1]; o2 += p * v[2]; o3 += p * v[3];
666
+ o4 += p * v[4]; o5 += p * v[5]; o6 += p * v[6]; o7 += p * v[7];
667
+ }
668
+ out[d0] = o0; out[d0 + 1] = o1; out[d0 + 2] = o2; out[d0 + 3] = o3;
669
+ out[d0 + 4] = o4; out[d0 + 5] = o5; out[d0 + 6] = o6; out[d0 + 7] = o7;
670
+ }
671
  quant8(out, c.hq + (size_t)i * DHd, c.hs + (size_t)i * (DHd / 32), DHd);
672
  }
673
  }
 
712
  const float scale = 1.0f / sqrtf((float)DHd);
713
  for (int l = 0; l < tdm::LAYERS; l++) {
714
  const tdm::Block& B = tdm::BLOCKS[l];
715
+ if (l == 0) {
716
+ NormCtx nc{x, xq, xs, nullptr, nullptr};
717
+ parallel(normRange, &nc, T);
718
+ }
719
 
720
  // ---- attention, one head at a time; W_o slice accumulated into the residual
721
  for (int h = 0; h < tdm::HEADS; h++) {
 
727
  parallel(attnRange, &ac, T);
728
  mm({&B.o, r0 / 32, DHd / 32, hq, DHd, hs, DHd / 32, T, x, D, h == 0 ? F(B.ob) : nullptr, true, 0}, D);
729
  }
730
+ NormCtx n1{x, xq, xs, F(B.ln1w), F(B.ln1b)};
731
+ parallel(normRange, &n1, T);
732
 
733
+ // ---- FFN: fc in 64-neuron chunks through GELU into int8 groups of G neurons, held in the
734
+ // K and V buffers (free until the next layer); each group's fc2 slice is accumulated into x.
735
+ constexpr int G = 256;
736
+ static_assert(tdm::FFN % G == 0 && G <= tdm::DHEAD * 4, "an FFN group must fit the K buffer");
737
  float* Hc = Qh;
738
+ int8_t* gq = (int8_t*)Kh; // [T, G] int8
739
+ float* gs = Vh; // [T, G / 32]
740
+ for (int g0 = 0; g0 < tdm::FFN; g0 += G) {
741
+ for (int c0 = g0; c0 < g0 + G; c0 += DHd) {
742
+ mm({&B.fc, 0, D / 32, xq, D, xs, D / 32, T, Hc, DHd, F(B.fcb), false, c0}, c0 + DHd);
743
+ GeluCtx gc{Hc, gq + (c0 - g0), gs + (c0 - g0) / 32, G};
744
+ parallel(geluRange, &gc, T);
745
+ }
746
+ mm({&B.fc2, g0 / 32, G / 32, gq, G, gs, G / 32, T, x, D, g0 == 0 ? F(B.fc2b) : nullptr, true, 0}, D);
747
  }
748
+ NormCtx n2{x, xq, xs, F(B.ln2w), F(B.ln2b)}; // the int8 copy is the next layer's input
749
+ parallel(normRange, &n2, T);
750
  }
751
  }
752
 
 
796
  static void headChoice(const Req& r, const float* x, int k, const Question& q, Answer& out) {
797
  const int sel = q.type == SCORE ? 1 : 0, n = q.n_options, DH = tdm::DH;
798
  const float Tt = tdm::TEMP[sel ? 2 : 0], s = expf(F(tdm::H_SCALE)[sel]);
799
+ float h[tdm::D], qa[tdm::DH], u[tdm::D], L[MAX_OPTIONS];
800
  headRow(x, r.ans[k], h);
801
  mvI8(tdm::H_A[sel], h, qa);
802
  mvI8(tdm::H_P, h, out.qvec);
803
+ mtvI8(tdm::H_O[sel], qa, u); // qa . (H_O h) == u . h
804
  float cbuf[QDIM];
805
  const float* c = q.protos ? centreOf(*q.protos, n, cbuf) : nullptr;
806
  for (int i = 0; i < n; i++) {
807
  headRow(x, r.opt[k][i], h);
808
+ out.z0[i] = s * dotf(u, h, tdm::D) / sqrtf((float)DH) / Tt;
 
809
  L[i] = out.z0[i] + (q.bias ? q.bias[i] : 0.0f);
810
  if (q.protos) L[i] += protoTerm(*q.protos, out.qvec, c, i) / Tt;
811
  }
 
850
  static void headSpan(const Req& r, const float* x, int k, const char* state, Answer& out) {
851
  const int S = r.S - 1, DH = tdm::DH; // state text tokens at rows 1..S
852
  const float Tt = tdm::TEMP[3], isq = 1.0f / sqrtf((float)DH);
853
+ float h[tdm::D], hAns[tdm::D], qS[tdm::DH], qe[tdm::DH], u[tdm::D];
854
  headRow(x, r.ans[k], hAns);
855
  mvI8(tdm::H_P, hAns, out.qvec);
856
  mvI8(tdm::H_SQ, hAns, qS);
857
+ mtvI8(tdm::H_SK, qS, u); // qS . (H_SK h) == u . h
858
  // start logits: null, then each state token; log-softmax at temperature Tt
859
  const float zNull = dotf(F(tdm::H_SNULL_W), hAns, tdm::D) + F(tdm::H_SNULL_B)[0];
860
  static float zs[tdm::TS_MAX];
861
  float mx = zNull;
862
  for (int t = 0; t < S; t++) {
863
  headRow(x, 1 + t, h);
864
+ zs[t] = dotf(u, h, tdm::D) * isq;
 
865
  if (zs[t] > mx) mx = zs[t];
866
  }
867
  float Z = expf((zNull - mx) / Tt);
 
882
  headRow(x, 1 + best, h);
883
  mvI8(tdm::H_ES, h, es);
884
  for (int i = 0; i < DH; i++) qe[i] += es[i];
885
+ mtvI8(tdm::H_EK, qe, u); // qe . (H_EK h) == u . h
886
  const int e1 = best + tdm::SPAN_MAX < S ? best + tdm::SPAN_MAX : S;
887
  float en[tdm::SPAN_MAX], emx = -1e30f;
888
  for (int t = best; t < e1; t++) {
889
  headRow(x, 1 + t, h);
890
+ en[t - best] = dotf(u, h, tdm::D) * isq;
 
891
  if (en[t - best] > emx) emx = en[t - best];
892
  }
893
  float EZ = 0;
esp32/tinydecide/src/model_data.S CHANGED
@@ -1,10 +1,12 @@
1
  // Embeds model.bin and vocab.bin in flash (.rodata, MMU-mapped) when the ESP-IDF component is built
2
  // with CONFIG_TINYDECIDE_EMBED_MODEL (CMakeLists.txt defines TINYDECIDE_EMBED and the include path).
3
- // The PIE kernel loads 128-bit words, so the model starts on a 16-byte boundary; all Q4 offsets
4
- // inside it are multiples of 16 (checked by tools/pack.mjs).
 
 
5
  #if defined(TINYDECIDE_EMBED)
6
  .section .rodata.td_model, "a"
7
- .balign 16
8
  .global td_model_bin
9
  td_model_bin:
10
  .incbin "model.bin"
 
1
  // Embeds model.bin and vocab.bin in flash (.rodata, MMU-mapped) when the ESP-IDF component is built
2
  // with CONFIG_TINYDECIDE_EMBED_MODEL (CMakeLists.txt defines TINYDECIDE_EMBED and the include path).
3
+ // The PIE kernel loads 128-bit words, so the model must start on a 16-byte boundary; all Q4 offsets
4
+ // inside it are multiples of 16 (checked by tools/pack.mjs). It starts on a 64-byte boundary so the
5
+ // weight rows, at 64-byte offsets in model.bin, line up with the flash cache lines: unaligned, each
6
+ // row touches one more line and the matmuls run up to ~10% slower.
7
  #if defined(TINYDECIDE_EMBED)
8
  .section .rodata.td_model, "a"
9
+ .balign 64
10
  .global td_model_bin
11
  td_model_bin:
12
  .incbin "model.bin"
esp32/tinydecide/src/tinydecide.h CHANGED
@@ -117,4 +117,7 @@ int requestTokens(const Question* qs, int nq, int state_tokens);
117
  // Q4_0 row-planar dot product against Q8_0 activations (dot_q4_pie.S, ESP32-S3 only).
118
  extern "C" float dot_q4q8_pie(const uint8_t* nib, const int8_t* xq, const uint8_t* scales,
119
  const float* xs, int nb);
 
 
 
120
  #endif
 
117
  // Q4_0 row-planar dot product against Q8_0 activations (dot_q4_pie.S, ESP32-S3 only).
118
  extern "C" float dot_q4q8_pie(const uint8_t* nib, const int8_t* xq, const uint8_t* scales,
119
  const float* xs, int nb);
120
+ // The same for ntile * 4 tokens xqs bytes apart (their scales xqs / 32 floats apart); out[4 * ntile].
121
+ extern "C" void dot4_q4q8_pie(const uint8_t* nib, const uint8_t* scales, const int8_t* xq, const float* xs,
122
+ int nb, int xqs, float* out, int ntile);
123
  #endif