Repository navigation
serve: ONEBIT_WARMUP selects which warm-up request is sent (engine#1539's bisect) - #1689
Conversation
…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
left a comment
There was a problem hiding this comment.
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.
|
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 hole: an unrecognised value silently means "none", while
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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:longgives only/v1/chat/completions,shortgives only/completion,nonegives no warmup, and an unknown value keeps the shipped pair. The existingONEBIT_NO_WARMUP=1/ embedding / cpu rows are unchanged.- The selection reaches the path the issue needs, not just the plan.
warm_backend()iterateswarmup_requests()(app/serve.cpp:1778), the same function--print-backendemits, so1bit serveand Lemonade see the same requests.noneis folded intowarmup_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 nlohmannoperator[]resize that would have emitted"warmup": null. git merge-treeonto current main is clean (the branch is based on6301cef).
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
left a comment
There was a problem hiding this comment.
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.
VERDICT: READY (1 nit) — the two paths the comment argues for are the two the test does not coverRight instrument for the right reason: the two warm-ups are not interchangeable (one samples, one does not), so making "which one a model cannot
Nit: 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).
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:
sliding_windowis 513), and1bit serveis only where it surfaces firstEvery 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:
ONEBIT_NO_WARMUP=1turns off both. Until now the only way to separate them was to hand-edit the--print-backendplan, which works for the Lemonade path and not for1bit serve— the path in the issue's repro.ONEBIT_WARMUP=long|short|nonenow selects them, and reaches the plan as well (warmup_requests()feeds both), so a Lemonade-driven experiment gets the same selection with no rebuild:both→ the shipped pair, no behaviour change;long→ only the sampling chat request;short→ only the 16-token/completion;none→ neither (the spellingONEBIT_NO_WARMUPalready had);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:
docs/serve.md. Caught by thebogusarm; fixed by normalising anything that is notlong/short/noneto the shipped pair.--print-backendthen indexed the empty array:out["warmup"] = out["warmups"][0], and nlohmann resizes an empty array on non-constoperator[]and insertsnull— 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.shalready builds a crafted GGUF (no GPU,--device cpu/hrxwithtests/fake_backend.py) and already asserts the plan'swarmups. It now also asserts each selection, plus that"warmup"still equalswarmups[0]in every case.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.shfails 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 noci.yml --targetentry is needed (the #1595 trap is foradd_tests naming anadd_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_backendposts both requests and does nothing about the ~485 tokens they leave in slot 0, and this fork does supportPOST /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.