Skip to content

feat(qwen3): serve Qwen3-Reranker-8B on HIP behind an SGLang-compatible /v1/rerank - #1981

Open
laudney wants to merge 42 commits into
ashhart:mainfrom
laudney:pr-qwen3-reranker-hip
Open

laudney wants to merge 42 commits into
ashhart:mainfrom
laudney:pr-qwen3-reranker-hip

Conversation

@laudney

@laudney laudney commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1977. Stacked on #1978 (the Qwen3 dense HIP core); until #1978 lands, this diff also shows its commits.

The native HIP server serves Qwen3-Reranker-8B as published, through POST /v1/rerank in #1895's rerank shape.

before (#1978) after (this PR)
Qwen/Qwen3-Reranker-8B on an AMD Radeon AI PRO R9700 (gfx1201) refused (no reranker head) served: POST/PUT /v1/rerank (and /rerank)
Against transformers float32, 34 cases (16 to 40,960 tokens): |yes − no| median / worst 0.0195 / 0.0839, gate 0.125 (transformers BF16: 0.0247 / 0.1099)
Against transformers float32: |score| median / worst 0.00023 / 0.0079, gate 0.0125 (transformers BF16: 0.00014 / 0.0108)
Rankings against float32 (5 query groups) 4 in FP32's order; 2 near-tie pairs swapped (FP32 gaps 0.0077, 0.0119), allowed within 0.25 (transformers BF16 swaps one)
Prompt ids against the model card's formatter 79/79 equal: default and 4 custom instructions, 4 max_length cuts
Window 32,768 by default, 40,960 with --context 0; one token past it is context_length_exceeded, the next request still scores
4 requests at once against one at a time; a client that leaves mid-request byte-equal; the next request equals its earlier answer
Device memory, peak 16.4 GB (32,768 window), 16.7 GB (40,960)
16 documents of ~260 tokens, cold, one request 3.22 s; SGLang 0.53 s on the same card (the dot2 GEMM tile; the gfx12 WMMA tile is the follow-up)

What "matches" means. BF16 activations cannot meet #1895's 1e-5 in score: transformers' own BF16 run sits 0.0108 in
score and 0.110 in yes − no from float32 on these cases. The oracle's gate against float32 is that distance rounded up:
|yes − no| ≤ 0.125 and |score| ≤ 0.0125 on every case. Rankings must equal float32's, except that a pair float32 puts
within 0.25 in yes − no (twice the gate) may come in the other order; each such pair is printed, and one beyond 0.25
fails. The recipe (docs/recipes/qwen3-reranker-hip.md) states the same.

Why

Serving the checkpoint as Qwen published it, with no conversion, makes TensorFold usable where SGLang serves it today:
benchmarks compare the same bytes on both engines, and rerank clients reach it with the schema they already send.

What this changes

  • Engine. A qwen3 folder with 1_LogitScore/config.json is a reranker. The family reads its yes and no tokens and
    both lm_head rows at load (a malformed file or row stops startup) and declares Info.rerank; only an engine that
    declares it answers the route. The prompt-only backend from Qwen3-Embedding-8B (BF16) on native HIP: BF16 dense GEMM and a qwen3 embedding engine #1978 answers the lane host's score hook: one prompt's
    final-normed last row, dotted in f64 with the two stored rows (not the 1.24 GB head on the device).
  • Server. zig/src/server/rerank.zig: Proposal: POST /v1/rerank, and Qwen3-Reranker-0.6B scoring on Metal #1895's request (query, documents as strings, {text} objects or text
    parts, instruction, top_n, return_documents, max_length) and SGLang's fields too (instruct, PUT); each
    document rendered with the checkpoint's own template, request text encoded as plain text; past max_length (default
    8,192) the content loses its last tokens, the template's tail kept, as the card's tokenizer cuts; scored as
    sigmoid(yes − no); results (index, relevance_score, document as sent) best first, ties in input order, and
    usage.total_tokens. Errors use the server's envelope; refusals are by name. Opt-in return_logits and
    return_prompt_token_ids add meta_info for checks. A reranker server refuses every other model route by name, and
    every other server answers the route with HTTP 400.
  • Lane host. A drain's deadline stops the score queue: the running score finishes, queued ones fail without
    running. This also reaches /v1/decisions on Flash Next on CUDA.
  • Tools and docs. tools/rerank/qwen3_reranker_oracle.py: pinned sources (revision, every non-weight file, the
    Transformers package tree, shard hashes before load), float32 (CPU) and BF16 references, compare with the gates
    above, and formatter. docs/api.md (Rerank), the recipe, and the CHANGELOG.

