| --- |
| license: apache-2.0 |
| tags: |
| - executorch |
| - xnnpack |
| - pte |
| - on-device |
| - feature-extraction |
| - sentence-similarity |
| base_model: |
| - Qwen/Qwen3-Embedding-0.6B |
| --- |
| # Qwen3-Embedding-0.6B β ExecuTorch |
|
|
| Multilingual text embeddings from a causal LM: text in, one 1024-dimensional vector out. |
| The largest and most capable embedding model on this shelf, and the only one that is a |
| decoder rather than an encoder. |
|
|
| - **Source**: Qwen/Qwen3-Embedding-0.6B β 595.8M parameters, 28 Qwen3 layers, hidden 1024, |
| 151669 vocabulary |
| - **License**: apache-2.0 |
| - **Input**: `input_ids` and `attention_mask`, both `[1, 256]` int64 |
| - **Output**: `[1, 1024]`, last-token pooled over the mask and L2-normalised inside the graph |
|
|
| ## The pooling is in the graph, and it is a different pooling |
|
|
| `1_Pooling/config.json` says `pooling_mode_lasttoken`. Every other embedding model on this |
| shelf averages over the sequence or takes CLS; this one takes the hidden state of the |
| **last real token**, because a causal model's last position is the only one that has |
| attended to the whole input. |
|
|
| | | pooling | normalised | |
| |---|---|---| |
| | all-MiniLM-L6-v2 | mean | yes | |
| | all-MiniLM-L12-v2 | mean | yes | |
| | bge-small-en-v1.5 | **CLS** | yes | |
| | multilingual-e5-base | mean | yes | |
| | multilingual-e5-large | mean | yes | |
| | **Qwen3-Embedding-0.6B** | **last token** | **yes** | |
| | paraphrase-multilingual-L12 | mean | **no** | |
|
|
| **Pad on either side.** Qwen's reference `last_token_pool` branches on |
| `attention_mask[:, -1]` to tell left padding from right. That branch is data-dependent, so |
| `torch.export` would freeze whichever way the example input happened to be padded and then |
| silently return a pad token's hidden state for the other. This graph instead locates the |
| last set position from the mask itself: |
|
|
| ```python |
| idx = (attention_mask * torch.arange(L)).argmax(dim=1) |
| ``` |
|
|
| Measured against the reference implementation on the same input, both padding sides, |
| max_abs_diff **0.000e+00** β it selects the same token either way. |
|
|
| **And the padding is free on both sides.** Right padding cannot matter to a causal model: |
| a pad at the end attends to nothing earlier. Left padding shifts every real token's RoPE |
| position, which sounds like it should matter and does not β RoPE encodes *differences*, so |
| a constant offset cancels. Measured: `position_ids = arange + 229` and `+ 1000` both leave |
| the vector unchanged to 5e-7, while a **non-uniform** shift (a gap inserted at position 10) |
| moves it to cosine 0.9957. The null result is real, not a disconnected knob. |
|
|
| ## The prefix is not in the graph, and it is asymmetric |
|
|
| `config_sentence_transformers.json` gives the query a whole task instruction and the |
| document the empty string: |
|
|
| ``` |
| query: Instruct: Given a web search query, retrieve relevant passages that answer the query |
| Query:{your query} |
| document: {your document, with nothing in front of it} |
| ``` |
|
|
| Unlike E5's `query: `/`passage: `, there is no symmetric mode documented. Putting the |
| instruction on both sides, or on neither, is not what the model was trained for. It does |
| not throw and does not look wrong β it retrieves worse. |
|
|
| ## Verification |
|
|
| | build | file | size (MB) | Mac median (ms)* | XNNPACK takes | worst cosine vs eager | retrieval budget spent | |
| |---|---|---|---|---|---|---| |
| | XNNPACK fp32 | `embed_qwen3_embedding_0_6b_xnnpack_fp32.pte` | 2383.7 | 184.0 | 80.5% | 1.000000 | 0% | |
| | XNNPACK fp16 | `embed_qwen3_embedding_0_6b_xnnpack_fp16.pte` | 1192.4 | 521.5 | 70.2% | 0.999992 | 2% | |
| | Core ML fp32 | `embed_qwen3_embedding_0_6b_coreml_all.pte` | 1195.1 | **30.0** | 100% | 0.999910 | 17% | |
|
|
| \*Mac arm64, median of 10, one 256-token sequence β a reference point for relative cost, |
| not a device number. Eager fp32 on the same input is 179.7 ms, so the Core ML build is |
| **6.0x eager**, 100% delegated in one subgraph. |
| |
| **The XNNPACK column moved because the export stopped going through |
| `F.scaled_dot_product_attention`.** It decomposes to `_safe_softmax`, whose guard for |
| fully-masked rows is six operations XNNPACK cannot take, once per attention block. This |
| model is right-padded, so no query row can lose every key and the guard can never fire: |
| with 249 of 256 positions masked, `attn_implementation="eager"` agrees with the sdpa graph |
| at cosine 1.00000000 while taking delegation from **71.8% to 80.5%** and fp32 from 203.9 ms |
| to 184.0 ms. |
| |
| Agreement with eager is the first check and it separates nothing here. The one that decides |
| is the last column: rank all eight test sentences against each other, then ask whether this |
| build's score error is smaller than the gap between the document a query retrieves and the |
| runner-up. Every shipped build keeps all 8 top-1 results and spends well under half the |
| available room. |
| |
| The vectors also have to be useful, which agreement cannot show: |
| |
| ``` |
| same language: 0.596 same meaning vs 0.123 unrelated |
| across languages: 0.521 same meaning vs 0.157 unrelated |
| ``` |
| |
| Note how much wider that is than the E5 models on this shelf, which compress everything |
| into 0.66β0.85. Both work; this one is easier to threshold against. |
| |
| ```bash |
| python convert/check_embed.py qwen3_embedding_0_6b fp32 # or fp16, int8, coreml |
| ``` |
| |
| ## Not shipped: int8, and this is the first time it was close |
| |
| **int8 is 1063.9 MB β smaller than fp16's 1192.4 MB.** No other embedding model on this |
| shelf manages that. Dynamic int8 quantises the linear weights and leaves the token |
| embedding table in fp32, so it only wins when that table is a small share of the file, and |
| here it is: 151669 x 1024 = **621 MB of the 2384 MB model, 26%**. Every other embedding |
| model on the shelf is 46β69% table and loses to fp16 on size alone. |
| |
| It is still not published, because the size question and the quality question are |
| different questions: |
| |
| ``` |
| worst retrieval score shift 0.0258 |
| closest fp32 top-1 decision 0.0234 |
| ``` |
| |
| The error is **110% of the room available**. On these eight sentences it happens not to |
| change any result β but eight deliberately dissimilar sentences are the friendliest corpus |
| a retrieval model will ever see, and that 0.0234 is an upper bound on what a real corpus |
| leaves. For scale, the same eight sentences give multilingual-e5-large a top-1 decision of |
| **0.0014**, and *its* int8 build β whose score error is smaller than this one's β does |
| change what one of the eight queries retrieves. |
|
|
| So the bar here is half the decision distance rather than all of it, and int8 misses it. |
| Correlation reads 0.990459, which would also have flagged it; the retrieval measurement is |
| what says by how much and why. |
|
|
| The attention rewrite above made int8 **13% faster (174.8 ms to 151.7 ms) and 7 points |
| more delegated (77.9% to 85.1%)** and did not change this verdict β it moved the score |
| error from 100% of the room to 110%. Speed and quality are separate questions here, and |
| only one of them was ever the reason this build is unpublished. |
|
|
| torch.export -> to_edge_transform_and_lower(partitioner) -> .pte |
| (conversion scripts: [executorch-models](https://github.com/john-rocky/executorch-models)) |
|
|