Skip to content

0.22.0: Batched inference - #50

Merged
MathisWellmann merged 14 commits into
mainfrom
batched
Jul 26, 2026
Merged

0.22.0: Batched inference#50
MathisWellmann merged 14 commits into
mainfrom
batched

Conversation

@MathisWellmann

Copy link
Copy Markdown
Owner

Changes:

  • compiler: await cargo instead of blocking runtime worker.
  • runtime: new build_and_register and publish_revision methods to split up the evolve contract into two.
  • evolve_failure: tag records with batch lane that produces it
  • runtime: add evolve_batch and evolve_batch_limited for concurrent multi-prompt evolution
  • runtime: reuse an existing revision when a candidate is byte-identical; literally deduplication built-in
  • metrics for batched operations.
  • Add example batched-evolution to showcase these new features.
  • Update README.md and CAVEATS.md with the appropriate docs.
  • Update website as well

Benchmarks:

Concurrency benchmarks of Bonsai-8B against a vllm backend, showing up to 10x more inference throughput at 32 concurrency.

┌───────────┬────────┬─────────┬───────────┬────────────┬──────────┬───────┐
│ in flight │  wall  │ speedup │ dec tok/s │ tok/s gain │ lanes ok │ built │
├───────────┼────────┼─────────┼───────────┼────────────┼──────────┼───────┤
│ 1         │ 407.1s │ 1.00×   │ 72        │ 1.00×      │ 18/32    │ 17    │
├───────────┼────────┼─────────┼───────────┼────────────┼──────────┼───────┤
│ 2         │ 679.6s │ 0.60×   │ 88        │ 1.22×      │ 18/32    │ 16    │
├───────────┼────────┼─────────┼───────────┼────────────┼──────────┼───────┤
│ 4         │ 149.8s │ 2.72×   │ 196       │ 2.71×      │ 18/32    │ 13    │
├───────────┼────────┼─────────┼───────────┼────────────┼──────────┼───────┤
│ 8         │ 102.3s │ 3.98×   │ 300       │ 4.15×      │ 16/32    │ 9     │
├───────────┼────────┼─────────┼───────────┼────────────┼──────────┼───────┤
│ 16        │ 57.2s  │ 7.12×   │ 475       │ 6.59×      │ 16/32    │ 10    │
├───────────┼────────┼─────────┼───────────┼────────────┼──────────┼───────┤
│ 32        │ 40.8s  │ 9.97×   │ 656       │ 9.10×      │ 17/32    │ 5     │
└───────────┴────────┴─────────┴───────────┴────────────┴──────────┴───────┘

So for the right workloads this architecture improvement it huge.

`compile_dylib` shelled out through `std::process::Command::output()`, a
multi-second blocking call sitting on a tokio worker inside an async fn.
Today nothing else shares the runtime — no host or example spawns — so it
is inert. Under concurrent evolution lanes it is not: tokio's multi-thread
runtime has no dedicated I/O thread (a worker parks on epoll when idle), so
workers stuck in `waitpid` stop readiness events from being processed and
the sibling lanes' inference responses stall behind the build they were
supposed to overlap with. On a `current_thread` runtime — what bare
`#[tokio::test]` gives you — one blocked worker is the only worker.

Switch to `tokio::process::Command`, matching what `doc_string.rs` already
does for its `cargo rustdoc` invocation. No thread is consumed at all,
which is strictly better than `spawn_blocking` and needs no ownership
changes to the arguments.

