summaryrefslogtreecommitdiffstats
path: root/_log/lex.md
blob: adebd49de15e1600f3c331fd7b1f9c41823383a4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
---
title: 'Lex: LLM dictionary on RPi5'
date: 2026-09-19
layout: post
---

Year of our Lord 2026. LLMs are everywhere. 

Built Lex, a context-aware dictionary.

<table style="width: 100%; border-collapse: collapse;">
  <tr>
    <td>
      <img src="rpi5.jpg" alt="RPi5" style="max-height: 450px; width: auto; max-width: 100%;">
    </td>
    <td>
      <img src="lex-email.png" alt="Email" style="max-height: 450px; width: auto; max-width: 100%;">
    </td>
  </tr>
</table>

Components: lex-cgi, lex-llm, lex-mailer. User emails word and context.
lex-cgi receives webhook, runs lex-llm, writes definition to file. lex-mailer
emails past entries daily, timed for spaced repetition.

Tried local inference using llama.cpp + Llama 3.1 8B (GGUF) first:

```
llama_backend_init();
mparams = llama_model_default_params();
mparams.n_gpu_layers = 0;   /* force all layers onto CPU */
model = llama_model_load_from_file(MODEL_PATH, mparams);

cparams = llama_context_default_params();
cparams.n_ctx = N_CTX; /* 1024 */
llama_init_from_model(model, cparams);
```

Generation loop synthesizes definition:

```
for (i = 0; i < MAX_TOKENS; i++) {
    new_token_id = llama_sampler_sample(smpl, ctx, -1);
    llama_sampler_accept(smpl, new_token_id);
    if (llama_vocab_is_eog(vocab, new_token_id))
        break;

    char buf[128];
    n = llama_token_to_piece(vocab, new_token_id, buf, sizeof(buf), 0, false);
    if (n > 0) {
        fwrite(buf, 1, (size_t)n, out);
        fflush(out);
    }
        
    batch = llama_batch_get_one(&new_token_id, 1);
    if (llama_decode(ctx, batch) != 0)
        break;
}
```

Build what's needed:

```
cmake -S $(LLAMA_SRC_DIR) -B $(LLAMA_BUILD) \
    -DBUILD_SHARED_LIBS=OFF \
    -DGGML_OPENMP=OFF \
    -DGGML_VULKAN=OFF \
    -DLLAMA_BUILD_SERVER=OFF \
    -DLLAMA_BUILD_UI=OFF
    # + misc size-reduction flags, omitted

cmake --build $(LLAMA_BUILD) --config Release -j$$(sysctl -n hw.ncpu)
```

Functional, but requires a $40/mo VPS (8 GB RAM). Benchmarked alternative
Q4_K_M models on T490 (i7-10510U, OpenBSD 7.9, CPU only):

<pre class="pre-no-style">
+--------------+--------+-------------+---------------------+----------+
| Model        | Tokens | Tok/s (μ±σ) |  Time s (Med/Range) | RAM (MB) |
+--------------+--------+-------------+---------------------+----------+
| Qwen 2.5 3B  |  114.0 | 5.22 ± 0.31 | 21.79 (19.39-25.20) | 3,235.22 |
| Qwen 2.5 7B  |  104.5 | 2.09 ± 0.07 | 49.27 (45.50-52.98) | 7,335.18 |
| Phi 3.5 3.8B |  128.2 | 2.06 ± 0.10 | 71.87 (48.67-72.37) | 3,922.55 |
| LLaMA 3.1 8B |  115.5 | 2.01 ± 0.08 | 57.10 (53.95-66.09) | 7,873.52 |
| Mistral 7B   |  106.6 | 1.67 ± 0.13 | 64.25 (57.85-66.86) | 7,535.61 |
+--------------+--------+-------------+---------------------+----------+
</pre>

Larger models caught nuances smaller ones missed, but definitions weren't
consistently better. Gemma 2 9B generated empty responses due to chat template
bug; fixed, but 7.8 GB RAM usage didn't justify re-running benchmarks. Smaller
models handled shorter prompts better. Qwen 2.5 struggled with formality level,
but produced tighter, faster definitions at 3.2 GB RAM.

Qwen 2.5 3B halves the VPS cost—still wasteful for the occasional prompt.
Decoupling lex-llm from lex-cgi lets lex-llm run on a 4 GB Raspberry Pi 5; rest
could run on a $5/mo 1 GB VPS.

Refactored lex-llm to communicate over Unix domain sockets. lex-cgi now spools
incoming emails to disk. A shell daemon periodically fetches them via SSH, runs
lex-llm, and syncs output back to VPS.

```
while true; do
    # fork to avoid resource leakage on unexpected errors
    (
        prompt=$(ssh -F "${SSH_CONFIG}" -n "${SSH_HOST}" \
            "cat '${SPOOL_DIR}/${SPOOL_FILE}'")

        response=$(print -r -- "${prompt}" \
            | nc -w 300 -U "${SOCK_PATH}" \
            | awk '# formatting fix, omitted...'
        )

        # validate output, write to tmp_file, 
        # atomically mv into place, rm spool file—omitted 

        print -r -- "${response}" \
            | fold -s -w 72 \
            | ssh -F "${SSH_CONFIG}" "${SSH_HOST}" "${remote_cmd}"
    )
done
```