Checks

  • zig build test -Dhip=true: 518/518 steps; test-rerank-host 33/34 (1 skip without the checkpoint).
  • Lean: no new findings (424, the base's).
  • The oracle's compare against both references at both windows, formatter, and the device cells above, on the R9700 with ROCm 10.1.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. The engine side holds: sigmoid(yes - no) from the label logits, an f64 dot of the final-normed BF16 row with the stored lm_head rows, no new kernel, checks before any GPU work, stable ranking, other routes refused by name on a reranker, and other families unchanged. Four asks, two of them our decisions:

  1. Response shape (decision): Proposal: POST /v1/rerank, and Qwen3-Reranker-0.6B scoring on Metal #1895's agreed shape (results with index, relevance_score and optional document, plus top_n and return_documents), not SGLang's bare array. One shape for the route, so @grantoverton's planned rerank PR and this one don't collide. Translating SGLang's request fields on input is fine.
  2. Oracle (decision): add the FP32 reference we agreed in Proposal: POST /v1/rerank, and Qwen3-Reranker-0.6B scoring on Metal #1895 and post both. Comparing against transformers BF16 with 0.25/0.02 gates (worst 0.108/0.0082) isn't the bar. Then restate the bound from what FP32 actually shows.
  3. Check the prompt against the model card's formatter, with both the default and a custom instruction.
  4. Document /v1/rerank in docs/api.md and the CHANGELOG, and name the shared lane-host drain change (it also reaches /v1/decisions on Flash Next CUDA).

Smaller points: rerank is detected from a file in the model folder rather than declared by the engine; the label rows are read on the first request; every server now answers /v1/rerank with 400 (fine, but say so); and it measures 6.3x slower than SGLang on 16 documents, worth a note on what's next. It's stacked on #1978, so it lands after that.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. An independent review on current main (ef9fa70):
Verdict: CHANGES (three). Our asks 2, 3 and 4 are still open. Ask 1, the response shape, is met.

Applying it:

Nothing was built or run here. The thread has no reply from the author since our 04:03 comment. The only change is
commit 30.

The four asks

1. The response shape (your decision: #1895's): met. Commit 30 (server/rerank.zig build, :320-350)
returns:

  • an object {"results": [...], "usage": {"total_tokens": N}}, keys in that order;
  • each result {"index", "relevance_score", "document"?}, best first, equal scores in request order, cut to top_n;
  • document is the item as the request sent it: a string, a {text} object or a content-part list;
  • meta_info only when return_logits or return_prompt_token_ids asks for it (meta returns null otherwise, and
    the key is left out).

The request now takes #1895's fields beside SGLang's:

  • instruction, with SGLang's instruct still read;
  • {text} document objects;
  • max_length.

Tests pin the key order, the count of 3 keys per result, usage.total_tokens (46 for the fixture's three prompts),
{text} documents with instruction used and echoed, and three new max_length refusals. The oracle reads
["results"] and relevance_score.

2. The FP32 oracle (your decision): not met.

  • tools/rerank/qwen3_reranker_oracle.py still loads transformers in torch.bfloat16, with the same 0.25 logit and
    0.02 score gates.
  • No FP32 reference was added, no FP32 numbers are posted, and the bound isn't restated.
  • (The float32 path in the series belongs to Qwen3-Embedding-8B (BF16) on native HIP: BF16 dense GEMM and a qwen3 embedding engine #1978's embedding oracle, tools/zig/oracle_qwen3_embedding.py.)
  • Still owed: an FP32 transformers reference beside the BF16 bar, with the worst and median yes−no and score
    differences for both against FP32, and the restated bound with rankings identical.

3. The model card's formatter: not met.

  • No host test renders the card's formatter (default and custom instruction) and compares ids with the route's
    prompt.
  • DEFAULT_INSTRUCT is still defined in the oracle and not used for that.

4. Docs: not met. docs/api.md has no /v1/rerank section and there's no CHANGELOG entry. The docs need:

New in commit 30, for the lead

  1. max_length refuses instead of cutting. Proposal: POST /v1/rerank, and Qwen3-Reranker-0.6B scoring on Metal #1895's plan (@grantoverton's) has max_length default 8192, with
    only the document's content cut to fit. Here it has no default, and a longer prompt is a 400: "… past max_length N;
    this server does not truncate".
    • Our gate didn't fix this either way.
    • But one route needs one meaning: a client that sends max_length gets a cut document on one engine and a 400 on
      the other.
    • Settle it before either PR lands.
  2. The error envelope is still SGLang's. Refusals are by name. The error type is still "Bad Request" for schema
    errors and "BadRequestError" for handler refusals (rerank_test.zig:268-270), not the server's own envelope.
    Whichever is chosen belongs in the docs (ask 4) and in @grantoverton's PR too.

Unchanged since our earlier review (small points still open)

  • Rerank is still detected from 1_LogitScore in the folder (server.zig:186), not declared by the engine. Gating on
    srv.rerank != null and srv.info.decides would close it.
  • The two label rows are still read on the first request.
  • Every server answers /v1/rerank with "this model is not a reranker" (rerank.zig:94).
  • The 6.3× gap to SGLang on 16 documents is the GEMM tile follow-up.

What holds

  • Scoring, precision, the checks before GPU work, stable ranking, the refusal of other routes on a reranker, and
    other families are as found. Commit 30 touches only server/rerank.zig, its two tests and the oracle.
  • No other route, family or engine changes.

Device cells (after changes 2-4)

  • HIP, gfx1201 (Radeon AI PRO R9700) | Qwen3-Reranker-8B at the pinned revision | the oracle's 34 cases against an
    FP32 transformers reference, with transformers BF16's diffs beside them: rankings identical, worst and median
    yes−no and score diffs posted against the restated bound.
  • Any host | Qwen3-Reranker-8B tokenizer and template only | the card-formatter prompt test, default and custom
    instruction: ids equal.
  • HIP, gfx1201 | Qwen3-Reranker-8B | the route in Proposal: POST /v1/rerank, and Qwen3-Reranker-0.6B scoring on Metal #1895's shape:
    • a 32,769-token prompt refused, then a scoring request;
    • a max_length request, as the lead decides;
    • 4 concurrent requests against one at a time, byte-equal;
    • a client that leaves mid-request.
  • HIP, gfx1201 | Qwen3-Embedding-8B (BF16) on native HIP: BF16 dense GEMM and a qwen3 embedding engine #1978's Qwen3-Embedding-8B, and CUDA Flash Next | /v1/embeddings and /v1/decisions unchanged
    against main.

@laudney

laudney commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. All four asks and the smaller points are addressed in five new commits on top. Nothing was force-pushed.

Ask Status Commit
1. #1895's response shape results (index, relevance_score, document as sent), top_n, return_documents, usage.total_tokens; #1895's instruction, {text} documents and max_length (refused, never cut); SGLang's request fields still read f32a1a0
2. FP32 reference transformers float32 on the CPU (8B in FP32 does not fit the 32 GB card), both references posted below, bound restated from FP32 7e6f707, 3175a68
3. Model card formatter 75/75 prompts equal the card's process_inputs ids token for token: 15 with the default instruction, 60 with 4 custom ones (sent as instruction, and once as instruct) 7e6f707
4. docs/api.md and CHANGELOG Rerank section; the CHANGELOG names the lane-host drain change and its reach to /v1/decisions on Flash Next CUDA 32b6de4
Detected from a file the engine now declares it: Info.rerank (yes and no tokens), set by the qwen3 family from 1_LogitScore; the server reads no file, so no other engine answers c696b52
Label rows on first request both lm_head rows read at load; a bad row fails startup c696b52
Every server answers 400 stated in docs/api.md and the CHANGELOG 32b6de4

Fidelity against transformers float32 (same pinned checkpoint and Transformers 5.22.1, 32 cases from 16 to 8,199 tokens, served end to end on an AMD Radeon AI PRO R9700):

Against FP32 |yes − no| median |yes − no| worst |score| median |score| worst
TensorFold (BF16 weights and activations, fp32 sums, f64 label dot) 0.0195 0.0839 0.00013 0.0079
transformers' own BF16 run 0.0247 0.1099 0.00008 0.0108
Rankings against FP32 Query groups in FP32's order Pairs in another order (FP32 yes − no gap)
TensorFold 4 of 5 2 (0.0077 and 0.0119)
transformers BF16 4 of 5 1 (the 0.0077 pair)

Restated bound. #1895's 1e-5 in score is not reachable with BF16 activations: transformers' own BF16 run sits 0.0108 from FP32 on these cases. So against FP32, the oracle now gates at transformers' own BF16 distance, rounded up: |yes − no| ≤ 0.125 and |score| ≤ 0.0125. A ranking may differ from FP32's only for a pair FP32 puts within 0.25 (twice the diff gate). Each swap is printed, and a swap beyond that fails. TensorFold passes. The BF16 reference keeps its gates (worst 0.108 / 0.0082) for the two window cases (32,768 and 40,960 tokens), which the CPU FP32 run does not cover.

Speed. This PR keeps the dot2 GEMM tile. Nearly all of the 6.3x is the seven BF16 projections, which run at about 19 TFLOPS on dot2. The follow-up is a gfx12 WMMA dense tile: a separate PR for #1977. Its probe reaches 39 to 58 TFLOPS on these shapes, and its gate is a per-projection FP64 comparison against dot2. matrix=off keeps today's bits.

@laudney

laudney commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. Your 08:27 review and my 08:27 reply crossed: the review read commit 30 only. Since then the PR is restacked on the rebased #1978 (a56bda4), as you asked. That was a force-push, and the old tip ef3daab is kept. It is 18 commits, all noreply, with CHANGELOG-only conflicts in the restack.

Point Status Commit
1. Response shape met (as you found) 8323634
2. FP32 oracle all 34 cases against transformers float32 (CPU), the 32,768 and 40,960 windows included; BF16 beside it; bound restated below 4bcdc6e, 84a4086
3. Card formatter 79/79 prompts equal the card's process_inputs ids token for token: default and 4 custom instructions, plus 4 max_length cuts 4bcdc6e, e118a45
4. Docs docs/api.md Rerank section (shape, PUT, the two opt-in extensions, envelope, refusals, max_length, the key scope); CHANGELOG names the lane-host drain change and its reach to /v1/decisions on Flash Next CUDA 93ed29f, 20ee121
max_length (lead's call) #1895's meaning: default 8192, and a longer prompt loses its content's last tokens with the template's tail kept, as the card's tokenizer cuts. A cap that would leave no document token is refused, and so is a template whose tail is not fixed e118a45
Error envelope (lead's call) the server's own {"error": {"message", "type"}}, as every route sends; a prompt past the window is context_length_exceeded, param documents e118a45
Rerank detected from a file the engine declares it: Info.rerank (yes and no tokens), set by the qwen3 family from 1_LogitScore; the server reads no file 8e10577
Label rows on first request both lm_head rows read at load 8e10577
Every server answers 400 stated in docs/api.md and the CHANGELOG 93ed29f

Fidelity against transformers float32 (pinned checkpoint, Transformers 5.22.1; 34 cases from 16 to 40,960 tokens; served end to end on an AMD Radeon AI PRO R9700):

Against FP32 |yes − no| median |yes − no| worst |score| median |score| worst
TensorFold 0.0195 0.0839 0.00023 0.0079
transformers BF16 0.0247 0.1099 0.00014 0.0108
Window case FP32 yes − no TensorFold Difference
32,768 tokens +0.9414 +0.9350 0.0064
40,960 tokens +0.7753 +0.7994 0.0241
Rankings against FP32 Query groups in FP32's order Pairs in another order (FP32 yes − no gap)
TensorFold 4 of 5 2 (0.0077 and 0.0119)
transformers BF16 4 of 5 1 (the 0.0077 pair)

Restated bound. The 1e-5 bar in score from #1895 is not reachable with BF16 activations: transformers' own BF16 run sits 0.0108 from FP32. Against FP32 the oracle therefore gates at transformers' own BF16 distance, rounded up: |yes − no| ≤ 0.125 and |score| ≤ 0.0125. Rankings must equal FP32's except for a pair FP32 puts within 0.25 (twice the diff gate, the most two documents within it can move). Each such swap is printed, and a swap past 0.25 fails. On these inputs identical rankings are not reachable for BF16 either: transformers BF16 itself swaps one pair. The two pairs are synthetic river-trade paragraphs whose FP32 scores differ by 0.0017 and 0.0023.

Device cells (gfx1201, R9700, Qwen3-Reranker-8B at the pinned revision):

Cell Result
34 cases against FP32, BF16 beside them PASS at both windows; numbers above
Card formatter, default and custom instruction (tokenizer and template) 79/79 ids equal
32,769-token prompt, then a scoring request refused (context_length_exceeded), next request within the gates
max_length 75 on prompts of 82 and 70 tokens 75 + 70 tokens scored; ids equal the card's cut
4 requests at once against one at a time 4/4 byte-equal
A client that leaves mid-request (16 long documents) the next request equals its earlier answer
Embeddings and decisions unchanged the PR touches neither route's code; full host suite 518/518 steps

Speed. The follow-up gfx12 WMMA dense tile is in progress (separate PR, #1977). On this card it runs Qwen3-8B's projections at 45 to 67 TFLOPS at 276 rows and up to 99 at 2,048, against dot2's 22 to 30. matrix=off keeps dot2's bits.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. The re-review finds all three asks met. The open points are settled #1895's way: max_length defaults to 8,192 and cuts, errors use the server's envelope, and the formatter matches the model card on 79 of 79 cases.

On the bound: accepted. #1895's 1e-5 in score isn't reachable with BF16 activations, since transformers' own BF16 run sits 0.0108 from FP32. So the bar is that distance, rounded up, and this sits inside it on every summary number except the score median (0.00023 against transformers BF16's 0.00014). The ranking difference (two near-tie pairs in another order, FP32 gaps of 0.0077 and 0.0119) is the same kind as transformers BF16's own one. Please put the bound in the PR body and the recipe, so a reader sees what "matches" means here.

This lands right after #1978, which it's stacked on.

laudney added a commit to laudney/TensorFold that referenced this pull request Nov 3, 2026
The review asked the embedding gate to follow the rule accepted for
the reranker in ashhart#1981: transformers' own BF16 distance from fp32,
rounded up, not twice it. The oracle and the GPU test now take the
BF16 bar's worst cosine distance at each width, round it up to two
significant digits with exact power-of-ten steps (never below it),
and cap it at 0.001, so the limits are 1.6e-4, 1.7e-4 and 1.9e-4 at
widths 4096, 1024 and 32; --bar-factor goes, and the recipe states
the new limits beside the posted distances, which already meet them.
@laudney

laudney commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. Done in 90715b4 (fast-forward): the bound is in the PR body ("What 'matches' means", with the summary table) and in a new recipe, docs/recipes/qwen3-reranker-hip.md, beside #1978's embedding recipe.

A checkpoint published in BF16 widened to fp32 by quant.read takes
twice its memory and needs an fp32 kernel; Qwen3-Embedding-8B would
not fit a 32 GiB card that way. quant.readStored reads like read but
keeps a BF16 or FP16 dense tensor as stored, dense.Matrix carries the
weight's dtype for the kernels, and dense.reference reads each
element by its dtype. Callers of quant.read keep fp32 weights and
their bits; the host tests cover both reads, views and the halves.
The repository's lean checker allows only single-line comments of
at most 120 columns, and the stored-dtype change gave the dense
format's module comment a second line. The comment now says the
same in one line, so the checker adds no problem over main.
A BF16 checkpoint kept as stored needs a prompt GEMM that reads BF16
weights; HIP had only the fp32 one-row dense kernel. A BF16 weight
decoder plugs into the existing dot2 GEMM tile with unit scales and
no bias path, which the tile now leaves out for such a decoder; the
MLX tiles compile to the same code. project dispatches a dense
weight by its dtype, and the dense embedding gather reads BF16
tables. GPU tests check it against float64 on gfx1201.
The HIP qwen3 forward runs one shape of Qwen3 dense model, so its
config reader takes the geometry and constants from config.json,
defaults head_dim to 128 as Qwen3Config does, and refuses by name the
features the forward does not run (projection biases, sliding window,
RoPE scaling, head_dim above 256, a non-SiLU MLP, uneven GQA groups),
with host tests on the 8B embedding and reranker configs.
Qwen3's attention has no output gate, so nothing turned the causal
kernel's fp32 (heads, len, d) rows into o_proj's row-major input;
tf_heads_rows does that transpose with one rounding to the output
kind, reached through Ops.headsRows, with an exact kernel test, a
kernel contract (copy and round, served Qwen3-8B shapes) and its
compiled-usage record on all four RDNA chips.
The embedding and reranker checkpoints share one Qwen3 dense body, so
the qwen3 family now uploads it as stored (BF16 projections, fp32
norms, an optional model. prefix), reads lm_head rows on the host for
the reranker, and runs lastRows: inputs batched layer by layer, each
attending only to itself from position 0, final-normed last rows out.
It adds the module, host tests and an env-gated GPU check of both
checkpoints against transformers.
Qwen3-Embedding-8B is trained for any output width from 32 to 4096,
and the embedding route only took widths from a declared list, so a
request for 384 or 2049 would be refused. The lane's embedding Info
gains mrl_min: with it set, every width from that one to the full
dimension crops; the list rule and the refusal without either stay.
Tests cover both bounds, widths between them and the old rules.
Qwen3-Reranker-4B ties its output head to the embedding and stores no
lm_head.weight, so readRows of the head failed for it; the model now
records the stored name of its head rows (lm_head.weight, or the
embedding as the checkpoint names it when tie_word_embeddings is set)
and the GPU check reads that name, with host tests for both layouts.
The embedding route and lane contract had no device backend, and the
HIP registry no qwen3 family. A prompt-only engine sizes its scratch
to the memory budget, shrinking the window to fit, and a lane backend
runs each batch's whole prompt in one step and returns the pooled
last rows, unit-normed when modules.json lists Normalize; the head is
read from the sentence-transformers folder. The registry gains
qwen3, a family's Loaded can say it serves embeddings in its own
window, and one-GPU families refuse --tp before any group forms.
The embedding gate compares the HIP engine with the model card's
Transformers example, so the reference must come from known sources.
The oracle pins the checkpoint revision, the SHA256 of every file in
the snapshot, the Transformers 5.22.1 package tree and its Qwen3
files, checks them before any model code runs, writes them into each
reference, and the server check refuses a reference made otherwise.
It also checks distance, norms, batch and token-ID equality.
Loading Qwen3-Embedding-8B took about 400 s, warm cache or not. The
loader released the checkpoint after every layer, and that release
also drops each whole shard from the page cache, so every later
layer's upload faulted its pages back in from disk. The loader now
keeps the mapping until the checkpoint closes at the end of the
load; the same server loads in 4 s and serves the same vectors.
Every kernel must pass the contract's gates before it lands, and the
BF16 dense GEMM had only its unit test. It now runs gates 1 to 4 for
fp32 and BF16 output at Qwen3-8B's projection shapes, on BF16 builds,
and gate 5 records its compiled usage on gfx1100, gfx1151 and
gfx1201. A record can mark a chip not served, as gfx1030's FP16 build
keeps only the kernel's trap body; the audits skip such a chip for a
launch, never for a planted control.
The embedding gate's limit comes from the BF16 bar's distance to the
fp32 target, so a bar of other inputs or a non-finite vector could
loosen it or make it pass on NaN. The oracle checker and the GPU
test now refuse references that differ in dtype, texts, token ids or
pins, or hold a ragged, zero or non-finite vector, and fail on any
non-finite distance before comparing.
The unit test and the kernel contract bounded the BF16 dense GEMM's
error with different rounding chains, and the contract's ignored the
fold every 128 inputs, allowing about 28 times more error at K 12288.
Both now take one chain from the launcher: a group's dot2s, then one
add a group. Every contract shape still passes within it.
The GPU gate checked only the revision and the Transformers tree of
its references, so a record without its checkpoint, weight or
library hashes still passed where the oracle's checker refuses it.
Both references must now carry the complete record, the same in
each, and a refused reference fails without printing a diff.
Two references could carry the same wrong pin maps and still pass the
GPU gate, which never looked at the checkpoint it loads. The test now
hashes every pinned checkpoint and weight file in the directory it
loads and requires every shard of the index to be pinned, before any
GPU work; a host test refuses a wrong hash and an unpinned shard.
Probing SGLang's Qwen3-Embedding-8B server with the same requests as
this one showed two gaps: whitespace-only text was embedded, and one
input longer than the context was refused only as an over-full
batch. Text that is empty or only Unicode whitespace, as Python's
strip sees it, is now refused with its index in a list, and an input
past the context is refused with its token count before the batch's
own limit applies.
The BF16 dense GEMM runs only where the build has BF16 activations;
on an FP16 build (RDNA2) its kernel keeps only a trap body, and the
qwen3 engine failed late as a scratch count error. It now refuses
before the checkpoint loads, naming the GPU it was built for.
The new qwen3 HIP family needs a recipe like the other native
models: what it serves and refuses, how to build and serve it on
gfx1201 with ROCm 10.1's hipcc, the request form the model card
uses, and the pinned oracle and GPU gate with their results on a
Radeon AI PRO R9700.
Review of ashhart#1978 asked for the request refusals this branch adds to
every embedding family to be written down. A new Embeddings section
in docs/api.md lists the whitespace-only text refusal, the
over-long input that is named with its token count and the
context_length_exceeded code before the batch limit, the batch
limit itself, and how dimensions crops to an MRL width, and the
CHANGELOG gains the Qwen3-Embedding-8B entry that points to it.
Review of ashhart#1978 asked for the pinned fidelity set to cover 2K to 32K
inputs and MRL widths 32, 1024 and 4096. The oracle adds five long
inputs, each the longest whole-line prefix of a pinned file within
2K to 32K tokens, pins every input's text and token IDs by SHA256,
runs the long ones alone at full length, and checks a server in
requests that fit its window, at full width and at each MRL width
against the cropped fp32 vector; the GPU test covers the same nine
inputs, and the recipe records the measured distances.
Merging main put hip_records.zig past lean_check's 600-line limit,
so the tf_heads_rows record joins the BF16 dense GEMM record in
hip_records_qwen3.zig, whose tests are now collected from the
contract module's test block; the record values and chip order are
unchanged. The oracle's new comments and one docstring become single
lines, so lean_check reports the same problems as main.
The review asked the embedding gate to follow the rule accepted for
the reranker in ashhart#1981: transformers' own BF16 distance from fp32,
rounded up, not twice it. The oracle and the GPU test now take the
BF16 bar's worst cosine distance at each width, round it up to two
significant digits with exact power-of-ten steps (never below it),
and cap it at 0.001, so the limits are 1.6e-4, 1.7e-4 and 1.9e-4 at
widths 4096, 1024 and 32; --bar-factor goes, and the recipe states
the new limits beside the posted distances, which already meet them.
The engine thread took the whole label-score queue and ran every
call even after a drain set closing, so long rerank prompts could run
past the drain's deadline; answer now reads closing under the host
lock before each call, finishes the one already started and fails the
rest without running them, with a test that closes the host mid-queue.
A Qwen3 reranker scores by its yes and no logits, and the checkpoint
names those tokens in 1_LogitScore/config.json, so the server now
reads them at startup into Server.rerank, treats a missing file as a
plain model and refuses a malformed one by name rather than silently
hiding the route, with a test-rerank-host step for the reranker tests.
Clients of the SGLang reranker service call /v1/rerank, so the server
now serves it for a checkpoint with reranker labels: each document is
rendered with the checkpoint template (request text masked as plain),
checked against the window, scored through the engine's score hook as
sigmoid(yes - no) and returned best first in SGLang's compact body and
error envelope, with opt-in raw logits and prompt ids for receipts; a
reranker server refuses every other engine route in routes.zig, which
covers chat and the other text routes before any body is read.
Native reranker scores need an independent reference, so this tool
renders the checkpoint template as SGLang does, records transformers'
ids and the yes-minus-no logit from the final-normed last row in f64
(plus the BF16-logit score), and its compare mode drives a server's
/v1/rerank with the raw-logit and id extensions to gate ids, logits,
scores, rankings, the masked special case and the window boundary.
The reranker's scores are only comparable to SGLang's when the route
feeds the engine the same prompt ids, so this env-gated test loads the
checkpoint's own tokenizer and template, sends every oracle case
through /v1/rerank and requires the ids the engine scored to equal
transformers' (both served windows included) and the masked
special-token case to differ.
A Qwen3 reranker reads only its yes and no logits, so its score hook
needs a couple of head rows, not the 1.24 GB head on the device; the
new score module checks a call before any GPU work (no mass, prompt
inside the window and vocabulary, at most 16 labels inside the head)
and turns the widened last row into f64 label logits against head
rows it reads once from the checkpoint and keeps.
A reference is only worth its sources, so the reranker oracle now pins
both reranker snapshots (revision and every non-weight file's sha256)
and the Transformers 5.22.1 package tree, checks them before any
model code runs (also as a sources command that loads nothing), hashes
the weight shards before loading, refuses an incomplete load or an
untied head, and writes its receipt whole or not at all.
A Qwen3-Reranker checkpoint declares its yes and no tokens in
1_LogitScore, so the qwen3 HIP family now takes that folder as the
reranker head: the engine's prompt backend answers the lane host's
score hook with one prompt's last row and f64 label logits from head
rows on the host, the family reports decides and no embeddings, and
the HIP registry passes decides into the served capabilities, so
/v1/rerank serves the checkpoint and the text routes refuse it.
A client of SGLang's reranker could still hit refusals or error types
TensorFold does not share, so the route now validates the whole body
the way SGLang's schema does (pydantic's lax ints and bools, text
content parts joined as SGLang extracts them, malformed parts refused)
before the handler's own checks, labels schema errors "Bad Request"
and handler refusals "BadRequestError", and takes PUT as SGLang does.
test-rerank-host carries an env-gated check of real prompt ids
against transformers, but Zig cached the step's run, so setting
TF_QWEN3_RERANK_DIR and _REF replayed an earlier skip; the run step
now has side effects, as the checkpoint loader tests do, so the ids
check runs whenever its variables are set.
The maintainer chose one rerank shape for every family, ashhart#1895's, so
the route now returns results (index, relevance_score, the document
as sent) best first with usage.total_tokens, takes ashhart#1895's request
fields (instruction, {text} documents, max_length enforced as a
limit, since this server does not truncate) beside SGLang's, and
keeps the raw logits and ids extensions behind their opt-in flags.
The review asked that the engine, not a file in the served folder,
make a server a reranker, as embedding engines declare .embeddings,
and that the label rows not wait for the first request: Info gains
rerank (the yes and no tokens), the qwen3 family parses and checks
1_LogitScore and reads both lm_head rows at load, and the server
drops its own reader, so no other engine can answer /v1/rerank.
The review asked for the route in docs/api.md and the CHANGELOG, and
for the shared lane-host change to be named: api.md gains a Rerank
section (fields, prompt, score, response, refusals, the key scope),
and the CHANGELOG entry states that the score queue now stops at a
drain's deadline, which also reaches /v1/decisions on Flash Next.
The review asked for ashhart#1895's FP32 transformers reference and for the
prompt checked against the model card's own formatter: the oracle
takes --dtype float32 --device cpu (8B FP32 does not fit the GPU),
records dtype, device and torch in its file, and a formatter command
compares every served prompt's ids with the card's process_inputs,
with the default instruction and custom ones sent both ways.
The review asked to restate the bound from what FP32 shows: against
an FP32 reference, compare now gates |yes - no| at 0.125 and the
score at 0.0125 (transformers' own BF16 run sits 0.110 and 0.0108
from FP32), lists each pair ranked in another order than FP32's and
fails one only beyond twice the diff gate, and prints the median,
p99 and worst; a BF16 reference keeps its gates.
The review asked for one max_length meaning with ashhart#1895's plan and a
settled error envelope: max_length now defaults to 8192 and a longer
prompt loses its content's last tokens, the template's tail kept, as
the model card's tokenizer cuts it (refused where no document token
would stay, or where the template's tail is not fixed), the window
refusal is a context-length error, and errors use the envelope every
route sends; the oracle checks four cuts against the card's ids.
The route's max_length now cuts a prompt's content as the model
card's tokenizer does, with 8192 by default, and its errors use the
server's own envelope: docs/api.md states both and the window error's
context_length_exceeded code, and the CHANGELOG entry names the cut.
On the rebased ashhart#1978 base zig/build/cuda.zig reached 601 lines, past
the lean checker's 600, with this PR's three lines for the rerank
host step; the step moves unchanged (filter, side effects, name and
description) to zig/build/rerank_host.zig, called with one line, as
proposal_vocab_host.zig does.
The review asked for the bound in the recipe, so a reader sees what
"matches" means: the new recipe names the files, the build and serve
lines, the request, the oracle's FP32 and BF16 references and
formatter check, the restated gates (transformers BF16's own distance
from FP32, rounded up, and the ranking rule) and today's results.
@laudney
laudney force-pushed the pr-qwen3-reranker-hip branch from 90715b4 to 158489e Compare November 3, 2026 18:32
@laudney

laudney commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Restacked on #1978's rebased head a5c4e9dee (on main f23603801), so this diff shows #1978's current commits again. The 19 commits are otherwise unchanged; two resolutions:

On the restacked stack (R9700): qwen3-hip-gpu-test passes (embedding gates, all 34 reranker cases, worst |yes − no| 0.108 against the BF16 gate's 0.25), lean 425 = the new base's. zig build test -Dhip=true passes except exl3_load's allocation-failure test, which fails the same way on a5c4e9dee alone in the same checkout directory (NondeterministicMemoryUsage; it passes in a checkout with a longer path), so it is not from this stack. The old head is kept as a backup.

@laudney

laudney commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Reranker GPU test on this PR's restacked head 158489e50 (on #1978's a5c4e9dee), for a card you can't run: AMD Radeon AI PRO R9700 (gfx1201), ROCm 10.1 (HIP 7.16.26385), Qwen/Qwen3-Reranker-8B at revision 77d193c791ed757ca307ee72715aa132723da912, reference from tools/rerank/qwen3_reranker_oracle.py (transformers BF16, 34 pinned cases of 16 to 40,960 tokens).

TF_QWEN3_RERANK_DIR=MODEL_DIRECTORY TF_QWEN3_RERANK_REF=reference.json \
  zig build qwen3-hip-gpu-test -Dhip=true -Dhip-arch=gfx1201 -Dhipcc=/opt/rocm/core/bin/hipcc --summary all

The skipped test is the embedding check (no TF_QWEN3_EMBED_* set; #1978's comment has it). Each rerank line is the engine's yes − no and score against transformers BF16's:

rerank card 85 tokens: diff 5.2012 ref 5.1713 score 0.994520 ref 0.994355
rerank card 108 tokens: diff -14.2402 ref -14.2330 score 0.000001 ref 0.000001
rerank card 81 tokens: diff -13.1411 ref -13.1140 score 0.000002 ref 0.000002
rerank card 104 tokens: diff 5.2706 ref 5.2029 score 0.994886 ref 0.994529
rerank fixed 95 tokens: diff 0.7895 ref 0.7577 score 0.687715 ref 0.680850
rerank fixed 93 tokens: diff -15.0287 ref -15.1365 score 0.000000 ref 0.000000
rerank fixed 86 tokens: diff 1.1678 ref 1.1448 score 0.762753 ref 0.758556
rerank fixed 79 tokens: diff -10.8844 ref -10.8436 score 0.000019 ref 0.000020
rerank fixed 89 tokens: diff 5.6286 ref 5.6668 score 0.996419 ref 0.996553
rerank fixed 91 tokens: diff 2.2759 ref 2.3025 score 0.906859 ref 0.909081
rerank fixed 88 tokens: diff -8.7393 ref -8.7349 score 0.000160 ref 0.000161
rerank fixed 78 tokens: diff 4.6909 ref 4.6105 score 0.990905 ref 0.990151
rerank len16 96 tokens: diff 2.1825 ref 2.2120 score 0.898669 ref 0.901318
rerank len16 103 tokens: diff 1.8733 ref 1.8404 score 0.866836 ref 0.862995
rerank len16 110 tokens: diff 2.7951 ref 2.8156 score 0.942410 ref 0.943515
rerank len128 208 tokens: diff 0.6882 ref 0.6815 score 0.665564 ref 0.664083
rerank len128 215 tokens: diff 0.6690 ref 0.6558 score 0.661284 ref 0.658316
rerank len128 222 tokens: diff 0.4272 ref 0.3958 score 0.605206 ref 0.597677
rerank len512 592 tokens: diff 1.1004 ref 1.0570 score 0.750328 ref 0.742112
rerank len512 599 tokens: diff 1.1815 ref 1.1798 score 0.765220 ref 0.764914
rerank len512 606 tokens: diff 1.0824 ref 1.0541 score 0.746953 ref 0.741570
rerank len2048 2128 tokens: diff 1.2201 ref 1.2464 score 0.772086 ref 0.776682
rerank len8192 8272 tokens: diff 1.3722 ref 1.3563 score 0.797730 ref 0.795153
rerank repeat 99 tokens: diff -12.8114 ref -12.8160 score 0.000003 ref 0.000003
rerank repeat 224 tokens: diff -13.5060 ref -13.5612 score 0.000001 ref 0.000001
rerank repeat 81 tokens: diff -13.1411 ref -13.1140 score 0.000002 ref 0.000002
rerank repeat 137 tokens: diff -13.8213 ref -13.8322 score 0.000001 ref 0.000001
rerank repeat 93 tokens: diff -13.0730 ref -13.0921 score 0.000002 ref 0.000002
rerank repeat 195 tokens: diff -13.3218 ref -13.2137 score 0.000002 ref 0.000002
rerank repeat 105 tokens: diff -12.8686 ref -12.9579 score 0.000003 ref 0.000002
rerank repeat 108 tokens: diff -14.2402 ref -14.2330 score 0.000001 ref 0.000001
rerank repeat 87 tokens: diff -13.9058 ref -13.8752 score 0.000001 ref 0.000001
rerank window32768 32768 tokens: diff 0.9350 ref 0.9152 score 0.718085 ref 0.714072
rerank window40960 40960 tokens: diff 0.7994 ref 0.7761 score 0.689844 ref 0.684834
rerank: 34 cases, worst |diff| 0.1081 (gate 0.25), worst |score| 0.00822 (gate 0.02)
Build Summary: 16/16 steps succeeded; 6/7 tests passed (1 skipped)
+- run test 6 pass, 1 skip (7 total) 1m MaxRSS:15G

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.

2 participants