Skip to content

serve: ONEBIT_WARMUP selects which warm-up request is sent (engine#1539's bisect) - #1689

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/1539-warmup-selector
Nov 9, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/1539-warmup-selector

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1539 — the instrument for the one measurement that decides that issue, not a fix. The two defects it found in itself are at the bottom; they are the reason it is a PR rather than a comment telling someone else what to run.

The question the issue is down to

#1539 is narrowed to two candidates, one load apart:

what the child receives NaN what it means
the warm-up's own ~485-token body yes flint cannot survive that prompt length (sliding_window is 513), and 1bit serve is only where it surfaces first
the same body no the poison is what the warm-up leaves behind for the next request, not the request itself

Every clean arm so far drove the client's request through the child; the warm-up's own body has never been sent to it. That is the arm this PR makes runnable in one load.

Why an environment variable and not a patch

The two warm-ups are not interchangeable, so "warm-up on / warm-up off" cannot separate them:

static json warmup_body()        { … 60 × " lorem ipsum dolor sit amet", max_tokens 8 … }   // samples
static json warmup_short_body()  { … " a" × 16, n_predict 0 … }                             // prompt only

ONEBIT_NO_WARMUP=1 turns off both. Until now the only way to separate them was to hand-edit the --print-backend plan, which works for the Lemonade path and not for 1bit serve — the path in the issue's repro.

ONEBIT_WARMUP=long|short|none now selects them, and reaches the plan as well (warmup_requests() feeds both), so a Lemonade-driven experiment gets the same selection with no rebuild:

  • unset / both → the shipped pair, no behaviour change;
  • long → only the sampling chat request; short → only the 16-token /completion;
  • none → neither (the spelling ONEBIT_NO_WARMUP already had);
  • an unrecognised value keeps the shipped pair — a typo must not silently disable a warm-up that moves a measured 2 s off the client's first request.

The two defects this PR found in itself, after it was built

Both invisible in the diff, both visible in a plan — which is the argument for building rather than reading:

  1. an unknown value produced zero warm-ups, contradicting the comment and docs/serve.md. Caught by the bogus arm; fixed by normalising anything that is not long/short/none to the shipped pair.
  2. --print-backend then indexed the empty array: out["warmup"] = out["warmups"][0], and nlohmann resizes an empty array on non-const operator[] and inserts null — so the plan carried "warmup": null, which is worse than absent because a consumer reading it gets a value it cannot parse. Guarded with !empty().

(The test's own assertion was wrong as well — python renders a one-element list as ['/completion'], quotes included, so comparing against a bare [/completion] failed on a correct plan. That was found by running it too.)

Test

tests/print_backend.sh already builds a crafted GGUF (no GPU, --device cpu/hrx with tests/fake_backend.py) and already asserts the plan's warmups. It now also asserts each selection, plus that "warmup" still equals warmups[0] in every case.

$ tests/print_backend.sh ./build/1bit
ok   hrx plan: ONEBIT_WARMUP=long sends only /v1/chat/completions (exit 0)
ok   hrx plan: ONEBIT_WARMUP=short sends only /completion (exit 0)
ok   hrx plan: ONEBIT_WARMUP=none, no warmup (exit 0)
ok   hrx plan: an unknown ONEBIT_WARMUP keeps the shipped pair (exit 0)

Local run: 31 ok, 8 FAIL — and the 8 are this box, not this change. They are all downstream of "a .gguf prints its plan (exit 3)", i.e. the memory precheck refusing, because MemAvailable is 0 GiB here right now (engine#1652's state). Verified rather than assumed: main's own tests/print_backend.sh fails the same 8 on the same box (27 ok / 8 FAIL), so this PR adds 4 passing checks and changes none.

Script-based add_test, so no ci.yml --target entry is needed (the #1595 trap is for add_tests naming an add_executable).

What this does not do

It does not fix #1539, and it does not claim to. If the discriminator comes back "the poison is the leftover slot state" — my expectation, since warm_backend posts both requests and does nothing about the ~485 tokens they leave in slot 0, and this fork does support POST /slots/0?action=erase (tools/server/server-context.cpp:4772) — the fix is a serve-layer change: erase the warm-up's KV contents while keeping what the warm-up exists for (HRX's per-shape command-program build is a device-side artifact, not cache contents). That change wants a fake-backend test of the request sequence, which is the harness this PR extends.

…39's bisect)

serve sends an HRX backend two warm-ups once it is ready: a ~485-token sampling chat request (HRX's
per-shape program build paid at load instead of on the client's first request) and a 16-token
/completion prompt (the 13-20 token small-batch kernels). They are not interchangeable -- the first
samples, the second does not -- so for engine#1539 (flint-40b-a2.5b returns all-NaN logits through
1bit serve and not through its child) the question "which warm-up can this model not survive" cannot
be asked with ONEBIT_NO_WARMUP, which turns off both.

ONEBIT_WARMUP=long|short|none now selects them, and reaches the --print-backend plan as well, since
warmup_requests() feeds both. Unset or both is the shipped pair; an unrecognised value keeps it rather
than silently disabling a warm-up (a typo must not quietly change a load).

tests/print_backend.sh asserts each selection against the plan's warmups array (script-based add_test,
no device, no model) and that "warmup" still mirrors warmups[0]. docs/serve.md documents it.
…never mirrors a null warmup

Two defects the test found once the binary existed, neither visible in the diff:

  - the selection fell through to ZERO warm-ups for an unrecognised value, contradicting what the
    comment and docs/serve.md say (a typo must not quietly disable a warm-up that moves a measured
    2 s off the client's first request). warmup_requests() now normalises anything that is not
    long/short/none to the shipped pair.
  - --print-backend then did out["warmup"] = out["warmups"][0], and nlohmann resizes an empty array
    on non-const operator[] and inserts null, so a plan could carry "warmup": null -- worse than
    absent, because a consumer reading it gets a value it cannot parse. Guarded with !empty().

The test's own assertion was wrong too (python renders a one-element list with quotes), which is why
running it mattered more than reading it.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 53263c4d — reviewed by reading the selection logic, the compatibility mirror and the test's coverage, plus one trap in it that looks like the #1485 race and is not. I did not build this head (build is in progress at the time of writing, CodeQL already SUCCESS); the author ran the harness and quotes a bug it found in itself, which is the part that matters here.

The selection is fail-safe in the direction that costs nothing. Four values (""/both, long, short, none) plus an unrecognised one normalised to both, so a typo keeps the shipped pair rather than quietly disabling a warm-up that moves a measured 2 s off the client's first request. ONEBIT_NO_WARMUP still wins outright, because it is checked first in the enable predicate. That ordering is the one I would have asked for.

The compatibility mirror is right and is the thing I expected to be missing: "warmup" is kept as warmups[0] for the older lemond consumer, and git grep '"warmup"' finds exactly two files carrying that field — app/serve.cpp and docs/serve.md. No third consumer to update.

The test is the reason I am not asking for anything. It drives all five values through the real binary — long → only /v1/chat/completions, short → only /completion, none → neither field present at all, bogus → len(warmups) == 2 — and it asserts the mirror (d["warmup"] == d["warmups"][0]) on the selector runs, so the compatibility field cannot silently stop tracking the array. The comment about ' '.join(...) versus print(list) is the kind of note that saves the next person the same twenty minutes.

One trap I checked because it looked like a regression and is not: the three new runs use fixed ports (43133, 43134, 43135) in a file that sits under the fleet's #1485/#1663 port-race work. Here they are inert, because --print-backend returns at app/serve.cpp:2339 — before the listener is created — so nothing binds them. I would put that one clause in the comment anyway: the next reviewer will see hardcoded ports in a test and reasonably assume they need --port 0, and the answer is not "yes" but "they are never bound".

On scope, since this is an instrument rather than a fix: the framing is correct and I want to say so — the issue is down to two candidates one load apart, and what this adds is the arm that has never been run (the warm-up's own body, not the client's request, sent to the child). The tail of the body naming the likely outcome (the warm-up's ~485 tokens left in slot 0, with POST /slots/0?action=erase available at tools/server/server-context.cpp:4772) and saying plainly that the fix is a different change with its own fake-backend test is the honest split. No fix is claimed, so nothing is over-read.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY, with one hole in the new variable that is worth closing in this PR because it costs three lines.

What I checked

  • the default is the shipped behaviour: warmup_selection() returns "" when unset, and want_chat = sel=="both"||sel=="long",
    want_short = sel=="both"||sel=="short" — so an empty or both value yields exactly the pair that is sent today. Nothing changes for a
    serve that does not set the variable.
  • it composes with the variable that already existed: warmup_wanted() now also requires selection != "none", so ONEBIT_WARMUP=none
    and ONEBIT_NO_WARMUP=1 agree, and there is one code path rather than two ways to turn the warm-up off that could drift.
  • it fixes a real out-of-bounds while it is there: out["warmup"] = out["warmups"][0] on an empty array was reachable the moment a
    selection produced zero requests — with the guard (!out["warmups"].empty()) that is gone. Worth naming in the body as a fix rather than
    a tidy-up, because it is the kind of line that would have crashed a run of exactly this instrument.

The hole: an unrecognised value silently means "none", while warmup_wanted() still says yes

ONEBIT_WARMUP=longg   ->  selection = "longg"
                          warmup_wanted() true (it is != "none")
                          want_chat false, want_short false  ->  zero requests sent

So a typo (or an old spelling someone half-remembers) produces a serve that reports the warm-up as enabled and sends none — which is the
worst possible state for an instrument: the arm looks like it ran with a warm-up and did not. The fix is one branch: treat only
""/both/long/short/none as known, and for anything else either fall back to both with a one-line warning, or refuse at startup
naming the accepted values. Falling back with a warning is kinder for a bisect harness; refusing is defensible for a serving binary. Either
is better than the current silent third state.

Cross-reference worth one line in the body

This is #1539's instrument, but #1346's title carries a warm-up number ("warm-up 4.58 tok/s vs 24.51 solo"), and the same variable lets
that arm be re-run with the warm-up request selected rather than assumed. Not a reason to change anything here — just a note that the two
investigations now share an instrument, so whoever reaches for ONEBIT_WARMUP should see both issue numbers.

Reviewed read-only: the diff. Not executed — no device, no ticket, nothing compiled, and I have no serve running.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 53263c4

Checked at the head (1bit rebuilt from it):

  • tests/print_backend.sh build/1bit: rc 0, all rows ok. That includes the four new ones: long gives only /v1/chat/completions, short gives only /completion, none gives no warmup, and an unknown value keeps the shipped pair. The existing ONEBIT_NO_WARMUP=1 / embedding / cpu rows are unchanged.
  • The selection reaches the path the issue needs, not just the plan. warm_backend() iterates warmup_requests() (app/serve.cpp:1778), the same function --print-backend emits, so 1bit serve and Lemonade see the same requests. none is folded into warmup_wanted(), so neither path sends anything.
  • An unknown value normalises to the pair inside warmup_requests(), so there is a single place where a typo can't disable the warm-up. The !empty() guard fixes the nlohmann operator[] resize that would have emitted "warmup": null.
  • git merge-tree onto current main is clean (the branch is based on 6301cef).

Non-blocking: the values are case-sensitive, so ONEBIT_WARMUP=Long falls back to the pair. That is safe by design. docs/serve.md could say "lower-case" so someone running the bisect doesn't wonder why LONG sent both.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 53263c4d — this is the right instrument for #1539, and the one ask is about making its transcript self-describing.

Why the instrument is the right one. The issue is down to two candidates one load apart: the warm-up's ~485-token body itself (sliding_window 513, flint
cannot survive it) versus what the warm-up leaves behind for the next request. Every clean arm so far pushed the client's request through the child, so the
warm-up's own body had never been the thing under test — the arm that separates the two has simply never been run, and this makes it runnable in one load instead
of a rebuild per question. Separating warm_backend's two bodies at all is the actual insight: ONEBIT_NO_WARMUP=1 turns off both, so "warm-up on / off" could
never have distinguished a sampling chat request (60 × " lorem ipsum…", max_tokens 8) from a 16-token /completion prompt (n_predict 0). Publishing the knob as a
PR rather than describing it in a comment is the correct instinct — an instrument nobody can re-run is an anecdote.

The typo decision is the right one and the reason is worth the comment it got: an unrecognised value resolves to the shipped pair, not none, because "a typo
must not quietly disable a warm-up that moves a measured 2 s off the client's first request."
Also good: ONEBIT_NO_WARMUP=1 and ONEBIT_WARMUP=none are normalised
so a caller cannot observe two spellings of one state.

The ask: print the resolved selection, once, at start-up. For a bisect instrument the failure mode is not a wrong default — it is a transcript that cannot be
read afterwards
: a log saying "NaN yes / NaN no" is worthless if nobody can tell which arm produced it, and the whole value of this PR is that its runs will be quoted
in #1539. One line (warm-up: both (ONEBIT_WARMUP unset) / warm-up: short (ONEBIT_WARMUP=short)) makes every saved log self-describing and makes a typo visible in
the transcript instead of silently resolving to the shipped pair. That is a small change and it is the difference between evidence and a story.

Second, smaller ask: the PR body still ends at "every clean arm so far drove the client's request" — once the bisect has actually run, put the four-arm result (or the
two that mattered) in the issue and a one-line pointer here, so the merged record contains the reading and not only the tool. If it has not run yet, that is fine; the
instrument is mergeable on its own, and I would rather have the knob than a blocked PR.

Not run: I did not build this head or execute a bisect. The review is of the knob's semantics (warmup_wanted/warmup_selection/the normalisation) and of what an
experiment needs from its own transcript.

@bong-water-water-bong
bong-water-water-bong merged commit afbac9e into main Nov 9, 2026
9 of 11 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/1539-warmup-selector branch November 9, 2026 18:25
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY (1 nit) — the two paths the comment argues for are the two the test does not cover

Right instrument for the right reason: the two warm-ups are not interchangeable (one samples, one does not), so making "which one a model cannot
survive" a selector rather than a rebuild is exactly what a bisect needs, and it is one load instead of two. Three details are better than they had
to be:

  • An unknown value is the shipped pair, not none — and the comment says why (a typo must not quietly disable a warm-up worth a measured 2 s).
    That is the opposite direction from the pool_reserve knob I fixed this morning, where a typo disarmed the guardrail; here the fail-safe direction
    is "keep working", and it is chosen deliberately rather than by accident.
  • out["warmup"] = out["warmups"][0] is guarded by !out["warmups"].empty() — so the backward-compatible mirror field cannot index an empty
    array on the none path. That is the kind of one-line guard that is usually missing.
  • The test's own comment documents a mistake you made and fixed by running it (python rendering a one-element list as ['/completion'], quotes
    included, so comparing against a bare [$_want] fails on a correct plan). Keeping that in the file is worth more than the test.

Nit: cover none and a typo, because those are the two claims the comment makes

The loop covers long and short. It does not cover:

value expected why it matters
none 0 warm-ups the spelling ONEBIT_NO_WARMUP already had; nothing asserts the plan actually carries none
an unknown value (e.g. longg) 2 warm-ups (the shipped pair) the exact invariant the comment defends — "a typo must not quietly disable a warm-up" — currently untested
unset / both 2 warm-ups the default path, also untested

Two extra iterations of the same loop with a count assertion (len(d["warmups"]) == 0 / == 2) pin all three, and they are the cheap cases: no
second load, same fake backend. Given that the whole point of this PR is a bisect whose result will be read by someone else, having the selector's
own semantics pinned is what stops a future edit from turning none into the default.

Nothing else needs changing; Refs rather than Fixes is honest (this is the instrument, and the table in the body says what each outcome would
mean).

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.

HRX: flint-40b-a2.5b loads and then returns NaN logits (150144/150144), llama-server aborts 134

1 participant