`syn` trees are `!Send` (a `proc_macro2` token may wrap a `proc_macro`
one), so introducing the await required scoping the ASTs in both
`compile_dylib` and `evolve_no_backpressure` such that they are dropped
before it; otherwise `evolve`'s `impl Future + Send` no longer holds.
Registration picked its revision id with `next_revision_id()` before
compiling, then asserted at push time that the registry length still
matched — an assertion whose message ("concurrent evolve() calls are not
supported") names exactly the thing this series is about to support. Two
lanes reaching that read together would pick the same id, and therefore
the same versioned `.so` path, and clobber each other's dylib.

Introduce `build_slot`, a tokio mutex covering the whole compile -> copy ->
load -> push sequence. Everything inside it is process-wide shared state:
the generated `crate_dir/src/lib.rs`, the unversioned `so_path` that cargo
overwrites each build, and the dense id. Cargo already takes an exclusive
lock on its build directory, so same-crate-dir builds serialize either
way; this makes the boundary explicit and makes id assignment correct by
construction — the id is read from the registry while holding the permit,
so nobody else can claim it. One build slot is the right starting point:
inference dominates a lane by an order of magnitude, so the contention is
noise.

Split the pipeline tail into two operations with different safety
requirements:

  - `build_and_register` compiles, loads and retains a revision without
    touching the dispatch pointers. A retained-but-unpublished revision is
    invisible to running calls, so it is exempt from the feedback-loop
    contract.
  - `publish_revision` performs the pointer swap. This is the only thing
    that can tear a multi-function revision apart mid-use, so it is now
    where `assert_no_calls_in_flight` lives. `activate_revision` becomes a
    thin wrapper over it, dropping a duplicated registry lookup.

`evolve` keeps a fail-fast copy of the assertion at entry so a contract
violation surfaces before minutes of inference rather than after.

Pure refactor: serial `evolve` takes the uncontended mutex and behaves
exactly as before. `evolve_no_backpressure` also shrank below the
`too_many_lines` threshold, so its exemption is removed.
`EvolveFailure` records carry a 1-based attempt index but no way to say
*which* evolution they belong to. With a single `evolve` that is fine —
there is only one. A batch drains all its lanes into the same buffer, so
without attribution a host cannot tell which prompt variant produced which
compiler diagnostics, which is exactly the per-variant view a batch is run
to get.

Add a `lane` field, defaulting to 0 and re-attributed with `with_lane`.
`from_error` keeps its signature, so this is additive for existing callers.
Add `Runtime::evolve_batch` and `evolve_batch_limited`: one lane per prompt,
run concurrently, results positionally aligned with the input.

Against an OpenAI-compatible endpoint there is no batch API to call —
batching *is* issuing the requests concurrently and letting the server's
continuous batcher merge them into one forward pass. Decode is
memory-bandwidth-bound, so a batch of n reads the model weights once per
step instead of n times; and because all lanes share the symbiont system
preamble (which embeds the rustdoc-derived API surface, by far the largest
part of the request), prefix caching lets every lane after the first skip
that prefill. The existing `LLM_TOKENS{kind="cached_input"}` counter already
reports whether the server is really reusing it.

Two design decisions worth stating:

Lanes do not publish. Every successful lane is compiled, loaded and
retained, but the dispatch pointers keep pointing wherever they did. The
host evaluates the candidates through `<name>_fn` handles and commits to one
with `activate_revision`. That is what a batch is *for* — eight candidates
with no fitness signal between them is not a hill to climb — and it has a
second consequence: since nothing is published, `evolve_batch` is exempt
from the feedback-loop contract, so generation of round n+1 can overlap
evaluation of round n.

Each lane owns its retry ladder. Its own chat history, its own attempt
budget, its own repeat detection. A lane that answers with prose ten times
fails alone; its siblings neither see the corrections nor lose budget to
them. The failure buffer is cleared once per batch rather than once per
lane, and records are tagged with their lane.

Ordered bounded concurrency comes from `futures_util`'s `buffered`, which
was already in the dependency tree via rig-core. Lane futures are built
eagerly into a Vec: constructing one does no work, and it avoids a
higher-ranked lifetime error that a lazy `.map()` closure returning
`impl Future` cannot express.

`evolve` is now `evolve_lane(.., Publish::Yes)` with the failure-buffer
clear hoisted into it, so the single-prompt path shares one retry ladder
with the batch rather than duplicating it.

Tests add a `RoutedAgent` double that answers on prompt content rather than
call order, since concurrent lanes interleave nondeterministically.
Eight near-identical prompts at low sampling temperature do not reliably
produce eight distinct implementations. When two lanes converge on the same
source, building both spends a multi-second `cargo build` on an artifact
that already exists — and hands the host two revision ids that are, in
every observable respect, the same program.

Before compiling, compare the rendered candidate against the sources of the
already-registered revisions and reuse the match. The check sits inside the
build permit, which is what makes it airtight under concurrency: two lanes
holding identical code cannot both miss the check and then both build.

A linear scan with full string comparison, not a hash index. The registry
holds tens to hundreds of entries of a few KB, so a miss costs microseconds
against a build that costs seconds — and there is no collision case to get
wrong.

This is visible behaviour, not just an optimization, so both entry points
document it and `REVISION_DEDUP_HITS` counts it. `evolve` may now return an
id it returned before; `evolve_batch` may return the same id in several
slots. The latter is a genuinely useful signal — repeated ids mean the
prompt variants are not diversifying the output, which is a prompt problem
the host should see rather than a cost it should silently pay.
`EVOLVE_DURATION` is recorded per lane, so after a batch its distribution
describes lane latency and says nothing about the batch. But lane latency is
not the number that decides whether batching is working — overlap is. Four
new series make that measurable:

  - `EVOLVE_BATCH_SIZE` and `EVOLVE_BATCH_DURATION`: divide one by the other
    for seconds per candidate, which is what should fall as the batch grows.
    Their ratio against the summed per-lane durations *is* the speedup.
  - `EVOLVE_BATCH_LANES{outcome}`: lanes fail independently, so a rising
    error share against a steady batch size localizes the problem to specific
    prompt variants rather than to the batch.
  - `BUILD_SLOT_WAIT`: the cost of serializing builds against one shared crate
    directory. Near zero means inference dominates and one build slot was the
    right call; if it climbs, the batch is bottlenecked on cargo and the crate
    dir needs to be split per lane.

The new integration test pins the distinction the first metric exists to
make: batch size is sampled once per batch while lane duration is sampled
once per lane — including for the lane that exhausted its retry budget,
which is pre-existing behaviour the test now documents.
890fb8b ("include video of \`fractal-studio-example\` in README") added a
bare `README.md` pattern to .gitignore — twice — while adding an 8 MB mp4.
The duplication and the commit's subject both point at a slip rather than
an intent: a bare pattern matches at every depth, and the repo tracks nine
README.md files including one per example.

It is inert for those, since ignore rules do not untrack. But it silently
swallows every *new* README: the next one added just never appears in
`jj status`, with no error anywhere. The following commit adds one.
A worked example of the population-search shape `evolve_batch` exists for:
eight prompts that differ only in a trailing strategy hint, evolved
concurrently, benchmarked against each other, and only then does one get
activated.

Counting primes below n is the task because *strategy* dominates it. Trial
division, a sieve, a bit-packed sieve and wheel factorization are all a few
dozen lines and all obviously correct to a model, but they span three orders
of magnitude in runtime. So eight near-identical prompts produce eight
genuinely different programs rather than eight paraphrases of one, which is
the premise the batch API is built on.

The example is written to demonstrate the parts that are easy to get wrong:

  - The per-lane hint goes *last*. Prefix reuse stops at the first differing
    token, so a hint spliced into the middle would throw away the cached
    prefill of everything before it.
  - Candidates are measured through `count_primes_below_fn(rev)` handles, not
    by activating each in turn. Nothing is hot-swapped between measurements,
    so all eight are timed against the same baseline.
  - Panics are read with `handle.take_panic()`, not `runtime.take_panic()`.
    A handle call's panic lands in *its* revision's buffer; the runtime
    method reads whichever revision is active, which during evaluation is
    still the initial one.
  - Failures are grouped by `EvolveFailure::lane`, so an unproductive prompt
    variant is identifiable rather than just "something failed".
  - Revisions are deduplicated before being counted as distinct, since lanes
    that converge on identical source share one.

`devenv.nix` gains `--parallel 8`. Without it `llama-server` decodes one
sequence at a time and the lanes would queue rather than batch, which would
make the example both slow and pointless as a demonstration. `--ctx-size` is
the total KV budget split across slots, so it is scaled with the slot count
to keep 16k per slot — comfortably above the largest preamble in the tree.

A round where every lane fails is reported rather than fatal, matching the
sort example: whether a small model invents a sieve is a property of the
model, not of the harness. `STRICT=1` demands a correct implementation.
A benchmark for the claim the previous commits are built on: that eight
concurrent lanes cost far less than eight sequential ones. Runs the same
eight-lane batch at in-flight limits 1, 2, 4 and 8 — lane count fixed so
every level does identical inference work and only the overlap differs —
against a containerized vLLM.

The sweep does not just report wall clock, because a wall-clock number alone
cannot distinguish batching from queueing. Each level installs its own
metrics recorder and reports the decomposition:

  - summed per-lane inference time, which should stay flat across levels
    (batching overlaps requests, it does not shorten them). If it grows with
    the limit, the server is queueing — `--max-num-seqs` too low, or
    `--parallel` unset on llama-server.
  - compile time and build-slot wait, which is the local pipeline the one
    build slot serializes. Near-zero wait is the regime that design assumes;
    if it rivals inference time, the shared crate dir is the next bottleneck.
  - prefix-cache hit rate from the provider's `cached_input_tokens`, which
    reads zero the moment prefix caching is off or the prompts diverge too
    early to share a prefix.

Level prompts are tagged with the level, so a level cannot silently ride on
revisions an earlier level registered — dedup would skip those builds and
make the later level look faster than it is.

Serves `prism-ml/Bonsai-8B-unpacked`, the safetensors base of the GGUF that
devenv.nix and CI already use, so the benchmark and the example smoke tests
exercise the same weights.

Two environment details worth recording, both of which cost an hour to
diagnose:

  - The GPU is requested as a CDI device rather than via `--gpus all`. On
    hosts where nvidia-container-toolkit only writes a CDI spec and does not
    register a runtime with the daemon — the common NixOS setup — `--gpus all`
    fails with a misleading "AMD CDI spec not found" while the CDI device
    resolves fine.
  - `ipc: host`, because vLLM's workers use shared memory and the 64 MB
    default surfaces as an opaque worker crash.

The bench probes the endpoint with a plain TCP connect and exits 0 with a
message when there is no server, so `cargo bench` stays green on machines
without a GPU and CI's `cargo bench --no-run` only ever compiles it. Both
skip paths are verified.
README gains `evolve_batch` under core highlights and population search under
use cases. Two things worth stating outside the API docs:

CAVEATS records that builds are serialized and why — the crate directory, the
`.so` cargo writes and the dense revision id are all process-wide, and cargo
locks its build directory regardless — along with the metric
(`symbiont_build_slot_wait_seconds`) that says when that trade has stopped
paying.

TODO records that prefix-cache visibility is provider-dependent. This is not a
guess: sending an identical 923-token prompt to vLLM twice returns
`prompt_tokens_details: null` both times, so `LLM_TOKENS{kind="cached_input"}`
reads zero there even though `vllm:prefix_cache_hits_total` shows the cache
working. The throughput bench scrapes `/metrics` to work around it; a general
fix needs a hook for hosts to feed a server-side metric back in.

Minor bump: `evolve_batch`/`evolve_batch_limited`, `EvolveFailure::lane` and
`with_lane`, and four new metric constants are additive.
Eight lanes at in-flight limits 1/2/4/8 against `prism-ml/Bonsai-8B-unpacked`
under vLLM v0.26.0 on an RTX PRO 6000 Blackwell, using the compose.yaml in
this directory. 74.0s -> 21.3s wall for the same eight candidates.

The wall-clock speedup flattens between 4 and 8 lanes (3.43x -> 3.47x), which
would read as the batcher giving up if that were the only column. It is not:
output tokens per second of wall clock keeps climbing 76 -> 102 -> 212 -> 237,
and `llm (sum)` stays in a 50-71s band instead of growing with the limit,
which is what distinguishes batching from queueing. The flattening is the
fixed serial tail — every lane still parses, validates, compiles and loads on
its own, and a lane that exhausts its retry budget runs all ten attempts no
matter how many siblings are in flight.

Two things the run confirms about earlier design decisions:

  - `slot wait` grows with concurrency exactly as predicted, 0ms -> 873ms, but
    stays under 4% of wall clock while total compile never exceeds 1.6s. The
    batch is inference-bound throughout, so one build slot was the right call
    and splitting the crate directory per lane would buy nothing yet.
  - Prefix caching serves 72-78% of prompt tokens — while the provider-reported
    `cached_input_tokens` was 0 for every single request. Without the
    `/metrics` scrape the column would have read a flat, wrong zero.

Both caveats are recorded alongside the numbers rather than smoothed over:
levels do unequal work because a failing lane spends ten requests (Bonsai-8B
lands 5-7 of 8 lanes, with the sieve prompts dying on u32-vs-usize indexing),
and `built` falls below `lanes ok` at the higher limits because dedup keys on
generated source, which the per-level prompt tag cannot reach.
The 8-lane sweep flattened between limits 4 and 8 (3.43x -> 3.47x), which read
as the batcher saturating. It was not: with only 8 lanes, limit 8 already has
every lane in flight and there is no further overlap to buy. Raise LANES to 32
and the curve runs to 9.97x wall / 9.10x decode throughput with no plateau —
32 candidates in 41s against 407s sequentially.

Making that measurable needed four changes:

  - LEVELS to [1,2,4,8,16,32] and LANES to 32, with startup assertions that
    every level is <= LANES and that there is a distinct strategy per lane.
    `evolve_batch_limited` clamps its limit to the lane count, so a level above
    LANES would silently duplicate the LANES row rather than measure anything.
  - 32 distinct strategy hints instead of 8, of deliberately mixed difficulty.
  - `--max-num-seqs=32` in compose.yaml. vLLM queues beyond that, so the top
    rows would have measured the queue, not the batcher.
  - A `dec tok/s` / `tok/s gain` column, which is the work-normalized view and
    the one to trust when levels burn different numbers of retries.

Two fixes fell out of running it:

`scrape_prefix_cache` treated a zero counter as "server does not expose this"
and returned None. On a cold server that discarded the *baseline* of the first
level's diff, so level 1 fell back to the provider-reported figure — always
zero on vLLM — and reported a confident 0% next to 73-75% everywhere else.
Presence of the series, not a non-zero value, is the right test.

The runtime is now multi-threaded, with one global recorder snapshotted per
level in place of a per-level thread-local one (a thread-local recorder would
miss everything recorded off the installing worker). This was chased as the
suspected cause of some wild outliers and turned out not to be: limit 16 came
in at 56.4s, 58.4s and 57.2s across two single-threaded runs and this
multi-threaded one. It is kept because it is the honest default at these
widths, not because it fixed anything.

The actual cause of the outliers is retry variance, now documented at the top
of the file and beside the numbers. A level that draws unlucky lanes lands at
double the `output tok` and runs 4-8x slower — one came out slower than serial.
Retries inside a lane are sequential, so they extend that lane's critical path,
and a level's wall clock is its slowest lane: concurrency recovers none of it,
which is why 2x the tokens costs far more than 2x the time.
@MathisWellmann MathisWellmann self-assigned this Jul 26, 2026
Adds a "Showcase: Batched Evolution" section between the fractal-studio
showcase and the core highlights, carrying the measured numbers from
`symbiont/benches/vllm/README.md`: 32 candidates in 41s against 407s
sequentially, 9.1x decode throughput, 74-76% prefix-cache hit rate.

The centrepiece is a column chart of decode throughput against concurrency.
Its colour is not a taste call: the site's `$teal` (#4ECDC4) sits at OKLCH
L 0.776, outside the dark-mode band of 0.48-0.67, so it is used only for the
hover state. The bars use `$dark-teal` (#00897B), which passes the lightness
band and clears 3:1 contrast against the page surface. One series means one
hue and no legend — length encodes magnitude, colour encodes nothing — and
only the two endpoints are directly labelled, with the y-axis carrying the
rest.

The 2-lane bar is a visible dip rather than a smooth curve, because that is
what was measured: that level drew lanes that ground through their full retry
budgets and generated twice the tokens of every other level. It is kept, with
the explanation in the collapsible data table underneath, rather than dropped
to make the curve look tidy.

`.bench-grid` / `.bench-card` move out of `.benchmarks` to the top level. They
were scoped to that one section, so reusing the markup here rendered the stat
tiles as bare stacked text.

Also adds "Batched evolution" and "Revision registry" cards to the core
highlights (the registry has been shipped for a while and was never listed),
and "Batched Population Search" to the hero list.

No gallery tile yet: every existing one is a screenshot of real output, and
running the example needs the GPU, so that entry is left until there is a
genuine screenshot to put in it.

Verified with `zola build` and screenshotted at 1100px and 390px.
@MathisWellmann
MathisWellmann merged commit 6208432 into main Jul 26, 2026
5 checks passed
@MathisWellmann
MathisWellmann deleted the batched branch July 26, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant