Repository navigation
feat: run the Qwen3 reranker's shared prompt head once per score call - #342
Merged
Merged
Conversation
score tokenizes the head every row starts with (the system prompt, the instruction and the query), runs it once as [1, P] keeping each layer's post-RoPE k and v, then runs each length group of document tails against them, causal, at positions P onward. A row whose ids do not start with the head's (a merge at the boundary, or a row cut inside the head) is scored whole, as before. Claude-Session: https://claude.ai/code/session_01762aPcWc5SxPgH7Munvfj9
… writes Joining the shared head's k and v to each tail's in [B, Nkv, S, D] copied a transposed view and a broadcast before every concatenation, eight extra kernels per layer, which made long documents (mostly one row per length group) slower than whole rows. In [B, S, Nkv, D] both parts are already contiguous, so the join is one copy of each. Claude-Session: https://claude.ai/code/session_01762aPcWc5SxPgH7Munvfj9
candle 0.11's fused Metal attention, given fewer queries than keys, masks only the last ceil((BQ + BK - 1) / BK) key blocks (one more when the keys are not a whole number of blocks). When the shared length is not a multiple of the key block, a query block's diagonal crosses one block more, so some tail positions saw later keys: on Metal bf16, 5 of 100 documents of about 500 tokens moved by up to 1.09 in logit against whole rows. The head now runs once cut to a multiple of 32 (BK is 8, 16 or 32) and its last tokens go with every tail, and a row shares when it starts with those ids. The Metal test covers every tail length modulo 32 at head dims 64 and 128. Claude-Session: https://claude.ai/code/session_01762aPcWc5SxPgH7Munvfj9
|
Greptile reviewed this. Push back if it's wrong - it has no feelings.
[Medium impact] The PR appears safe to merge, with the previous finding fixed and no new blocking issues found. Reviews (2) · Last reviewed commit: "test: compare the reranker tests' score ..." · Reviewed by Greptile |
… a NaN The Metal shared-head test failed on CI: random weights can put every document's probability near 0 or 1 (one local run had all of them between 0.991 and 0.997), so the check that the scores differ, 1e-3 on probabilities, can miss. The check now reads the spread on the logit scale, where those scores still differ. max_gap also asserts that every score is finite. f32::max skips a NaN, so a NaN score used to compare as equal, which the review pointed out.
github-actions Bot
pushed a commit
that referenced
this pull request
Oct 23, 2026
## [7.5.0](v7.4.1...v7.5.0) (2026-10-23) ### 🍕 Features * run the Qwen3 reranker's shared prompt head once per score call ([#342](#342)) ([477c69a](477c69a))
|
🎉 This PR is included in version 7.5.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
Contributor
Author
|
Opened huggingface/candle#4062 for the Metal causal-mask issue described above. Once a candle release has it, the multiple-of-32 rounding here can go. 🍄 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every row
Qwen3Reranker::scorebuilds starts with the same tokens: the system prompt, then<Instruct>: ...,<Query>: ...and<Document>:. Until now each row ran them through all 28 layers again. On the model card's example, 69 of a row's 85 tokens are the same for every document.What changes
scoretokenizes the shared head once. It runs the head's first multiple of 32 tokens once as[1, P]and keeps each layer's k and v after RoPE. Then it runs each length group of tails (the rest of the head, the document and the suffix) against them, causal, at positionsPonward.Pids. When a document's first characters merge with the head's last token, the merge falls in the part that goes with the tail. A row cut inside the shared ids is scored whole, as before.Qwen3Attention::forwardandQwen3DecoderLayer::forwardtake optional earlier k and v and can return their own. They hold k and v as[B, S, Nkv, D], the layout the rope kernel andv_projalready write. That way joining the shared part to a tail is one plain copy of each. My first version joined in[B, Nkv, S, D]. It copied a transposed view and a broadcast before each join, and long documents, which mostly run one row per length group, came out about 9% slower than whole rows.Qwen3Model::forward_causal_afterruns rows after shared k and v. On Metal it uses the fused kernel's causal mask. Elsewhere it uses an additive[1, 1, T, P + T]mask shifted byP.Why a multiple of 32
When candle 0.11's fused Metal attention (
call_sdpa_full) gets fewer queries than keys, it lines its causal mask up with the last keys. It masks only the lastceil((BQ + BK - 1) / BK)key blocks, plus one when the key count is not a whole number of blocks. Ifkeys - queriesis not a multiple of the key block, a query block's diagonal crosses one block more than that. Then some positions see keys after their own. My first measurement found it: on Metal bf16, 5 of 100 documents of about 500 tokens moved by up to 1.09 in logit against whole rows. BK is 8, 16 or 32, so a shared length that is a multiple of 32 keeps the diagonal inside the masked blocks. This is worth an issue upstream in candle.Tests
reranker_scores_with_the_shared_head_as_with_whole_rows(CPU, f32) comparesscorewith whole rows within 1e-5 over documents of 0 to 40 words. It runs with a head that is not a multiple of 32, a 64-token head where one document leaves the shared ids and is scored whole, and a max length that cuts every row inside the shared ids. I broke three things one at a time: removing the fallback, the RoPE offset, or the mask offset each turns it red.reranker_scores_with_the_shared_head_as_with_whole_rows_on_metalcovers every tail length modulo 32 at head dims 64 and 128, in f32 (1e-4) and bf16 (2e-2). It failed on the version without the rounding (3.0e-3 at head dim 64, f32) and fails again with the block set to 1.qwen3_reranker_06b_matches_python(CPU f32 against the model card's Python, 1e-4, plus the mid-relevance logits within 1e-3) andqwen3_reranker_06b_metal_bf16_near_pythonpass with sharing on.Measured
scorewith Qwen3-Reranker-0.6B, Metal bf16, query "What is the capital of China?". The base is the reranker as #339 merged it. Base and new runs alternated in three rounds on a loaded machine (load average 200 to 270).Card-length documents run about 2.6 times faster, and long ones about 12% faster. In every round, all 200 scores match the base at the 8 decimals the bench prints, and so do the logits computed from them. The long-document token count is an estimate from the text's length; I did not count it.
🍄