Performance: lex-llm was 8x slower (190 s/req) on the RPi5. Benchmarked
version loaded the model every request. Loading it once at init reduced
execution time to 55 seconds.

Forcing the model to RAM at init via LLAMA_LOAD_MODE_MLOCK would have been
desirable, but RPi5 lacks RAM to grant the mlock. Between LLAMA_LOAD_MODE_NONE
and LLAMA_LOAD_MODE_MMAP, former yielded better steady-state performance (1.3
GB less MAX RSS, 1-2 seconds faster).

RPi5's slower Cortex-A76 processor was still 2x behind benchmarks.
llama_decode() was evaluating system + user prompt every request. System prompt
doesn't change between requests; cached token offsets and decoded only the part
that changed:

```
/* drop everything after common prefix. */
llama_memory_seq_rm(llama_get_memory(llm->ctx), 0, n_common, -1);

/* decode the diff */
if (n_common < n_new_tokens) {
    batch = llama_batch_get_one(new_tokens + n_common, 
        n_new_tokens - n_common); 
    if (llama_decode(llm->ctx, batch) != 0)
        return;

    memcpy(llm->cached_tokens + n_common, 
        new_tokens + n_common, 
        (size_t)(n_new_tokens - n_common) * sizeof(llama_token));

    llm->n_cached_tokens = n_new_tokens;
}
```

Prefix-caching reduced execution time to 35 seconds—acceptable for an
email-driven async dictionary. 

Note on measurements: 3.4 A supply likely caused CPU throttling on the RPi5
during measurements. Final version uses the recommended 5 A supply.

Fidelity: Didn't notice qualitative differences between T490 and RPi5 output
under normal operation. They exist, however. System prompt fragment duplicated
the SYNONYMS instruction inside GENERAL TONE:

```
One terse sentence of describing general tone. One terse sentence of 
standard conversational synonyms.
```

RPi5 echoed the second stray sentence verbatim; T490 did not:

```
GENERAL TONE:
The phrase suggests the impending end of life with somber and formal
language.
One terse sentence of standard conversational synonyms: Buried or
surrounded by darkness.
```

Possible artifact of non-associative floating-point arithmetic (x86 SIMD vs ARM
NEON instruction ordering). Dropping duplicate sentence from prompt reconciled
the outputs.

Storage: lex-llm binary is 12.9 MB. GGUF takes up 2 GB. Standard OpenBSD
install recommends 8 GB. SD card I have is 4 GB—3.5 GB usable.

Flashed OpenBSD onto SD card; tossed man, game, and X file sets. Kept comp set
to build lex-llm. Disabled boot-time library re-linking and purged
/usr/share/relink to reclaim a further 450 MB. OS + lex-llm + model now fits:

```
Filesystem     Size    Used   Avail Capacity  Mounted on
/dev/sd0a      3.5G    2.8G    477M    86%    /
mfs:76650     61.9M    5.0K   58.8M     1%    /tmp
mfs:29597     15.4M   87.0K   14.6M     1%    /var/run
mfs:35201     30.9M    113K   29.3M     1%    /var/log
```

Installation went smoothly, except the RPi5 wouldn't boot without a serial
adapter plugged into the JST port. RX pin floated with no internal pull-up
enabled. Resolved by adding an external 10 kΩ pull-up (RX to 3.3 V).


Worked well for two weeks. Then the file system corrupted twice within a week.
Disk had 400M+ free space; badblocks revealed no bad sectors. fsck_ffs reported
a partially truncated inode indicating an interrupted write; momentary link
glitch perhaps—no way to know now.

Relocated /dev (sshd pty allocation) to mfs. Moved cron's log to /var/log and
symlinked ntpd.drift to /var/run/ntpd.drift. Assigned a static IP and disabled
most daemons (dhcpleased, resolvd, slaacd). / is now read-only:

```
/dev/sd0a on / type ffs (local, noatime, wxallowed, read-only)
mfs:69255 on /dev type mfs (asynchronous, local, nosuid, size=32768 512-blocks)
mfs:75124 on /tmp type mfs (asynchronous, local, nodev, nosuid, size=131072 512-blocks)
mfs:15499 on /var/log type mfs (asynchronous, local, nodev, nosuid, size=65536 512-blocks)
mfs:14160 on /var/run type mfs (asynchronous, local, nodev, nosuid, size=32768 512-blocks)
```

Security: PGP keys authenticate requests. lex-cgi (chrooted), lex-llm,
lex-mailer employ pledge and unveil:

```
my $pid = fork();
if ($pid == 0) {
    pledge(qw(stdio proc exec));
    # sendmail
}

# unveil rules...
unveil($dict_dir, 'r');
unveil(); # unveil lock

# main process drops 'exec'
pledge(qw(stdio rpath wpath cpath proc));
```

Packet filter blocks network scans, access to other hosts:

```
set skip on lo
block all

block out on $net_if to $home_net
pass out on $net_if proto { tcp, udp } to $router port domain
pass out on $net_if inet proto tcp from ($net_if) to $vps port ssh
pass out on $net_if proto udp to ! $home_net port ntp
pass out on $net_if proto tcp to ! $home_net port https
pass in on $net_if inet proto tcp from $home_net to ($net_if) port ssh
```

End of line.

Source: <a
href="https://git.asciimx.com/lex/tree/?id=b904341947bfeac99fe91d3c9ace760f7da312fd"
class="external" target="_blank" rel="noopener noreferrer">b904341</a>