---
README.md | 44 +++++++++++++++++++-------------------------
1 file changed, 19 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index 131ff66..389aec2 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -28,7 +28,7 @@ brew install krishhgg/tap/sys1rust
sys1rust serve
```
-The first `serve` downloads the typed-decisions model (846 MB) into the Hugging Face cache, then listens on 127.0.0.1:8000. `sys1rust pull [model]` downloads a model ahead of time and `sys1rust models` lists what is downloaded. To run the server in the background now and at every login:
+The first `serve` downloads the typed-decisions model (846 MB) into the Hugging Face cache, then prints `listening on http://127.0.0.1:8000` when it's ready. `sys1rust pull [model]` downloads a model ahead of time and `sys1rust models` lists what is downloaded. To run it in the background now and at every login, first stop the foreground server with Ctrl-C to free port 8000, then run:
```sh
brew services start sys1rust
@@ -42,17 +42,11 @@ Without Homebrew, download a tarball from [Releases](https://github.com/krishhgg
xattr -dr com.apple.quarantine sys1rust-*
```
-Then run `bin/sys1rust serve` from the unpacked folder. The binary loads MLX from the `lib/` folder next to `bin/`, so keep the folder together.
+Then run `bin/sys1rust serve` from the unpacked folder. The binary loads MLX from the `lib/` folder next to `bin/`, so keep the folder together. To run it as `sys1rust`, add that `bin/` to your `PATH` or symlink `bin/sys1rust` into a directory on it. A symlink still finds `lib/`.
## Try it
-Start the server, unless `brew services` already runs it:
-
-```sh
-sys1rust serve
-```
-
-It prints `listening on http://127.0.0.1:8000` when it's ready. In another terminal:
+With the server from [Install](#install) running, in the foreground or under `brew services`, send it a request from another terminal:
```sh
curl -s localhost:8000/v1/systemone -H 'content-type: application/json' -d '{
@@ -68,12 +62,12 @@ curl -s localhost:8000/v1/systemone -H 'content-type: application/json' -d '{
```
-
+
On the base M5, inference for this request took 13.0 to 13.3 ms over 18 runs, 6 in each of 3 fresh processes after 2 warm-up requests. That is the `X-Inference-Time-Ms` header, which `curl -si` shows. It covers tokenizing, running the model and decoding. It leaves out reading and checking the request, waiting for the GPU and writing the reply.
-Each question gets one of three answer types:
+Each question gets one of 3 answer types:
| type | you give | you get back |
| --- | --- | --- |
@@ -81,24 +75,24 @@ Each question gets one of three answer types:
| `score` | an ordered list of levels | `score`, the expected level (it can fall between levels), `probabilities` per level, and a `legend` |
| `noul` | only the instructions | `noul`, the probability that the answer is yes |
-Every answer also has two confidence numbers, which measure different things:
+Every answer also has 2 confidence numbers, which measure different things:
-- `answer_confidence` is the probability of the answer given. It is the one to use for deciding whether to trust an answer.
-- For `choice` and `score`, `confidence` is 1 minus the normalized entropy of the probabilities. It is 0 for an even split and 1 when everything is on one option. For `noul`, it equals `answer_confidence`: the larger of the yes and no probabilities, which is 0.5 for an even split.
+- `answer_confidence` is the probability of the most likely option. For `choice` that is the chosen label, for `score` the most likely level, which can differ from the expected `score`, and for `noul` the larger of yes and no. It is the one to use for deciding whether to trust an answer.
+- For `choice` and `score`, `confidence` is 1 minus the normalized entropy of the probabilities. It is 0 for an even split and 1 when everything is on one option. For `noul`, it equals `answer_confidence`, which is 0.5 for an even split.
-Don't compare the two against the same threshold. `action.act_probability` comes from a second output of the model: its probability for acting on the answer rather than escalating. Ctrl-C stops the server, and when it stops, the model is out of memory.
+Don't compare them against the same threshold. `action.act_probability` is the model's probability, from a second output, for acting on the answer rather than escalating. Ctrl-C stops a foreground server, and `brew services stop sys1rust` stops the background one. When the server stops, the model is out of memory.
## How it works
-
+
- **It checks requests the way `laya serve` does**, with the same limits, error codes and `detail` strings. It checks the API key first, then takes a slot, then reads and checks the body.
-- **The GPU runs one request at a time.** `sys1rust` holds up to 16 requests (`LAYA_MAX_CONCURRENT`), one running and the rest waiting their turn. A request that arrives while all 16 slots are held gets `503` with `Retry-After: 1` at once, so clients must retry it. More clients don't get more throughput, since one request already fills the GPU.
+- **The GPU runs one request at a time.** `sys1rust` holds up to 16 requests (`LAYA_MAX_CONCURRENT`), one running and the rest waiting their turn. A request that arrives while all 16 slots are in use gets `503` with `Retry-After: 1` at once, so clients must retry it. More clients don't get more throughput, since one request already fills the GPU.
- **All the questions in a request go through the model together**, one row per question, in fp16.
- **It beats Python MLX by doing less work and tuning its matmuls, not by being Rust.** It skips computing on padding, runs the decision head's last layer only at the positions the answer reads, uses dense attention where that is cheaper, and from 512 tokens computes local attention only over the keys each window reaches. Its matmuls run MLX's own kernels for the M5's matrix units, with tile sizes tuned on this M5. The answers don't change.
-- **It downloads a model once, at a pinned revision.** The first `sys1rust serve` fetches the five files the model needs (846 MB for typed-decisions) into the Hugging Face cache and checks each one's hash. Later starts read them from there. `--offline` turns downloads off.
+- **It downloads a model once, at a pinned revision.** The first `sys1rust serve` fetches the 5 files the model needs (846 MB for typed-decisions) into the Hugging Face cache and checks each one's hash. Later starts read them from there. `--offline` turns downloads off.
## Speed
@@ -110,13 +104,13 @@ Median time per request for the typed-decisions model on a base M5, in ms. These
| laya-mlx, Python MLX at its fastest (compiled, buffer cache capped) | 19.0 | 47.6 | 430 |
| `laya serve`, stock, over HTTP | 53.9 | 157.6 | 708 |
-- **Against stock `laya serve`**, it is 1.9 to 4.1x faster per request, counting `sys1rust`'s 0.3 ms of HTTP. The `laya serve` times come from the bake-off ([`REPORT.md`](results/REPORT.md)), an earlier session than the round 3 sys1rust times, and separate runs on this laptop vary by about 5%. In earlier runs, `sys1rust` sustained 7.9 requests/s over 5 minutes, and `laya serve` 3.54 over 2 minutes. `laya serve` runs fp32 for requests with fewer than 5 questions, but even upstream in fp16, which it doesn't ship, is 1.9 to 2.9x slower on these three sizes, also against times from an earlier session.
+- **Against stock `laya serve`**, it is 1.9 to 4.1x faster per request, counting `sys1rust`'s 0.3 ms of HTTP. The `laya serve` times come from the bake-off ([`REPORT.md`](results/REPORT.md)), an earlier session than the round 3 sys1rust times, and separate runs on this laptop vary by about 5%. In earlier runs, `sys1rust` sustained 7.9 requests/s over 5 minutes, and `laya serve` 3.54 over 2 minutes. `laya serve` runs fp32 for requests with fewer than 5 questions, but even upstream in fp16, which it doesn't ship, is 1.9 to 2.9x slower on these 3 sizes, also against times from an earlier session.
- **Against Python laya-mlx at its fastest**, it is 1.25x faster, and faster on all 12 benchmark sizes, by 13 to 55%.
-- **It gives the same answers.** On the 1,500-answer correctness workload, 1,498 agree with the upstream PyTorch fp32 reference (99.9%, above the 99% gate). The two that differ are near ties.
+- **It gives the same answers.** On the 1,500-answer correctness workload, 1,498 agree with the upstream PyTorch fp32 reference (99.9%, above the 99% gate). The 2 that differ are near ties.
- **Its tail stays close to the median.** In the timing runs, no request took more than twice the median for its size. Python MLX without a capped cache had 9% of requests over that line.
- **It starts fast and is small.** From process start to the first answer takes 243 to 375 ms, with the model files already in the OS file cache. `sys1rust` is a 7 MB binary, and its HTTP layer adds 0.3 ms per request. macOS caches the compiled GPU kernels by the binary's path and the kernel source, so the first start from a new path or after a kernel change takes about 1.7 s longer.
-Everything here was measured on one Mac: a MacBook Pro 14 with a base M5, on macOS 26.2 and wall power. Other chips are untested. The write-ups are in [`results/`](results/README.md). They run from the bake-off of 13 existing runtimes ([`REPORT.md`](results/REPORT.md)) to the speed round ([`SPEED.md`](results/SPEED.md)).
+All these measurements come from one Mac, a MacBook Pro 14 with a base M5, on macOS 26.2 and wall power. Other chips are untested. The write-ups are in [`results/`](results/README.md). They run from the bake-off of 13 existing runtimes ([`REPORT.md`](results/REPORT.md)) to the speed round ([`SPEED.md`](results/SPEED.md)).
## Models
@@ -175,7 +169,7 @@ When it's ready, `sys1rust` prints one JSON line on stdout with the address, mod
`sys1rust` listens on 127.0.0.1 by default, so only programs on the same Mac can reach it. Keep that default. `sys1rust` speaks plain HTTP without TLS, and it has no header read timeout and no connection limit. Only the request body has a deadline, and a body that takes over 10 s gets `408`.
-An API key alone does not make remote serving safe. With `LAYA_API_KEY` set, `/v1/systemone` answers only requests that send `Authorization: Bearer `, but over plain HTTP anyone on the network path can read that key. The key is checked only once the headers have arrived, so it does nothing against connections that never finish sending them. Each such connection holds a socket for as long as the client keeps it open, and nothing caps how many there are.
+An API key alone does not make remote serving safe. With `LAYA_API_KEY` set, `/v1/systemone` answers only requests that send `Authorization: Bearer `, but over plain HTTP anyone on the network path can read that key. `sys1rust` checks the key only once the headers have arrived, so it does nothing against connections that never finish sending them. Each such connection holds a socket for as long as the client keeps it open, and nothing caps how many there are.
To serve clients on other machines, run a reverse proxy on the same Mac in front of `sys1rust`. The proxy should terminate TLS, time out slow headers, limit connections and forward to 127.0.0.1. Set an API key as well:
@@ -192,8 +186,8 @@ sys1rust serve --port 8000 # the proxy forwards to 127.0.0.1:8000
Differences from laya serve
- **Bind address.** `sys1rust` listens on 127.0.0.1 by default. `laya serve` listens on 0.0.0.0.
-- **Downloads.** `sys1rust` downloads only the three Laya models, only at the revisions pinned in `bench/models.lock.json`, and only the five files each one needs. `laya serve` downloads any model repo at its newest revision.
-- **Duplicate choice keys.** When two of a choice's labels print as the same JSON key, such as `"1"` and `1`, upstream writes that key twice in `probabilities`, and `sys1rust` writes it once with the second label's probability. Python's `json`, JavaScript's `JSON.parse` and serde_json's `Value` keep the last duplicate, so a client parsing with one of them gets the same object from both servers. A parser that rejects duplicate keys or keeps the first one reads the two responses differently.
+- **Downloads.** `sys1rust` downloads only the 3 Laya models, only at the revisions pinned in `bench/models.lock.json`, and only the 5 files each one needs. `laya serve` downloads any model repo at its newest revision.
+- **Duplicate choice keys.** When 2 of a choice's labels print as the same JSON key, such as `"1"` and `1`, upstream writes that key twice in `probabilities`, and `sys1rust` writes it once with the second label's probability. Python's `json`, JavaScript's `JSON.parse` and serde_json's `Value` keep the last duplicate, so a client parsing with one of them gets the same object from both servers. A parser that rejects duplicate keys or keeps the first one reads the 2 responses differently.
- **JSON.** `sys1rust` accepts standard JSON in UTF-8. Upstream's `json.loads` also accepts `NaN`, `Infinity` and `-Infinity`, `\u` escapes of unpaired surrogates, and bodies in UTF-16 or UTF-32 or with a byte order mark. `sys1rust` answers those with 400 `request body must be valid JSON`.