Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +4 -0
- DeepSeek_V41_Tech_Report.pdf +3 -0
- assets/dsv41_agentic_performance.png +3 -0
- assets/dsv41_kv_cache.png +3 -0
- encoding/README.md +305 -0
- encoding/encoding.py +979 -0
- encoding/test_encoding.py +558 -0
- encoding/tests/test_input_1.json +82 -0
- encoding/tests/test_input_2.json +24 -0
- encoding/tests/test_input_3.json +93 -0
- encoding/tests/test_input_4.json +28 -0
- encoding/tests/test_input_5.json +39 -0
- encoding/tests/test_output_1.txt +38 -0
- encoding/tests/test_output_2.txt +1 -0
- encoding/tests/test_output_3.txt +35 -0
- encoding/tests/test_output_4.txt +2 -0
- encoding/tests/test_output_5.txt +11 -0
- evaluation/README.md +103 -0
- evaluation/dsh-minimal.patch +711 -0
- inference/README.md +71 -0
- inference/convert.py +205 -0
- inference/engram.py +184 -0
- inference/examples/example.txt +7 -0
- inference/examples/example_harmony.json +96 -0
- inference/examples/images/carrots.jpeg +3 -0
- inference/examples/images/corn.jpeg +0 -0
- inference/generate.py +218 -0
- inference/image_processor.py +173 -0
- inference/kernel.py +591 -0
- inference/model.py +1309 -0
- inference/requirements.txt +9 -0
- inference/run.sh +54 -0
- inference/vision.py +119 -0
- model-00002-of-00048.safetensors +3 -0
- model-00003-of-00048.safetensors +3 -0
- model-00019-of-00048.safetensors +3 -0
- model-00020-of-00048.safetensors +3 -0
- model-00021-of-00048.safetensors +3 -0
- model-00022-of-00048.safetensors +3 -0
- model-00039-of-00048.safetensors +3 -0
- model-00040-of-00048.safetensors +3 -0
- model-00042-of-00048.safetensors +3 -0
- model-00043-of-00048.safetensors +3 -0
- model-00044-of-00048.safetensors +3 -0
- model-00045-of-00048.safetensors +3 -0
- model-00046-of-00048.safetensors +3 -0
- model-00047-of-00048.safetensors +3 -0
- model-00048-of-00048.safetensors +3 -0
- recipe/.gitignore +7 -0
- recipe/LICENSE +21 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/dsv41_agentic_performance.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/dsv41_kv_cache.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
inference/examples/images/carrots.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
DeepSeek_V41_Tech_Report.pdf filter=lfs diff=lfs merge=lfs -text
|
DeepSeek_V41_Tech_Report.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ba68e2e40408125ae6d2f63a9a241b61c73910691c74ec1a2a7023c851eac08d
|
| 3 |
+
size 1809802
|
assets/dsv41_agentic_performance.png
ADDED
|
Git LFS Details
|
assets/dsv41_kv_cache.png
ADDED
|
Git LFS Details
|
encoding/README.md
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DeepSeek-V4.1 text and vision encoding
|
| 2 |
+
|
| 3 |
+
`encoding.py` is the standalone prompt-format reference for DeepSeek-V4.1. It
|
| 4 |
+
supports multi-turn conversations, tool calls, thinking modes, numeric reasoning
|
| 5 |
+
effort, mid-conversation system messages, and interleaved image content blocks,
|
| 6 |
+
without importing the inference implementation.
|
| 7 |
+
|
| 8 |
+
## V4.1 changes relative to V4
|
| 9 |
+
|
| 10 |
+
Three prompt-format changes distinguish V4.1 from V4:
|
| 11 |
+
|
| 12 |
+
1. **DSML tag names use a leading space.** Tool calls are wrapped in
|
| 13 |
+
`<|DSML| calls>` blocks with `<|DSML| invoke>` / `<|DSML| parameter>` tags
|
| 14 |
+
(note the space before `calls`, `invoke`, and `parameter`). The V4 format used
|
| 15 |
+
`<|DSML|tool_calls>` without a space.
|
| 16 |
+
|
| 17 |
+
2. **Reasoning effort is a numeric budget (1–100).** The effort prefix is
|
| 18 |
+
rendered as `Reasoning Effort: {budget} (range 1-100, ...)` rather than the
|
| 19 |
+
verbose natural-language descriptions used in V4. String aliases map as
|
| 20 |
+
follows: `"low"` → 50, `"high"` → 75, `"max"` → 100. The
|
| 21 |
+
default is `"high"` (75). The effort prefix is only rendered in
|
| 22 |
+
`thinking_mode="thinking"` and only at the beginning of the conversation
|
| 23 |
+
(index 0).
|
| 24 |
+
|
| 25 |
+
3. **Mid-conversation system messages** are supported via the `<|System|>` token.
|
| 26 |
+
A mid-conversation system message behaves like a user message for the purpose
|
| 27 |
+
of appending the assistant generation header.
|
| 28 |
+
|
| 29 |
+
## Quick start
|
| 30 |
+
|
| 31 |
+
```python
|
| 32 |
+
from encoding import encode_messages, parse_message_from_completion_text
|
| 33 |
+
|
| 34 |
+
# Text-only conversation
|
| 35 |
+
messages = [
|
| 36 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 37 |
+
{"role": "user", "content": "What is 2+2?"},
|
| 38 |
+
]
|
| 39 |
+
prompt, media = encode_messages(
|
| 40 |
+
messages,
|
| 41 |
+
thinking_mode="thinking",
|
| 42 |
+
reasoning_effort=75, # integer 1–100, or "low"/"high"/"max"
|
| 43 |
+
return_multi_modal_data=True,
|
| 44 |
+
)
|
| 45 |
+
# prompt:
|
| 46 |
+
# '<|begin▁of▁sentence|><|System|>Reasoning Effort: 75 (range 1-100, the higher the
|
| 47 |
+
# value, the more thorough the reasoning)\n\nYou are a helpful assistant.
|
| 48 |
+
# <|User|>What is 2+2?<|Assistant|><think>'
|
| 49 |
+
|
| 50 |
+
# Parse model output back to a structured message
|
| 51 |
+
completion = "Simple arithmetic.</think>2 + 2 = 4.<|end▁of▁sentence|>"
|
| 52 |
+
parsed = parse_message_from_completion_text(completion, thinking_mode="thinking")
|
| 53 |
+
# => {"role": "assistant", "reasoning_content": "Simple arithmetic.",
|
| 54 |
+
# "content": "2 + 2 = 4.", "tool_calls": []}
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
> **Note:** `parse_message_from_completion_text` is designed to handle
|
| 58 |
+
> well-formatted model output only. It does not attempt to correct or recover
|
| 59 |
+
> from malformed output that the model might occasionally generate. For
|
| 60 |
+
> production use, additional error handling is recommended.
|
| 61 |
+
|
| 62 |
+
## OpenAI-style messages
|
| 63 |
+
|
| 64 |
+
```python
|
| 65 |
+
from encoding import encode_messages
|
| 66 |
+
|
| 67 |
+
messages = [{
|
| 68 |
+
"role": "user",
|
| 69 |
+
"content": [
|
| 70 |
+
{"type": "text", "text": "第一张图"},
|
| 71 |
+
{
|
| 72 |
+
"type": "image_url",
|
| 73 |
+
"image_url": {"url": "examples/images/image_1.jpeg"},
|
| 74 |
+
},
|
| 75 |
+
{"type": "text", "text": "有什么内容?"},
|
| 76 |
+
],
|
| 77 |
+
}]
|
| 78 |
+
|
| 79 |
+
prompt, media = encode_messages(
|
| 80 |
+
messages,
|
| 81 |
+
thinking_mode="chat",
|
| 82 |
+
return_multi_modal_data=True,
|
| 83 |
+
)
|
| 84 |
+
# prompt:
|
| 85 |
+
# '<|begin▁of▁sentence|><|User|>第一张图\n\n<|deepseek_image|>\n\n有什么内容?<|Assistant|></think>'
|
| 86 |
+
# media["images"] contains the image records in prompt order
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
Images are represented in the prompt by `<|deepseek_image|>`. `media["images"]`
|
| 90 |
+
contains the corresponding image records in exactly the same order they appear in
|
| 91 |
+
the prompt. Pixel loading and expansion into model image tokens are handled by
|
| 92 |
+
`inference/image_processor.py`.
|
| 93 |
+
|
| 94 |
+
## Compact TXT notation
|
| 95 |
+
|
| 96 |
+
`parse_tagged_text()` converts a compact prompt such as
|
| 97 |
+
|
| 98 |
+
```text
|
| 99 |
+
第一张图<image>examples/images/image_1.jpeg</image>有什么内容?
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
into the same standard content blocks. It is an input convenience layer, not a
|
| 103 |
+
second encoding implementation.
|
| 104 |
+
|
| 105 |
+
## Message format
|
| 106 |
+
|
| 107 |
+
### Special tokens
|
| 108 |
+
|
| 109 |
+
| Token | Purpose |
|
| 110 |
+
| :--- | :--- |
|
| 111 |
+
| `<|begin▁of▁sentence|>` | Beginning of sequence (BOS) |
|
| 112 |
+
| `<|end▁of▁sentence|>` | End of assistant turn (EOS) |
|
| 113 |
+
| `<|User|>` | User turn prefix |
|
| 114 |
+
| `<|Assistant|>` | Assistant turn prefix |
|
| 115 |
+
| `<|System|>` | Mid-conversation system message prefix |
|
| 116 |
+
| `<|latest_reminder|>` | Latest reminder (date, locale, etc.) |
|
| 117 |
+
| `<think>` / `</think>` | Reasoning block delimiters |
|
| 118 |
+
| `|DSML|` | DSML markup token |
|
| 119 |
+
| `<|deepseek_image|>` | Image placeholder in the prompt string |
|
| 120 |
+
|
| 121 |
+
### Roles
|
| 122 |
+
|
| 123 |
+
The encoding supports the following message roles: `system`, `user`, `assistant`,
|
| 124 |
+
`tool`, and `latest_reminder`.
|
| 125 |
+
|
| 126 |
+
A `tool` message is not rendered directly: `merge_tool_messages()` converts it
|
| 127 |
+
into a `<tool_result>` block inside the preceding user message. When multiple
|
| 128 |
+
tool results are present, they are sorted by the order of the corresponding
|
| 129 |
+
`tool_calls` in the preceding assistant message.
|
| 130 |
+
|
| 131 |
+
### Basic chat
|
| 132 |
+
|
| 133 |
+
A simple multi-turn conversation is encoded as:
|
| 134 |
+
|
| 135 |
+
```
|
| 136 |
+
<|begin▁of▁sentence|>{system_prompt}
|
| 137 |
+
<|User|>{user_message}<|Assistant|></think>{response}<|end▁of▁sentence|>
|
| 138 |
+
<|User|>{user_message_2}<|Assistant|></think>{response_2}<|end▁of▁sentence|>
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
- The BOS token is prepended at the very beginning of the conversation.
|
| 142 |
+
- In **chat mode** (`thinking_mode="chat"`), `</think>` is placed right after
|
| 143 |
+
`<|Assistant|>` to immediately close the thinking block, so the model generates
|
| 144 |
+
content directly.
|
| 145 |
+
|
| 146 |
+
### Thinking mode
|
| 147 |
+
|
| 148 |
+
In **thinking mode** (`thinking_mode="thinking"`), the model produces explicit
|
| 149 |
+
reasoning inside `<think>...</think>` blocks before responding.
|
| 150 |
+
|
| 151 |
+
```
|
| 152 |
+
<|begin▁of▁sentence|><|System|>{reasoning_effort_prefix}{system_prompt}
|
| 153 |
+
<|User|>{message}<|Assistant|><think>{reasoning}</think>{response}<|end▁of▁sentence|>
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
The reasoning effort prefix is injected once, before the system message, as a
|
| 157 |
+
`<|System|>` block:
|
| 158 |
+
|
| 159 |
+
```
|
| 160 |
+
<|System|>Reasoning Effort: {budget} (range 1-100, the higher the value, the more thorough the reasoning)
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
The `drop_thinking` parameter (default `True`) controls whether reasoning from
|
| 164 |
+
earlier turns is preserved:
|
| 165 |
+
|
| 166 |
+
- **Without tools**: reasoning content from assistant turns **before** the last
|
| 167 |
+
user message is stripped. Only the final assistant turn retains its
|
| 168 |
+
`<think>...</think>` block.
|
| 169 |
+
- **With tools**: `drop_thinking` is automatically disabled. All turns retain
|
| 170 |
+
their reasoning, because tool-calling conversations require full context for
|
| 171 |
+
the model to track multi-step reasoning across tool calls.
|
| 172 |
+
|
| 173 |
+
### Tool calling (DSML format)
|
| 174 |
+
|
| 175 |
+
Tools are defined on the `system` message via the `tools` field
|
| 176 |
+
(OpenAI-compatible format). When tools are present, the following schema block is
|
| 177 |
+
injected into the system prompt:
|
| 178 |
+
|
| 179 |
+
```
|
| 180 |
+
## Tools
|
| 181 |
+
|
| 182 |
+
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML| calls>" block like the following:
|
| 183 |
+
|
| 184 |
+
<|DSML| calls>
|
| 185 |
+
<|DSML| invoke name="$TOOL_NAME">
|
| 186 |
+
<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
|
| 187 |
+
...
|
| 188 |
+
</|DSML| invoke>
|
| 189 |
+
<|DSML| invoke name="$TOOL_NAME2">
|
| 190 |
+
...
|
| 191 |
+
</|DSML| invoke>
|
| 192 |
+
</|DSML| calls>
|
| 193 |
+
|
| 194 |
+
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
|
| 195 |
+
|
| 196 |
+
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
|
| 197 |
+
|
| 198 |
+
Otherwise, output directly after </think> with tool calls or final response.
|
| 199 |
+
|
| 200 |
+
### Available Tool Schemas
|
| 201 |
+
|
| 202 |
+
{tool_definitions_json}
|
| 203 |
+
|
| 204 |
+
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
An actual tool call in the assistant turn looks like:
|
| 208 |
+
|
| 209 |
+
```xml
|
| 210 |
+
|
| 211 |
+
<|DSML| calls>
|
| 212 |
+
<|DSML| invoke name="function_name">
|
| 213 |
+
<|DSML| parameter name="param" string="true">string_value</|DSML| parameter>
|
| 214 |
+
<|DSML| parameter name="count" string="false">5</|DSML| parameter>
|
| 215 |
+
</|DSML| invoke>
|
| 216 |
+
</|DSML| calls><|end▁of▁sentence|>
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
- `string="true"`: the parameter value is a raw string.
|
| 220 |
+
- `string="false"`: the parameter value is JSON (number, boolean, array, object).
|
| 221 |
+
|
| 222 |
+
Tool execution results are wrapped in `<tool_result>` tags within user messages:
|
| 223 |
+
|
| 224 |
+
```
|
| 225 |
+
<|User|><tool_result>{result_json}</tool_result><|Assistant|><think>...
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
### Tool namespaces
|
| 229 |
+
|
| 230 |
+
Tool definitions may include a `namespace` alongside `function`, either as a
|
| 231 |
+
string or as an object with `name` and an optional `description`:
|
| 232 |
+
|
| 233 |
+
```python
|
| 234 |
+
tool = {
|
| 235 |
+
"type": "function",
|
| 236 |
+
"namespace": {"name": "search", "description": "Search tools."},
|
| 237 |
+
"function": {
|
| 238 |
+
"name": "lookup",
|
| 239 |
+
"description": "Look up a value",
|
| 240 |
+
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
|
| 241 |
+
},
|
| 242 |
+
}
|
| 243 |
+
tool_call = {
|
| 244 |
+
"type": "function",
|
| 245 |
+
"namespace": "search",
|
| 246 |
+
"function": {"name": "lookup", "arguments": '{"query": "value"}'},
|
| 247 |
+
}
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
The tool schema and DSML invocation both use `search::lookup`. The namespace
|
| 251 |
+
description is prepended to the tool description, separated by a newline.
|
| 252 |
+
The parser returns `function.name="lookup"` and `namespace="search"` on the
|
| 253 |
+
tool call, so its output can be passed back to `encode_messages()` directly.
|
| 254 |
+
|
| 255 |
+
Input also accepts `namespace` inside `function`, or a qualified function name
|
| 256 |
+
such as `search::lookup`. A qualified name must agree with any explicit
|
| 257 |
+
namespace; `::` separates exactly one namespace from the tool name. Tools
|
| 258 |
+
without a namespace retain their original names and output format.
|
| 259 |
+
|
| 260 |
+
### Reasoning effort
|
| 261 |
+
|
| 262 |
+
Pass `reasoning_effort` as an integer in `[1, 100]` or as one of `"low"` (50),
|
| 263 |
+
`"high"` (75), or `"max"` (100). The default is `"high"` (75).
|
| 264 |
+
The setting only affects `thinking_mode="thinking"` and is only rendered at the
|
| 265 |
+
start of the conversation (index 0). Intermediate values may be used to elicit
|
| 266 |
+
interpolated reasoning behavior.
|
| 267 |
+
|
| 268 |
+
### Quick instruction special tokens
|
| 269 |
+
|
| 270 |
+
Quick instruction tokens are used for auxiliary classification and generation
|
| 271 |
+
tasks. They are appended to messages via the `"task"` field to trigger
|
| 272 |
+
specialized model behavior for a single-token or short-form output.
|
| 273 |
+
|
| 274 |
+
| Special Token | Description | Format |
|
| 275 |
+
|:---|:---|:---|
|
| 276 |
+
| `<|action|>` | Determines whether the user prompt requires a web search or can be answered directly. | `...<|User|>{prompt}<|Assistant|><think><|action|>` |
|
| 277 |
+
| `<|title|>` | Generates a concise conversation title after the first assistant response. | `...<|Assistant|>{response}<|end▁of▁sentence|><|title|>` |
|
| 278 |
+
| `<|query|>` | Generates search queries for the user prompt. | `...<|User|>{prompt}<|query|>` |
|
| 279 |
+
| `<|authority|>` | Classifies the user prompt's demand for source authoritativeness. | `...<|User|>{prompt}<|authority|>` |
|
| 280 |
+
| `<|domain|>` | Identifies the domain of the user prompt. | `...<|User|>{prompt}<|domain|>` |
|
| 281 |
+
| `<|read_url|>` | Determines whether each URL in the user prompt should be fetched and read. | `...<|User|>{prompt}<|read_url|>` |
|
| 282 |
+
|
| 283 |
+
Usage in message format:
|
| 284 |
+
|
| 285 |
+
- **`action`** on a user message: the `<|action|>` token is placed after the
|
| 286 |
+
assistant prefix and thinking token, triggering a routing decision (e.g.,
|
| 287 |
+
"Search" or "Answer").
|
| 288 |
+
- **Other tasks** (`query`, `authority`, `domain`, `read_url`) on a user message:
|
| 289 |
+
the task token is appended directly after the user content.
|
| 290 |
+
- **`title`** on an assistant message: the `<|title|>` token is appended after
|
| 291 |
+
the assistant's EOS. The next assistant message provides the generated title.
|
| 292 |
+
|
| 293 |
+
## Tests
|
| 294 |
+
|
| 295 |
+
From this directory:
|
| 296 |
+
|
| 297 |
+
```bash
|
| 298 |
+
python -m pytest -q test_encoding.py
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
Test cases are stored as paired JSON input / TXT expected-output files under
|
| 302 |
+
`tests/`. The tests cover multi-turn conversations, tool calling, thinking mode,
|
| 303 |
+
numeric reasoning effort, mid-conversation system messages, and multimodal image
|
| 304 |
+
ordering. They include a check that the TXT and JSON examples encode to the same
|
| 305 |
+
prompt and preserve the same image ordering.
|
encoding/encoding.py
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DeepSeek-V4.1 Text and Vision Encoding
|
| 3 |
+
|
| 4 |
+
A fully self-contained implementation for encoding/decoding DeepSeek-V4.1 chat
|
| 5 |
+
messages with tool calling, thinking mode, quick instruction tasks, and image
|
| 6 |
+
content blocks. No dependency on encoding_dsv4.
|
| 7 |
+
|
| 8 |
+
V4.1 changes relative to V4:
|
| 9 |
+
|
| 10 |
+
1. DSML tag names: tool calls are wrapped in "<|DSML| calls>" blocks with
|
| 11 |
+
"<|DSML| invoke>" / "<|DSML| parameter>" tags (leading-space tag names).
|
| 12 |
+
2. Numeric reasoning effort: "Reasoning Effort: {budget} (range 1-100, ...)".
|
| 13 |
+
Accepts an int in [1, 100] or one of "low"/"high"/"max"
|
| 14 |
+
(mapped to 50/75/100). Defaults to "high". Only rendered in thinking mode.
|
| 15 |
+
3. Mid-conversation system messages are supported via the "<|System|>" token.
|
| 16 |
+
A mid-conversation system message behaves like a user message for the purpose
|
| 17 |
+
of appending the assistant generation header.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from typing import Any, Dict, List, Union, Optional, Tuple
|
| 21 |
+
import copy
|
| 22 |
+
import json
|
| 23 |
+
import re
|
| 24 |
+
|
| 25 |
+
# ============================================================
|
| 26 |
+
# Special Tokens
|
| 27 |
+
# ============================================================
|
| 28 |
+
|
| 29 |
+
bos_token: str = "<|begin▁of▁sentence|>"
|
| 30 |
+
eos_token: str = "<|end▁of▁sentence|>"
|
| 31 |
+
thinking_start_token: str = "<think>"
|
| 32 |
+
thinking_end_token: str = "</think>"
|
| 33 |
+
dsml_token: str = "|DSML|"
|
| 34 |
+
|
| 35 |
+
USER_SP_TOKEN = "<|User|>"
|
| 36 |
+
ASSISTANT_SP_TOKEN = "<|Assistant|>"
|
| 37 |
+
LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"
|
| 38 |
+
|
| 39 |
+
IMAGE_PLACEHOLDER = "<|deepseek_image|>"
|
| 40 |
+
IMAGE_TAG_PATTERN = re.compile(r"<image>(.*?)</image>", re.DOTALL)
|
| 41 |
+
|
| 42 |
+
# Task special tokens for internal classification tasks
|
| 43 |
+
DS_TASK_SP_TOKENS = {
|
| 44 |
+
"action": "<|action|>",
|
| 45 |
+
"query": "<|query|>",
|
| 46 |
+
"authority": "<|authority|>",
|
| 47 |
+
"domain": "<|domain|>",
|
| 48 |
+
"title": "<|title|>",
|
| 49 |
+
"read_url": "<|read_url|>",
|
| 50 |
+
}
|
| 51 |
+
VALID_TASKS = set(DS_TASK_SP_TOKENS.keys())
|
| 52 |
+
|
| 53 |
+
# ============================================================
|
| 54 |
+
# Templates
|
| 55 |
+
# ============================================================
|
| 56 |
+
|
| 57 |
+
system_msg_template: str = "{content}"
|
| 58 |
+
user_msg_template: str = "{content}"
|
| 59 |
+
latest_reminder_msg_template: str = "{content}"
|
| 60 |
+
assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token
|
| 61 |
+
assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"
|
| 62 |
+
thinking_template: str = "{reasoning_content}"
|
| 63 |
+
|
| 64 |
+
response_format_template: str = (
|
| 65 |
+
"## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
tool_output_template: str = (
|
| 69 |
+
"<tool_result>{content}</tool_result>"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# ============================================================
|
| 73 |
+
# Utility Functions
|
| 74 |
+
# ============================================================
|
| 75 |
+
|
| 76 |
+
def to_json(value: Any) -> str:
|
| 77 |
+
"""Serialize a value to JSON string."""
|
| 78 |
+
try:
|
| 79 |
+
return json.dumps(value, ensure_ascii=False)
|
| 80 |
+
except:
|
| 81 |
+
return json.dumps(value, ensure_ascii=True)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def tools_from_openai_format(tools):
|
| 85 |
+
"""Extract function definitions with namespace-qualified names."""
|
| 86 |
+
functions = []
|
| 87 |
+
for tool in tools:
|
| 88 |
+
function = dict(tool["function"])
|
| 89 |
+
if tool.get("namespace") is not None:
|
| 90 |
+
function["namespace"] = tool["namespace"]
|
| 91 |
+
function["name"] = _tool_name_for_encoding(function)
|
| 92 |
+
namespace = function.pop("namespace", None)
|
| 93 |
+
if isinstance(namespace, dict) and namespace.get("description"):
|
| 94 |
+
function["description"] = (
|
| 95 |
+
namespace["description"] + "\n" + (function.get("description") or "")
|
| 96 |
+
)
|
| 97 |
+
functions.append(function)
|
| 98 |
+
return functions
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _split_tool_name(name: str, namespace: Optional[str] = None) -> Tuple[Optional[str], str]:
|
| 102 |
+
"""Split a qualified name and validate any explicit namespace."""
|
| 103 |
+
prefix, separator, bare_name = name.partition("::")
|
| 104 |
+
if separator:
|
| 105 |
+
assert namespace in (None, prefix), (
|
| 106 |
+
f"Conflicting tool namespaces: {namespace} != {prefix}"
|
| 107 |
+
)
|
| 108 |
+
namespace, name = prefix, bare_name
|
| 109 |
+
assert "::" not in name, f"Tool name must not contain '::': {name}"
|
| 110 |
+
assert namespace is None or "::" not in namespace, (
|
| 111 |
+
f"Tool namespace must not contain '::': {namespace}"
|
| 112 |
+
)
|
| 113 |
+
return namespace, name
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _tool_name_for_encoding(tool: Dict[str, Any]) -> str:
|
| 117 |
+
namespace = tool.get("namespace")
|
| 118 |
+
if isinstance(namespace, dict):
|
| 119 |
+
namespace = namespace["name"]
|
| 120 |
+
namespace, name = _split_tool_name(tool["name"], namespace)
|
| 121 |
+
return name if namespace is None else f"{namespace}::{name}"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def tool_calls_from_openai_format(tool_calls):
|
| 125 |
+
"""Convert OpenAI-format tool calls to internal format."""
|
| 126 |
+
calls = []
|
| 127 |
+
for tool_call in tool_calls:
|
| 128 |
+
function = tool_call["function"]
|
| 129 |
+
namespace, name = _split_tool_name(
|
| 130 |
+
function["name"], tool_call.get("namespace") or function.get("namespace")
|
| 131 |
+
)
|
| 132 |
+
call = {"name": name, "arguments": function["arguments"]}
|
| 133 |
+
if namespace is not None:
|
| 134 |
+
call["namespace"] = namespace
|
| 135 |
+
calls.append(call)
|
| 136 |
+
return calls
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def tool_calls_to_openai_format(tool_calls):
|
| 140 |
+
"""Convert internal tool calls to OpenAI format."""
|
| 141 |
+
calls = []
|
| 142 |
+
for tool_call in tool_calls:
|
| 143 |
+
call = {
|
| 144 |
+
"type": "function",
|
| 145 |
+
"function": {
|
| 146 |
+
"name": tool_call["name"],
|
| 147 |
+
"arguments": tool_call["arguments"],
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
if tool_call.get("namespace") is not None:
|
| 151 |
+
call["namespace"] = tool_call["namespace"]
|
| 152 |
+
calls.append(call)
|
| 153 |
+
return calls
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]:
|
| 157 |
+
"""
|
| 158 |
+
Decode DSML parameters back to a tool call dict.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
tool_name: Name of the tool.
|
| 162 |
+
tool_args: Dict mapping param_name -> (value, is_string_flag).
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
Dict with "name", "arguments" (JSON string), and optional "namespace".
|
| 166 |
+
"""
|
| 167 |
+
def _decode_value(key: str, value: str, string: str):
|
| 168 |
+
if string == "true":
|
| 169 |
+
value = to_json(value)
|
| 170 |
+
return f"{to_json(key)}: {value}"
|
| 171 |
+
|
| 172 |
+
tool_args_json = "{" + ", ".join([_decode_value(k, v, string=is_str) for k, (v, is_str) in tool_args.items()]) + "}"
|
| 173 |
+
namespace, name = _split_tool_name(tool_name)
|
| 174 |
+
tool_call = dict(name=name, arguments=tool_args_json)
|
| 175 |
+
if namespace is not None:
|
| 176 |
+
tool_call["namespace"] = namespace
|
| 177 |
+
return tool_call
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ============================================================
|
| 181 |
+
# Preprocessing
|
| 182 |
+
# ============================================================
|
| 183 |
+
|
| 184 |
+
def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 185 |
+
"""
|
| 186 |
+
Merge tool messages into the preceding user message using content_blocks format.
|
| 187 |
+
|
| 188 |
+
DeepSeek-V4.1 does not have a standalone "tool" role; instead, tool results
|
| 189 |
+
are encoded as <tool_result> blocks within user messages.
|
| 190 |
+
"""
|
| 191 |
+
merged: List[Dict[str, Any]] = []
|
| 192 |
+
|
| 193 |
+
for msg in messages:
|
| 194 |
+
msg = copy.deepcopy(msg)
|
| 195 |
+
role = msg.get("role")
|
| 196 |
+
|
| 197 |
+
if role == "tool":
|
| 198 |
+
# Convert tool message to a user message with tool_result block
|
| 199 |
+
tool_block = {
|
| 200 |
+
"type": "tool_result",
|
| 201 |
+
"tool_use_id": msg.get("tool_call_id", ""),
|
| 202 |
+
"content": msg.get("content", ""),
|
| 203 |
+
}
|
| 204 |
+
# Merge into previous message if it's already a user (merged tool)
|
| 205 |
+
if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1]:
|
| 206 |
+
merged[-1]["content_blocks"].append(tool_block)
|
| 207 |
+
else:
|
| 208 |
+
merged.append({
|
| 209 |
+
"role": "user",
|
| 210 |
+
"content_blocks": [tool_block],
|
| 211 |
+
})
|
| 212 |
+
elif role == "user":
|
| 213 |
+
content_blocks = msg.get("content_blocks")
|
| 214 |
+
if content_blocks is None:
|
| 215 |
+
content_blocks = [{"type": "text", "text": msg.get("content", "")}]
|
| 216 |
+
if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1] and merged[-1].get("task") is None:
|
| 217 |
+
merged[-1]["content_blocks"].extend(content_blocks)
|
| 218 |
+
else:
|
| 219 |
+
# Preserve structured content and all message-level metadata.
|
| 220 |
+
new_msg = msg
|
| 221 |
+
new_msg["content_blocks"] = content_blocks
|
| 222 |
+
merged.append(new_msg)
|
| 223 |
+
else:
|
| 224 |
+
merged.append(msg)
|
| 225 |
+
|
| 226 |
+
return merged
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def sort_tool_results_by_call_order(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 230 |
+
"""
|
| 231 |
+
Sort tool_result blocks within user messages by the order of tool_calls
|
| 232 |
+
in the preceding assistant message.
|
| 233 |
+
"""
|
| 234 |
+
last_tool_call_order: Dict[str, int] = {}
|
| 235 |
+
|
| 236 |
+
for msg in messages:
|
| 237 |
+
role = msg.get("role")
|
| 238 |
+
if role == "assistant" and msg.get("tool_calls"):
|
| 239 |
+
last_tool_call_order = {}
|
| 240 |
+
for idx, tc in enumerate(msg["tool_calls"]):
|
| 241 |
+
tc_id = tc.get("id") or tc.get("function", {}).get("id", "")
|
| 242 |
+
if tc_id:
|
| 243 |
+
last_tool_call_order[tc_id] = idx
|
| 244 |
+
|
| 245 |
+
elif role == "user" and msg.get("content_blocks"):
|
| 246 |
+
tool_blocks = [b for b in msg["content_blocks"] if b.get("type") == "tool_result"]
|
| 247 |
+
if len(tool_blocks) > 1 and last_tool_call_order:
|
| 248 |
+
sorted_blocks = sorted(
|
| 249 |
+
tool_blocks,
|
| 250 |
+
key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0)
|
| 251 |
+
)
|
| 252 |
+
sorted_idx = 0
|
| 253 |
+
new_blocks = []
|
| 254 |
+
for block in msg["content_blocks"]:
|
| 255 |
+
if block.get("type") == "tool_result":
|
| 256 |
+
new_blocks.append(sorted_blocks[sorted_idx])
|
| 257 |
+
sorted_idx += 1
|
| 258 |
+
else:
|
| 259 |
+
new_blocks.append(block)
|
| 260 |
+
msg["content_blocks"] = new_blocks
|
| 261 |
+
|
| 262 |
+
return messages
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ============================================================
|
| 266 |
+
# Vision Message Preprocessing
|
| 267 |
+
# ============================================================
|
| 268 |
+
|
| 269 |
+
def parse_tagged_text(text: str) -> Union[str, List[Dict[str, Any]]]:
|
| 270 |
+
"""Convert ``<image>path</image>`` text into standard content blocks."""
|
| 271 |
+
matches = list(IMAGE_TAG_PATTERN.finditer(text))
|
| 272 |
+
remaining = IMAGE_TAG_PATTERN.sub("", text)
|
| 273 |
+
if "<image>" in remaining or "</image>" in remaining:
|
| 274 |
+
raise ValueError("Malformed <image>path</image> tag")
|
| 275 |
+
if not matches:
|
| 276 |
+
return text
|
| 277 |
+
|
| 278 |
+
blocks: List[Dict[str, Any]] = []
|
| 279 |
+
cursor = 0
|
| 280 |
+
for match in matches:
|
| 281 |
+
if match.start() > cursor:
|
| 282 |
+
blocks.append({"type": "text", "text": text[cursor:match.start()]})
|
| 283 |
+
path = match.group(1)
|
| 284 |
+
if not path:
|
| 285 |
+
raise ValueError("Image path must not be empty")
|
| 286 |
+
blocks.append({
|
| 287 |
+
"type": "image_url",
|
| 288 |
+
"image_url": {"url": path},
|
| 289 |
+
})
|
| 290 |
+
cursor = match.end()
|
| 291 |
+
if cursor < len(text):
|
| 292 |
+
blocks.append({"type": "text", "text": text[cursor:]})
|
| 293 |
+
return blocks
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _is_image_block(block: Dict[str, Any]) -> bool:
|
| 297 |
+
"""Return whether a content block is an OpenAI/Anthropic/internal image."""
|
| 298 |
+
return isinstance(block, dict) and block.get("type") in ("image", "image_url")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _extract_image(block: Dict[str, Any]) -> Dict[str, Any]:
|
| 302 |
+
"""Normalize a supported image block into an internal image record."""
|
| 303 |
+
record: Dict[str, Any] = {"type": "image"}
|
| 304 |
+
if block.get("type") == "image_url":
|
| 305 |
+
image_url = block.get("image_url")
|
| 306 |
+
if isinstance(image_url, str):
|
| 307 |
+
record["url"] = image_url
|
| 308 |
+
else:
|
| 309 |
+
record["url"] = (image_url or {}).get("url", "")
|
| 310 |
+
else:
|
| 311 |
+
for key in ("source", "url", "data"):
|
| 312 |
+
if key in block:
|
| 313 |
+
record[key] = block[key]
|
| 314 |
+
if not any(record.get(key) for key in ("source", "url", "data")):
|
| 315 |
+
raise ValueError("Image block does not contain a valid source")
|
| 316 |
+
return record
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _process_image_blocks(
|
| 320 |
+
blocks: List[Any], image_placeholder: str = IMAGE_PLACEHOLDER
|
| 321 |
+
) -> Tuple[List[Any], List[Dict[str, Any]]]:
|
| 322 |
+
"""Replace image blocks and collect their records in one ordered traversal."""
|
| 323 |
+
new_blocks: List[Any] = []
|
| 324 |
+
images: List[Dict[str, Any]] = []
|
| 325 |
+
for block in blocks:
|
| 326 |
+
if not isinstance(block, dict):
|
| 327 |
+
new_blocks.append(block)
|
| 328 |
+
continue
|
| 329 |
+
if _is_image_block(block):
|
| 330 |
+
new_blocks.append({"type": "text", "text": image_placeholder})
|
| 331 |
+
images.append(_extract_image(block))
|
| 332 |
+
elif block.get("type") == "tool_result" and isinstance(block.get("content"), list):
|
| 333 |
+
block = copy.copy(block)
|
| 334 |
+
block["content"], nested_images = _process_image_blocks(
|
| 335 |
+
block["content"], image_placeholder)
|
| 336 |
+
new_blocks.append(block)
|
| 337 |
+
images.extend(nested_images)
|
| 338 |
+
elif block.get("type") == "text":
|
| 339 |
+
text = block.get("text") or ""
|
| 340 |
+
if IMAGE_PLACEHOLDER in text:
|
| 341 |
+
raise ValueError(
|
| 342 |
+
f"Text block contains image placeholder '{IMAGE_PLACEHOLDER}': "
|
| 343 |
+
f"'{text[:100]}'. Images should be separate content blocks."
|
| 344 |
+
)
|
| 345 |
+
new_blocks.append(block)
|
| 346 |
+
else:
|
| 347 |
+
new_blocks.append(block)
|
| 348 |
+
return new_blocks, images
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _validate_no_image_sp_tokens(msg: Dict[str, Any]) -> None:
|
| 352 |
+
"""Reject user-supplied image placeholder tokens in textual fields."""
|
| 353 |
+
content = msg.get("content")
|
| 354 |
+
if isinstance(content, str) and IMAGE_PLACEHOLDER in content:
|
| 355 |
+
raise ValueError(
|
| 356 |
+
f"Message content contains image special token '{IMAGE_PLACEHOLDER}'. "
|
| 357 |
+
"Images should be provided as image content blocks."
|
| 358 |
+
)
|
| 359 |
+
reasoning_content = msg.get("reasoning_content")
|
| 360 |
+
if isinstance(reasoning_content, str) and IMAGE_PLACEHOLDER in reasoning_content:
|
| 361 |
+
raise ValueError(
|
| 362 |
+
f"reasoning_content contains image special token '{IMAGE_PLACEHOLDER}'"
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def process_image_messages(
|
| 367 |
+
messages: List[Dict[str, Any]],
|
| 368 |
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
| 369 |
+
"""Normalize image blocks and return their records in prompt order."""
|
| 370 |
+
processed: List[Dict[str, Any]] = []
|
| 371 |
+
images: List[Dict[str, Any]] = []
|
| 372 |
+
for msg in messages:
|
| 373 |
+
msg = copy.deepcopy(msg)
|
| 374 |
+
_validate_no_image_sp_tokens(msg)
|
| 375 |
+
|
| 376 |
+
if isinstance(msg.get("content"), list) and "content_blocks" not in msg:
|
| 377 |
+
msg["content_blocks"] = msg.pop("content")
|
| 378 |
+
|
| 379 |
+
if msg.get("content_blocks"):
|
| 380 |
+
msg["content_blocks"], message_images = _process_image_blocks(
|
| 381 |
+
msg["content_blocks"])
|
| 382 |
+
images.extend(message_images)
|
| 383 |
+
if not isinstance(msg.get("content"), str):
|
| 384 |
+
texts = [
|
| 385 |
+
block.get("text", "")
|
| 386 |
+
for block in msg["content_blocks"]
|
| 387 |
+
if isinstance(block, dict) and block.get("type") == "text"
|
| 388 |
+
]
|
| 389 |
+
msg["content"] = "\n\n".join(texts)
|
| 390 |
+
|
| 391 |
+
processed.append(msg)
|
| 392 |
+
return processed, images
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _read_until_stop(index: int, text: str, stop: List[str]) -> Tuple[int, str, Optional[str]]:
|
| 396 |
+
"""
|
| 397 |
+
Read text from index until one of the stop strings is found.
|
| 398 |
+
|
| 399 |
+
Returns:
|
| 400 |
+
Tuple of (new_index, content_before_stop, matched_stop_string_or_None).
|
| 401 |
+
"""
|
| 402 |
+
min_pos = len(text)
|
| 403 |
+
matched_stop = None
|
| 404 |
+
|
| 405 |
+
for s in stop:
|
| 406 |
+
pos = text.find(s, index)
|
| 407 |
+
if pos != -1 and pos < min_pos:
|
| 408 |
+
min_pos = pos
|
| 409 |
+
matched_stop = s
|
| 410 |
+
|
| 411 |
+
if matched_stop:
|
| 412 |
+
content = text[index:min_pos]
|
| 413 |
+
return min_pos + len(matched_stop), content, matched_stop
|
| 414 |
+
else:
|
| 415 |
+
content = text[index:]
|
| 416 |
+
return len(text), content, None
|
| 417 |
+
|
| 418 |
+
# ============================================================
|
| 419 |
+
# V4.1 Special Tokens and DSML Tag Names
|
| 420 |
+
# ============================================================
|
| 421 |
+
|
| 422 |
+
SYSTEM_SP_TOKEN = "<|System|>"
|
| 423 |
+
|
| 424 |
+
tool_calls_block_name: str = " calls"
|
| 425 |
+
tool_call_tag_name: str = " invoke"
|
| 426 |
+
tool_parameter_tag_name: str = " parameter"
|
| 427 |
+
|
| 428 |
+
tool_call_template: str = (
|
| 429 |
+
"<{dsml_token}{tool_call_tag_name} name=\"{name}\">\n{arguments}\n</{dsml_token}{tool_call_tag_name}>"
|
| 430 |
+
)
|
| 431 |
+
tool_calls_template = (
|
| 432 |
+
"<{dsml_token}{tc_block_name}>\n{tool_calls}\n</{dsml_token}{tc_block_name}>"
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
# ============================================================
|
| 436 |
+
# Reasoning Effort (numeric budget)
|
| 437 |
+
# ============================================================
|
| 438 |
+
|
| 439 |
+
REASONING_EFFORT_TEMPLATE = (
|
| 440 |
+
"Reasoning Effort: {budget} "
|
| 441 |
+
"(range 1-100, the higher the value, the more thorough the reasoning)\n\n"
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
REASONING_EFFORT_MAPPINGS: Dict[str, int] = {
|
| 445 |
+
"low": 50,
|
| 446 |
+
"high": 75,
|
| 447 |
+
"max": 100,
|
| 448 |
+
}
|
| 449 |
+
DEFAULT_REASONING_EFFORT = "high"
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def render_reasoning_effort(
|
| 453 |
+
index: int,
|
| 454 |
+
thinking_mode: str,
|
| 455 |
+
effort: Union[str, int, None],
|
| 456 |
+
) -> str:
|
| 457 |
+
"""Render the V4.1 numeric reasoning effort prefix (thinking mode, index 0 only)."""
|
| 458 |
+
if effort is None:
|
| 459 |
+
effort = DEFAULT_REASONING_EFFORT
|
| 460 |
+
assert (
|
| 461 |
+
type(effort) is int and 1 <= effort <= 100
|
| 462 |
+
) or effort in REASONING_EFFORT_MAPPINGS, (
|
| 463 |
+
"Invalid reasoning effort for deepseek_v41: "
|
| 464 |
+
f"{effort}, should be int within [1,100] or {list(REASONING_EFFORT_MAPPINGS)}"
|
| 465 |
+
)
|
| 466 |
+
if type(effort) is str:
|
| 467 |
+
effort = REASONING_EFFORT_MAPPINGS[effort]
|
| 468 |
+
if index == 0 and thinking_mode == "thinking":
|
| 469 |
+
return REASONING_EFFORT_TEMPLATE.format(budget=effort)
|
| 470 |
+
return ""
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
# ============================================================
|
| 474 |
+
# Tools rendering
|
| 475 |
+
# ============================================================
|
| 476 |
+
|
| 477 |
+
TOOLS_TEMPLATE = """## Tools
|
| 478 |
+
|
| 479 |
+
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}{tc_block_name}>" block like the following:
|
| 480 |
+
|
| 481 |
+
<{dsml_token}{tc_block_name}>
|
| 482 |
+
<{dsml_token}{tool_call_tag_name} name="$TOOL_NAME">
|
| 483 |
+
<{dsml_token}{tool_parameter_tag_name} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}{tool_parameter_tag_name}>
|
| 484 |
+
...
|
| 485 |
+
</{dsml_token}{tool_call_tag_name}>
|
| 486 |
+
<{dsml_token}{tool_call_tag_name} name="$TOOL_NAME2">
|
| 487 |
+
...
|
| 488 |
+
</{dsml_token}{tool_call_tag_name}>
|
| 489 |
+
</{dsml_token}{tc_block_name}>
|
| 490 |
+
|
| 491 |
+
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
|
| 492 |
+
|
| 493 |
+
If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
|
| 494 |
+
|
| 495 |
+
Otherwise, output directly after {thinking_end_token} with tool calls or final response.
|
| 496 |
+
|
| 497 |
+
### Available Tool Schemas
|
| 498 |
+
|
| 499 |
+
{tool_schemas}
|
| 500 |
+
|
| 501 |
+
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
|
| 502 |
+
"""
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:
|
| 506 |
+
"""Render tool schemas into the V4.1 system prompt format."""
|
| 507 |
+
tools_json = [to_json(t) for t in tools]
|
| 508 |
+
|
| 509 |
+
return TOOLS_TEMPLATE.format(
|
| 510 |
+
tool_schemas="\n".join(tools_json),
|
| 511 |
+
dsml_token=dsml_token,
|
| 512 |
+
tc_block_name=tool_calls_block_name,
|
| 513 |
+
tool_call_tag_name=tool_call_tag_name,
|
| 514 |
+
tool_parameter_tag_name=tool_parameter_tag_name,
|
| 515 |
+
thinking_start_token=thinking_start_token,
|
| 516 |
+
thinking_end_token=thinking_end_token,
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def encode_arguments_to_dsml(tool_call: Dict[str, Any]) -> str:
|
| 521 |
+
"""Encode tool call arguments into V4.1 DSML parameter format."""
|
| 522 |
+
p_dsml_template = (
|
| 523 |
+
'<{dsml_token}{tool_parameter_tag_name} name="{key}" string="{is_str}">'
|
| 524 |
+
'{value}</{dsml_token}{tool_parameter_tag_name}>'
|
| 525 |
+
)
|
| 526 |
+
P_dsml_strs = []
|
| 527 |
+
|
| 528 |
+
arguments = tool_call["arguments"]
|
| 529 |
+
if not isinstance(arguments, dict):
|
| 530 |
+
# Tolerate JSON strings, including double-encoded ones.
|
| 531 |
+
for _ in range(2):
|
| 532 |
+
if isinstance(arguments, str):
|
| 533 |
+
try:
|
| 534 |
+
arguments = json.loads(arguments)
|
| 535 |
+
except Exception:
|
| 536 |
+
break
|
| 537 |
+
else:
|
| 538 |
+
break
|
| 539 |
+
if not isinstance(arguments, dict):
|
| 540 |
+
arguments = {"arguments": tool_call["arguments"]}
|
| 541 |
+
|
| 542 |
+
for k, v in arguments.items():
|
| 543 |
+
P_dsml_strs.append(p_dsml_template.format(
|
| 544 |
+
dsml_token=dsml_token,
|
| 545 |
+
tool_parameter_tag_name=tool_parameter_tag_name,
|
| 546 |
+
key=k,
|
| 547 |
+
is_str="true" if isinstance(v, str) else "false",
|
| 548 |
+
value=v if isinstance(v, str) else to_json(v),
|
| 549 |
+
))
|
| 550 |
+
|
| 551 |
+
return "\n".join(P_dsml_strs)
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
# ============================================================
|
| 555 |
+
# Message Rendering
|
| 556 |
+
# ============================================================
|
| 557 |
+
|
| 558 |
+
def find_last_user_index(messages: List[Dict[str, Any]]) -> int:
|
| 559 |
+
"""
|
| 560 |
+
Find the index of the last user message.
|
| 561 |
+
|
| 562 |
+
V4.1 supports mid-conversation system messages, which count as user
|
| 563 |
+
messages for the purposes of the assistant generation header.
|
| 564 |
+
"""
|
| 565 |
+
last_user_index = -1
|
| 566 |
+
for idx in range(len(messages) - 1, -1, -1):
|
| 567 |
+
role = messages[idx].get("role")
|
| 568 |
+
if role == "user" or (role == "system" and idx > 0):
|
| 569 |
+
last_user_index = idx
|
| 570 |
+
break
|
| 571 |
+
return last_user_index
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def render_message(
|
| 575 |
+
index: int,
|
| 576 |
+
messages: List[Dict[str, Any]],
|
| 577 |
+
thinking_mode: str,
|
| 578 |
+
drop_thinking: bool = True,
|
| 579 |
+
reasoning_effort: Union[str, int, None] = None,
|
| 580 |
+
) -> str:
|
| 581 |
+
"""
|
| 582 |
+
Render a single message at the given index into its V4.1 encoded string form.
|
| 583 |
+
"""
|
| 584 |
+
assert 0 <= index < len(messages)
|
| 585 |
+
assert thinking_mode in ["chat", "thinking"], f"Invalid thinking_mode `{thinking_mode}`"
|
| 586 |
+
|
| 587 |
+
msg = messages[index]
|
| 588 |
+
last_user_idx = find_last_user_index(messages)
|
| 589 |
+
|
| 590 |
+
role = msg.get("role")
|
| 591 |
+
content = msg.get("content")
|
| 592 |
+
tools = msg.get("tools")
|
| 593 |
+
response_format = msg.get("response_format")
|
| 594 |
+
tool_calls = msg.get("tool_calls")
|
| 595 |
+
reasoning_content = msg.get("reasoning_content")
|
| 596 |
+
wo_eos = msg.get("wo_eos", False)
|
| 597 |
+
|
| 598 |
+
if tools:
|
| 599 |
+
tools = tools_from_openai_format(tools)
|
| 600 |
+
if tool_calls:
|
| 601 |
+
tool_calls = tool_calls_from_openai_format(tool_calls)
|
| 602 |
+
|
| 603 |
+
# Reasoning effort prefix (thinking mode, index 0 only)
|
| 604 |
+
reasoning_effort_prompt = render_reasoning_effort(index, thinking_mode, reasoning_effort)
|
| 605 |
+
# System token leads the conversation when there is a reasoning effort prompt
|
| 606 |
+
# or the first message is a system message.
|
| 607 |
+
prompt = SYSTEM_SP_TOKEN if index == 0 and (reasoning_effort_prompt or role == "system") else ""
|
| 608 |
+
prompt += reasoning_effort_prompt
|
| 609 |
+
|
| 610 |
+
if role == "system":
|
| 611 |
+
if index > 0:
|
| 612 |
+
# Mid-conversation system message
|
| 613 |
+
prompt += SYSTEM_SP_TOKEN
|
| 614 |
+
prompt += system_msg_template.format(content=content or "")
|
| 615 |
+
if tools:
|
| 616 |
+
prompt += "\n\n" + render_tools(tools)
|
| 617 |
+
if response_format:
|
| 618 |
+
prompt += "\n\n" + response_format_template.format(schema=to_json(response_format))
|
| 619 |
+
|
| 620 |
+
elif role == "user":
|
| 621 |
+
prompt += USER_SP_TOKEN
|
| 622 |
+
|
| 623 |
+
# Handle content blocks (tool results mixed with text)
|
| 624 |
+
content_blocks = msg.get("content_blocks")
|
| 625 |
+
if content_blocks:
|
| 626 |
+
parts = []
|
| 627 |
+
for block in content_blocks:
|
| 628 |
+
block_type = block.get("type")
|
| 629 |
+
if block_type == "text":
|
| 630 |
+
parts.append(block.get("text", ""))
|
| 631 |
+
elif block_type == "tool_result":
|
| 632 |
+
tool_content = block.get("content", "")
|
| 633 |
+
if isinstance(tool_content, list):
|
| 634 |
+
text_parts = []
|
| 635 |
+
for b in tool_content:
|
| 636 |
+
if b.get("type") == "text":
|
| 637 |
+
text_parts.append(b.get("text", ""))
|
| 638 |
+
else:
|
| 639 |
+
text_parts.append(f"[Unsupported {b.get('type')}]")
|
| 640 |
+
tool_content = "\n\n".join(text_parts)
|
| 641 |
+
parts.append(tool_output_template.format(content=tool_content))
|
| 642 |
+
else:
|
| 643 |
+
parts.append(f"[Unsupported {block_type}]")
|
| 644 |
+
prompt += "\n\n".join(parts)
|
| 645 |
+
else:
|
| 646 |
+
prompt += content or ""
|
| 647 |
+
|
| 648 |
+
elif role == "latest_reminder":
|
| 649 |
+
prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content)
|
| 650 |
+
|
| 651 |
+
elif role == "tool":
|
| 652 |
+
raise NotImplementedError("deepseek_v41 merges tool messages into user; please preprocess with merge_tool_messages()")
|
| 653 |
+
|
| 654 |
+
elif role == "assistant":
|
| 655 |
+
thinking_part = ""
|
| 656 |
+
tc_content = ""
|
| 657 |
+
|
| 658 |
+
if tool_calls:
|
| 659 |
+
tc_list = [
|
| 660 |
+
tool_call_template.format(
|
| 661 |
+
dsml_token=dsml_token,
|
| 662 |
+
tool_call_tag_name=tool_call_tag_name,
|
| 663 |
+
name=_tool_name_for_encoding(tc),
|
| 664 |
+
arguments=encode_arguments_to_dsml(tc)
|
| 665 |
+
)
|
| 666 |
+
for tc in tool_calls
|
| 667 |
+
]
|
| 668 |
+
tc_content += '\n\n' + tool_calls_template.format(
|
| 669 |
+
dsml_token=dsml_token,
|
| 670 |
+
tool_calls="\n".join(tc_list),
|
| 671 |
+
tc_block_name=tool_calls_block_name,
|
| 672 |
+
)
|
| 673 |
+
|
| 674 |
+
summary_content = content or ""
|
| 675 |
+
rc = reasoning_content or ""
|
| 676 |
+
|
| 677 |
+
# Check if previous message has a task - if so, this is a task output (no thinking)
|
| 678 |
+
prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None
|
| 679 |
+
|
| 680 |
+
if thinking_mode == "thinking" and not prev_has_task:
|
| 681 |
+
if not drop_thinking or index > last_user_idx:
|
| 682 |
+
thinking_part = thinking_template.format(reasoning_content=rc) + thinking_end_token
|
| 683 |
+
else:
|
| 684 |
+
thinking_part = ""
|
| 685 |
+
|
| 686 |
+
if wo_eos:
|
| 687 |
+
prompt += assistant_msg_wo_eos_template.format(
|
| 688 |
+
reasoning=thinking_part,
|
| 689 |
+
content=summary_content,
|
| 690 |
+
tool_calls=tc_content,
|
| 691 |
+
)
|
| 692 |
+
else:
|
| 693 |
+
prompt += assistant_msg_template.format(
|
| 694 |
+
reasoning=thinking_part,
|
| 695 |
+
content=summary_content,
|
| 696 |
+
tool_calls=tc_content,
|
| 697 |
+
)
|
| 698 |
+
else:
|
| 699 |
+
raise NotImplementedError(f"Unknown role: {role}")
|
| 700 |
+
|
| 701 |
+
# Append transition tokens based on what follows
|
| 702 |
+
if index + 1 < len(messages) and messages[index + 1].get("role") not in ["assistant", "latest_reminder"]:
|
| 703 |
+
return prompt
|
| 704 |
+
|
| 705 |
+
task = messages[index].get("task")
|
| 706 |
+
if task is not None:
|
| 707 |
+
# Task special token for internal classification tasks
|
| 708 |
+
assert task in VALID_TASKS, f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}"
|
| 709 |
+
task_sp_token = DS_TASK_SP_TOKENS[task]
|
| 710 |
+
|
| 711 |
+
if task != "action":
|
| 712 |
+
# Non-action tasks: append task sp token directly after the message
|
| 713 |
+
prompt += task_sp_token
|
| 714 |
+
else:
|
| 715 |
+
# Action task: append Assistant + thinking token + action sp token
|
| 716 |
+
prompt += ASSISTANT_SP_TOKEN
|
| 717 |
+
prompt += thinking_end_token if thinking_mode != "thinking" else thinking_start_token
|
| 718 |
+
prompt += task_sp_token
|
| 719 |
+
|
| 720 |
+
elif role == "user" or (role == "system" and index > 0):
|
| 721 |
+
# Normal generation: append Assistant + thinking token
|
| 722 |
+
# (mid-conversation system messages also trigger the assistant header)
|
| 723 |
+
prompt += ASSISTANT_SP_TOKEN
|
| 724 |
+
if not drop_thinking and thinking_mode == "thinking":
|
| 725 |
+
prompt += thinking_start_token
|
| 726 |
+
elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx:
|
| 727 |
+
prompt += thinking_start_token
|
| 728 |
+
else:
|
| 729 |
+
prompt += thinking_end_token
|
| 730 |
+
|
| 731 |
+
return prompt
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
# ============================================================
|
| 735 |
+
# Main Encoding Function
|
| 736 |
+
# ============================================================
|
| 737 |
+
|
| 738 |
+
def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 739 |
+
"""
|
| 740 |
+
Drop reasoning_content and non-essential messages before the last user message.
|
| 741 |
+
Same as V4, but uses the V4.1 last-user definition (mid systems count).
|
| 742 |
+
"""
|
| 743 |
+
last_user_idx = find_last_user_index(messages)
|
| 744 |
+
result = []
|
| 745 |
+
keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"}
|
| 746 |
+
|
| 747 |
+
for idx, msg in enumerate(messages):
|
| 748 |
+
role = msg.get("role")
|
| 749 |
+
if role in keep_roles or idx >= last_user_idx:
|
| 750 |
+
result.append(msg)
|
| 751 |
+
elif role == "assistant":
|
| 752 |
+
msg = copy.copy(msg)
|
| 753 |
+
msg.pop("reasoning_content", None)
|
| 754 |
+
result.append(msg)
|
| 755 |
+
|
| 756 |
+
return result
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def _encode_messages_text(
|
| 760 |
+
messages: List[Dict[str, Any]],
|
| 761 |
+
thinking_mode: str,
|
| 762 |
+
context: Optional[List[Dict[str, Any]]] = None,
|
| 763 |
+
drop_thinking: bool = True,
|
| 764 |
+
add_default_bos_token: bool = True,
|
| 765 |
+
reasoning_effort: Union[str, int, None] = None,
|
| 766 |
+
) -> str:
|
| 767 |
+
"""Encode preprocessed (text-only) messages into the V4.1 prompt format."""
|
| 768 |
+
context = context if context else []
|
| 769 |
+
|
| 770 |
+
# Preprocess: merge tool messages and sort tool results
|
| 771 |
+
messages = merge_tool_messages(messages)
|
| 772 |
+
messages = sort_tool_results_by_call_order(context + messages)[len(context):]
|
| 773 |
+
if context:
|
| 774 |
+
context = merge_tool_messages(context)
|
| 775 |
+
context = sort_tool_results_by_call_order(context)
|
| 776 |
+
|
| 777 |
+
full_messages = context + messages
|
| 778 |
+
|
| 779 |
+
prompt = bos_token if add_default_bos_token and len(context) == 0 else ""
|
| 780 |
+
|
| 781 |
+
# Resolve drop_thinking: if any message has tools defined, don't drop thinking
|
| 782 |
+
effective_drop_thinking = drop_thinking
|
| 783 |
+
if any(m.get("tools") for m in full_messages):
|
| 784 |
+
effective_drop_thinking = False
|
| 785 |
+
|
| 786 |
+
if thinking_mode == "thinking" and effective_drop_thinking:
|
| 787 |
+
full_messages = _drop_thinking_messages(full_messages)
|
| 788 |
+
num_to_render = len(full_messages) - len(_drop_thinking_messages(context))
|
| 789 |
+
context_len = len(full_messages) - num_to_render
|
| 790 |
+
else:
|
| 791 |
+
num_to_render = len(messages)
|
| 792 |
+
context_len = len(context)
|
| 793 |
+
|
| 794 |
+
for idx in range(num_to_render):
|
| 795 |
+
prompt += render_message(
|
| 796 |
+
idx + context_len,
|
| 797 |
+
full_messages,
|
| 798 |
+
thinking_mode=thinking_mode,
|
| 799 |
+
drop_thinking=effective_drop_thinking,
|
| 800 |
+
reasoning_effort=reasoning_effort,
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
return prompt
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
def encode_messages(
|
| 807 |
+
messages: List[Dict[str, Any]],
|
| 808 |
+
thinking_mode: str,
|
| 809 |
+
context: Optional[List[Dict[str, Any]]] = None,
|
| 810 |
+
drop_thinking: bool = True,
|
| 811 |
+
add_default_bos_token: bool = True,
|
| 812 |
+
reasoning_effort: Union[str, int, None] = None,
|
| 813 |
+
return_multi_modal_data: bool = False,
|
| 814 |
+
) -> Any:
|
| 815 |
+
"""Encode text or multimodal messages into the DeepSeek-V4.1 prompt format.
|
| 816 |
+
|
| 817 |
+
Text-only calls return the prompt string. When return_multi_modal_data is
|
| 818 |
+
true, the result is ``(prompt, media_data)``.
|
| 819 |
+
"""
|
| 820 |
+
context = context or []
|
| 821 |
+
processed_context, _ = process_image_messages(context) if context else ([], [])
|
| 822 |
+
processed_messages, images = process_image_messages(messages)
|
| 823 |
+
prompt = _encode_messages_text(
|
| 824 |
+
processed_messages,
|
| 825 |
+
thinking_mode=thinking_mode,
|
| 826 |
+
context=processed_context if processed_context else None,
|
| 827 |
+
drop_thinking=drop_thinking,
|
| 828 |
+
add_default_bos_token=add_default_bos_token,
|
| 829 |
+
reasoning_effort=reasoning_effort,
|
| 830 |
+
)
|
| 831 |
+
if return_multi_modal_data:
|
| 832 |
+
return prompt, {"images": images}
|
| 833 |
+
return prompt
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def load_cases(input_file: str) -> List[Dict[str, Any]]:
|
| 837 |
+
"""Load one or more OpenAI-format conversation cases from JSON."""
|
| 838 |
+
with open(input_file) as file:
|
| 839 |
+
data = json.load(file)
|
| 840 |
+
if isinstance(data, dict):
|
| 841 |
+
data = [data]
|
| 842 |
+
elif data and isinstance(data[0], dict) and "role" in data[0]:
|
| 843 |
+
data = [{"messages": data}]
|
| 844 |
+
|
| 845 |
+
cases = []
|
| 846 |
+
for case in data:
|
| 847 |
+
messages = copy.deepcopy(case["messages"])
|
| 848 |
+
if "tools" in case:
|
| 849 |
+
if not messages:
|
| 850 |
+
raise ValueError("A case with tools must contain at least one message")
|
| 851 |
+
messages[0]["tools"] = case["tools"]
|
| 852 |
+
cases.append({
|
| 853 |
+
"messages": messages,
|
| 854 |
+
"context": case.get("context"),
|
| 855 |
+
"thinking_mode": case.get("thinking_mode"),
|
| 856 |
+
"reasoning_effort": case.get("reasoning_effort"),
|
| 857 |
+
})
|
| 858 |
+
return cases
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
def encode_case(
|
| 862 |
+
case: Dict[str, Any], thinking_mode: str
|
| 863 |
+
) -> Tuple[str, List[Dict[str, Any]]]:
|
| 864 |
+
"""Encode one JSON case and return its current-turn image records."""
|
| 865 |
+
prompt, media_data = encode_messages(
|
| 866 |
+
case["messages"],
|
| 867 |
+
thinking_mode=case.get("thinking_mode") or thinking_mode,
|
| 868 |
+
context=case.get("context"),
|
| 869 |
+
reasoning_effort=case.get("reasoning_effort"),
|
| 870 |
+
return_multi_modal_data=True,
|
| 871 |
+
)
|
| 872 |
+
return prompt, media_data["images"]
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
# ============================================================
|
| 876 |
+
# Parsing (Decoding model output)
|
| 877 |
+
# ============================================================
|
| 878 |
+
|
| 879 |
+
def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Dict[str, str]]]:
|
| 880 |
+
"""
|
| 881 |
+
Parse V4.1 DSML tool calls from text starting at the given index.
|
| 882 |
+
|
| 883 |
+
Returns:
|
| 884 |
+
Tuple of (new_index, last_stop_token, list_of_tool_call_dicts).
|
| 885 |
+
"""
|
| 886 |
+
tool_calls: List[Dict[str, Any]] = []
|
| 887 |
+
stop_token = None
|
| 888 |
+
tool_calls_end_token = f"</{dsml_token}{tool_calls_block_name}>"
|
| 889 |
+
tool_call_start_token = f"<{dsml_token}{tool_call_tag_name}"
|
| 890 |
+
tool_call_end_token = f"</{dsml_token}{tool_call_tag_name}"
|
| 891 |
+
tool_parameter_start_token = f"<{dsml_token}{tool_parameter_tag_name}"
|
| 892 |
+
tool_parameter_end_token = f"/{dsml_token}{tool_parameter_tag_name}"
|
| 893 |
+
|
| 894 |
+
while index < len(text):
|
| 895 |
+
index, _, stop_token = _read_until_stop(index, text, [tool_call_start_token, tool_calls_end_token])
|
| 896 |
+
if _ != ">\n":
|
| 897 |
+
raise ValueError(f"Tool call format error: expected '>\\n' but got '{_}'")
|
| 898 |
+
|
| 899 |
+
if stop_token == tool_calls_end_token:
|
| 900 |
+
break
|
| 901 |
+
|
| 902 |
+
if stop_token is None:
|
| 903 |
+
raise ValueError("Missing special token in tool calls")
|
| 904 |
+
|
| 905 |
+
index, tool_name_content, stop_token = _read_until_stop(index, text, [tool_parameter_start_token, tool_call_end_token])
|
| 906 |
+
|
| 907 |
+
p_tool_name = re.findall(r'^\s*name="(.*?)">\n$', tool_name_content, flags=re.DOTALL)
|
| 908 |
+
if len(p_tool_name) != 1:
|
| 909 |
+
raise ValueError(f"Tool name format error: '{tool_name_content}'")
|
| 910 |
+
tool_name = p_tool_name[0]
|
| 911 |
+
|
| 912 |
+
tool_args: Dict[str, Tuple[str, str]] = {}
|
| 913 |
+
while stop_token == tool_parameter_start_token:
|
| 914 |
+
index, param_content, stop_token = _read_until_stop(index, text, [tool_parameter_end_token])
|
| 915 |
+
|
| 916 |
+
param_kv = re.findall(r'^ name="(.*?)" string="(true|false)">(.*?)<$', param_content, flags=re.DOTALL)
|
| 917 |
+
if len(param_kv) != 1:
|
| 918 |
+
raise ValueError(f"Parameter format error: '{param_content}'")
|
| 919 |
+
param_name, string, param_value = param_kv[0]
|
| 920 |
+
|
| 921 |
+
if param_name in tool_args:
|
| 922 |
+
raise ValueError(f"Duplicate parameter name: '{param_name}'")
|
| 923 |
+
tool_args[param_name] = (param_value, string)
|
| 924 |
+
|
| 925 |
+
index, content, stop_token = _read_until_stop(index, text, [tool_parameter_start_token, tool_call_end_token])
|
| 926 |
+
if content != ">\n":
|
| 927 |
+
raise ValueError(f"Parameter format error: expected '>\\n' but got '{content}'")
|
| 928 |
+
|
| 929 |
+
tool_call = decode_dsml_to_arguments(tool_name=tool_name, tool_args=tool_args)
|
| 930 |
+
tool_calls.append(tool_call)
|
| 931 |
+
|
| 932 |
+
return index, stop_token, tool_calls
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[str, Any]:
|
| 936 |
+
"""
|
| 937 |
+
Parse a model completion text into a structured assistant message (V4.1 format).
|
| 938 |
+
|
| 939 |
+
Returns:
|
| 940 |
+
Dict with keys: "role", "content", "reasoning_content", "tool_calls".
|
| 941 |
+
tool_calls are in OpenAI format.
|
| 942 |
+
"""
|
| 943 |
+
summary_content, reasoning_content, tool_calls = "", "", []
|
| 944 |
+
index, stop_token = 0, None
|
| 945 |
+
tool_calls_start_token = f"\n\n<{dsml_token}{tool_calls_block_name}"
|
| 946 |
+
|
| 947 |
+
is_thinking = thinking_mode == "thinking"
|
| 948 |
+
is_tool_calling = False
|
| 949 |
+
|
| 950 |
+
if is_thinking:
|
| 951 |
+
index, content_delta, stop_token = _read_until_stop(index, text, [thinking_end_token, tool_calls_start_token])
|
| 952 |
+
reasoning_content = content_delta
|
| 953 |
+
assert stop_token == thinking_end_token, "Invalid thinking format: missing </think>"
|
| 954 |
+
|
| 955 |
+
index, content_delta, stop_token = _read_until_stop(index, text, [eos_token, tool_calls_start_token])
|
| 956 |
+
summary_content = content_delta
|
| 957 |
+
if stop_token == tool_calls_start_token:
|
| 958 |
+
is_tool_calling = True
|
| 959 |
+
else:
|
| 960 |
+
assert stop_token == eos_token, "Invalid format: missing EOS token"
|
| 961 |
+
|
| 962 |
+
if is_tool_calling:
|
| 963 |
+
index, stop_token, tool_calls = parse_tool_calls(index, text)
|
| 964 |
+
|
| 965 |
+
index, tool_ends_text, stop_token = _read_until_stop(index, text, [eos_token])
|
| 966 |
+
assert not tool_ends_text, "Unexpected content after tool calls"
|
| 967 |
+
|
| 968 |
+
assert len(text) == index and stop_token in [eos_token, None], "Unexpected content at end"
|
| 969 |
+
|
| 970 |
+
for sp_token in [bos_token, eos_token, thinking_start_token, thinking_end_token, dsml_token]:
|
| 971 |
+
assert sp_token not in summary_content and sp_token not in reasoning_content, \
|
| 972 |
+
f"Unexpected special token '{sp_token}' in content"
|
| 973 |
+
|
| 974 |
+
return {
|
| 975 |
+
"role": "assistant",
|
| 976 |
+
"content": summary_content,
|
| 977 |
+
"reasoning_content": reasoning_content,
|
| 978 |
+
"tool_calls": tool_calls_to_openai_format(tool_calls)
|
| 979 |
+
}
|
encoding/test_encoding.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for encoding.py (DeepSeek-V4.1 encoding).
|
| 3 |
+
|
| 4 |
+
Adapted from dsv41-master/deepseek_harmony/tests/test_deepseek_v41.py for the
|
| 5 |
+
self-contained dict-based API in this repo.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import copy
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
import encoding as enc
|
| 16 |
+
from encoding import (
|
| 17 |
+
IMAGE_PLACEHOLDER,
|
| 18 |
+
SYSTEM_SP_TOKEN,
|
| 19 |
+
encode_messages,
|
| 20 |
+
parse_message_from_completion_text,
|
| 21 |
+
render_message,
|
| 22 |
+
merge_tool_messages,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
REASONING_EFFORT_TEMPLATE = (
|
| 27 |
+
SYSTEM_SP_TOKEN + "Reasoning Effort: {budget} "
|
| 28 |
+
"(range 1-100, the higher the value, the more thorough the reasoning)\n\n"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
V41_TOOL_CALL_OUTPUT = (
|
| 32 |
+
' reason </think>summary\n\n'
|
| 33 |
+
'<|DSML| calls>\n'
|
| 34 |
+
'<|DSML| invoke name="lookup">\n'
|
| 35 |
+
'<|DSML| parameter name="query" string="true">value'
|
| 36 |
+
'</|DSML| parameter>\n'
|
| 37 |
+
'<|DSML| parameter name="limit" string="false">2'
|
| 38 |
+
'</|DSML| parameter>\n'
|
| 39 |
+
'</|DSML| invoke>\n'
|
| 40 |
+
'</|DSML| calls><|end▁of▁sentence|>'
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def make_tool() -> dict:
|
| 45 |
+
return {
|
| 46 |
+
"type": "function",
|
| 47 |
+
"function": {
|
| 48 |
+
"name": "lookup",
|
| 49 |
+
"description": "Look up a value",
|
| 50 |
+
"parameters": {
|
| 51 |
+
"type": "object",
|
| 52 |
+
"properties": {
|
| 53 |
+
"query": {"type": "string"},
|
| 54 |
+
"limit": {"type": "integer"},
|
| 55 |
+
},
|
| 56 |
+
},
|
| 57 |
+
},
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def make_tool_call_messages() -> list:
|
| 62 |
+
return [
|
| 63 |
+
{"role": "user", "content": "question"},
|
| 64 |
+
{
|
| 65 |
+
"role": "assistant",
|
| 66 |
+
"reasoning_content": " reason ",
|
| 67 |
+
"content": "summary",
|
| 68 |
+
"tool_calls": [
|
| 69 |
+
{
|
| 70 |
+
"type": "function",
|
| 71 |
+
"function": {
|
| 72 |
+
"name": "lookup",
|
| 73 |
+
"arguments": '{"query":"value","limit":2}',
|
| 74 |
+
},
|
| 75 |
+
}
|
| 76 |
+
],
|
| 77 |
+
},
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ============================================================
|
| 82 |
+
# Vision
|
| 83 |
+
# ============================================================
|
| 84 |
+
|
| 85 |
+
def test_v41_renders_images() -> None:
|
| 86 |
+
prompt, media = encode_messages(
|
| 87 |
+
[
|
| 88 |
+
{
|
| 89 |
+
"role": "user",
|
| 90 |
+
"content": [
|
| 91 |
+
{"type": "text", "text": "inspect"},
|
| 92 |
+
{"type": "image_url", "image_url": {"url": "/unused/image.png"}},
|
| 93 |
+
],
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
thinking_mode="chat",
|
| 97 |
+
return_multi_modal_data=True,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
assert prompt == (
|
| 101 |
+
'<|begin▁of▁sentence|><|User|>inspect\n\n'
|
| 102 |
+
f'{IMAGE_PLACEHOLDER}<|Assistant|></think>'
|
| 103 |
+
)
|
| 104 |
+
assert media == {"images": [{"type": "image", "url": "/unused/image.png"}]}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_v41_rejects_image_placeholder_in_text() -> None:
|
| 108 |
+
with pytest.raises(ValueError):
|
| 109 |
+
encode_messages(
|
| 110 |
+
[{"role": "user", "content": f"hi {IMAGE_PLACEHOLDER}"}],
|
| 111 |
+
thinking_mode="chat",
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ============================================================
|
| 116 |
+
# Reasoning Effort
|
| 117 |
+
# ============================================================
|
| 118 |
+
|
| 119 |
+
@pytest.mark.parametrize(
|
| 120 |
+
("effort", "budget"),
|
| 121 |
+
[
|
| 122 |
+
(None, 75),
|
| 123 |
+
("low", 50),
|
| 124 |
+
("high", 75),
|
| 125 |
+
("max", 100),
|
| 126 |
+
(1, 1),
|
| 127 |
+
(42, 42),
|
| 128 |
+
(100, 100),
|
| 129 |
+
],
|
| 130 |
+
)
|
| 131 |
+
def test_v41_maps_reasoning_effort_to_1_100_budget(
|
| 132 |
+
effort: Any,
|
| 133 |
+
budget: int,
|
| 134 |
+
) -> None:
|
| 135 |
+
prompt = encode_messages(
|
| 136 |
+
[{"role": "user", "content": "question"}],
|
| 137 |
+
thinking_mode="thinking",
|
| 138 |
+
reasoning_effort=effort,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
assert prompt == (
|
| 142 |
+
'<|begin▁of▁sentence|>'
|
| 143 |
+
f'{REASONING_EFFORT_TEMPLATE.format(budget=budget)}'
|
| 144 |
+
'<|User|>question<|Assistant|><think>'
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_v41_only_adds_reasoning_effort_to_first_thinking_message() -> None:
|
| 149 |
+
messages = [
|
| 150 |
+
{"role": "system", "content": "system"},
|
| 151 |
+
{"role": "user", "content": "question"},
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
later_message = render_message(
|
| 155 |
+
1, messages, thinking_mode="thinking", reasoning_effort=100
|
| 156 |
+
)
|
| 157 |
+
chat_message = render_message(
|
| 158 |
+
0, messages, thinking_mode="chat", reasoning_effort=100
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
assert "Reasoning Effort:" not in later_message
|
| 162 |
+
assert "Reasoning Effort:" not in chat_message
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_v41_chat_mode_has_no_reasoning_effort_or_system_token() -> None:
|
| 166 |
+
prompt = encode_messages(
|
| 167 |
+
[{"role": "user", "content": "hello"}],
|
| 168 |
+
thinking_mode="chat",
|
| 169 |
+
reasoning_effort="max",
|
| 170 |
+
)
|
| 171 |
+
assert prompt == '<|begin▁of▁sentence|><|User|>hello<|Assistant|></think>'
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@pytest.mark.parametrize("effort", [-1, 0, 101, "medium"])
|
| 175 |
+
def test_v41_rejects_out_of_range_or_unknown_reasoning_effort(
|
| 176 |
+
effort: Any,
|
| 177 |
+
) -> None:
|
| 178 |
+
with pytest.raises(AssertionError, match=r"int within \[1,100\]"):
|
| 179 |
+
encode_messages(
|
| 180 |
+
[{"role": "user", "content": "question"}],
|
| 181 |
+
thinking_mode="thinking",
|
| 182 |
+
reasoning_effort=effort,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@pytest.mark.parametrize("effort", [True, False, 1.5])
|
| 187 |
+
def test_v41_rejects_non_string_non_integer_effort_types(effort: Any) -> None:
|
| 188 |
+
# bool is not `type(...) is int`; float is invalid too
|
| 189 |
+
with pytest.raises(AssertionError):
|
| 190 |
+
encode_messages(
|
| 191 |
+
[{"role": "user", "content": "question"}],
|
| 192 |
+
thinking_mode="thinking",
|
| 193 |
+
reasoning_effort=effort,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ============================================================
|
| 198 |
+
# System token
|
| 199 |
+
# ============================================================
|
| 200 |
+
|
| 201 |
+
def test_v41_leading_system_message_uses_system_token() -> None:
|
| 202 |
+
prompt = encode_messages(
|
| 203 |
+
[
|
| 204 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 205 |
+
{"role": "user", "content": "hello"},
|
| 206 |
+
],
|
| 207 |
+
thinking_mode="chat",
|
| 208 |
+
)
|
| 209 |
+
assert prompt == (
|
| 210 |
+
'<|begin▁of▁sentence|><|System|>You are a helpful assistant.'
|
| 211 |
+
'<|User|>hello<|Assistant|></think>'
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def test_v41_mid_conversation_system_message() -> None:
|
| 216 |
+
prompt = encode_messages(
|
| 217 |
+
[
|
| 218 |
+
{"role": "system", "content": "sys"},
|
| 219 |
+
{"role": "user", "content": "q1"},
|
| 220 |
+
{"role": "assistant", "content": "a1", "reasoning_content": "r1"},
|
| 221 |
+
{"role": "system", "content": "mid sys"},
|
| 222 |
+
],
|
| 223 |
+
thinking_mode="thinking",
|
| 224 |
+
reasoning_effort=88,
|
| 225 |
+
)
|
| 226 |
+
# Mid-conversation system gets its own <|System|> token and triggers
|
| 227 |
+
# the assistant generation header afterwards.
|
| 228 |
+
assert prompt == (
|
| 229 |
+
'<|begin▁of▁sentence|>'
|
| 230 |
+
f'{REASONING_EFFORT_TEMPLATE.format(budget=88)}'
|
| 231 |
+
'sys<|User|>q1<|Assistant|></think>a1<|end▁of▁sentence|>'
|
| 232 |
+
'<|System|>mid sys<|Assistant|><think>'
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ============================================================
|
| 237 |
+
# DSML tool tags
|
| 238 |
+
# ============================================================
|
| 239 |
+
|
| 240 |
+
def test_v41_tool_instructions_use_spaced_dsml_tags_in_chat_mode() -> None:
|
| 241 |
+
prompt = encode_messages(
|
| 242 |
+
[
|
| 243 |
+
{"role": "system", "content": "system", "tools": [make_tool()]},
|
| 244 |
+
{"role": "user", "content": "question"},
|
| 245 |
+
],
|
| 246 |
+
thinking_mode="chat",
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
assert (
|
| 250 |
+
'<|DSML| calls>\n'
|
| 251 |
+
'<|DSML| invoke name="$TOOL_NAME">\n'
|
| 252 |
+
'<|DSML| parameter name="$PARAMETER_NAME" '
|
| 253 |
+
'string="true|false">$PARAMETER_VALUE</|DSML| parameter>\n'
|
| 254 |
+
'...\n'
|
| 255 |
+
'</|DSML| invoke>'
|
| 256 |
+
) in prompt
|
| 257 |
+
assert '<|DSML|tool_calls>' not in prompt
|
| 258 |
+
assert '<|DSML|invoke' not in prompt
|
| 259 |
+
assert '<|DSML|parameter' not in prompt
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def test_v41_renders_spaced_dsml_with_v4_assistant_semantics() -> None:
|
| 263 |
+
messages = make_tool_call_messages()
|
| 264 |
+
|
| 265 |
+
prompt = render_message(1, messages, thinking_mode="thinking")
|
| 266 |
+
|
| 267 |
+
assert prompt == V41_TOOL_CALL_OUTPUT
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def test_v41_parses_spaced_dsml_roundtrip() -> None:
|
| 271 |
+
messages = make_tool_call_messages()
|
| 272 |
+
|
| 273 |
+
parsed = parse_message_from_completion_text(
|
| 274 |
+
V41_TOOL_CALL_OUTPUT, thinking_mode="thinking"
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
assert parsed["role"] == "assistant"
|
| 278 |
+
assert parsed["reasoning_content"] == " reason "
|
| 279 |
+
assert parsed["content"] == "summary"
|
| 280 |
+
assert parsed["tool_calls"]
|
| 281 |
+
assert parsed["tool_calls"][0]["function"]["name"] == "lookup"
|
| 282 |
+
assert json.loads(parsed["tool_calls"][0]["function"]["arguments"]) == {
|
| 283 |
+
"query": "value",
|
| 284 |
+
"limit": 2,
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
# Re-encoding the parsed message reproduces the original completion text
|
| 288 |
+
assert encode_messages(
|
| 289 |
+
[parsed],
|
| 290 |
+
thinking_mode="thinking",
|
| 291 |
+
context=messages[:1],
|
| 292 |
+
) == V41_TOOL_CALL_OUTPUT
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def test_v41_parse_rejects_unspaced_v4_dsml() -> None:
|
| 296 |
+
v4_output = V41_TOOL_CALL_OUTPUT.replace("|DSML| calls", "|DSML|tool_calls") \
|
| 297 |
+
.replace("|DSML| invoke", "|DSML|invoke") \
|
| 298 |
+
.replace("|DSML| parameter", "|DSML|parameter")
|
| 299 |
+
with pytest.raises(AssertionError):
|
| 300 |
+
parse_message_from_completion_text(v4_output, thinking_mode="thinking")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
# ============================================================
|
| 304 |
+
# Tool namespaces
|
| 305 |
+
# ============================================================
|
| 306 |
+
|
| 307 |
+
@pytest.mark.parametrize("location", ["tool", "function"])
|
| 308 |
+
@pytest.mark.parametrize("namespace", ["search", {"name": "search", "description": "Search tools."}])
|
| 309 |
+
def test_v41_renders_namespaced_tool_schemas(location: str, namespace: Any) -> None:
|
| 310 |
+
tool = make_tool()
|
| 311 |
+
target = tool if location == "tool" else tool["function"]
|
| 312 |
+
target["namespace"] = namespace
|
| 313 |
+
original = copy.deepcopy(tool)
|
| 314 |
+
|
| 315 |
+
prompt = encode_messages(
|
| 316 |
+
[{"role": "system", "content": "system", "tools": [tool]}],
|
| 317 |
+
thinking_mode="chat",
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
schema = dict(make_tool()["function"], name="search::lookup")
|
| 321 |
+
if isinstance(namespace, dict):
|
| 322 |
+
schema["description"] = "Search tools.\nLook up a value"
|
| 323 |
+
assert json.dumps(schema) in prompt
|
| 324 |
+
assert '"namespace":' not in prompt
|
| 325 |
+
assert tool == original
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@pytest.mark.parametrize("thinking_mode", ["chat", "thinking"])
|
| 329 |
+
@pytest.mark.parametrize("location", ["tool", "function", "qualified_name"])
|
| 330 |
+
def test_v41_namespaced_tool_calls_roundtrip(thinking_mode: str, location: str) -> None:
|
| 331 |
+
messages = make_tool_call_messages()
|
| 332 |
+
call = messages[1]["tool_calls"][0]
|
| 333 |
+
if location == "qualified_name":
|
| 334 |
+
call["function"]["name"] = "search::lookup"
|
| 335 |
+
else:
|
| 336 |
+
target = call if location == "tool" else call["function"]
|
| 337 |
+
target["namespace"] = "search"
|
| 338 |
+
original = copy.deepcopy(messages)
|
| 339 |
+
|
| 340 |
+
expected = V41_TOOL_CALL_OUTPUT.replace('name="lookup"', 'name="search::lookup"')
|
| 341 |
+
if thinking_mode == "chat":
|
| 342 |
+
expected = expected.split("</think>", 1)[1]
|
| 343 |
+
assert render_message(1, messages, thinking_mode=thinking_mode) == expected
|
| 344 |
+
|
| 345 |
+
parsed = parse_message_from_completion_text(expected, thinking_mode=thinking_mode)
|
| 346 |
+
assert parsed["tool_calls"] == [{
|
| 347 |
+
"type": "function",
|
| 348 |
+
"namespace": "search",
|
| 349 |
+
"function": {
|
| 350 |
+
"name": "lookup",
|
| 351 |
+
"arguments": '{"query": "value", "limit": 2}',
|
| 352 |
+
},
|
| 353 |
+
}]
|
| 354 |
+
assert encode_messages(
|
| 355 |
+
[parsed], thinking_mode=thinking_mode, context=messages[:1]
|
| 356 |
+
) == expected
|
| 357 |
+
assert messages == original
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def test_v41_keeps_same_named_tools_in_separate_namespaces() -> None:
|
| 361 |
+
tools, calls = [], []
|
| 362 |
+
for namespace in (None, "search", "files"):
|
| 363 |
+
tool = make_tool()
|
| 364 |
+
call = {
|
| 365 |
+
"type": "function",
|
| 366 |
+
"function": {"name": "lookup", "arguments": '{"query":"value"}'},
|
| 367 |
+
}
|
| 368 |
+
if namespace is not None:
|
| 369 |
+
tool["namespace"] = {"name": namespace}
|
| 370 |
+
call["namespace"] = namespace
|
| 371 |
+
tools.append(tool)
|
| 372 |
+
calls.append(call)
|
| 373 |
+
|
| 374 |
+
messages = [
|
| 375 |
+
{"role": "system", "content": "system", "tools": tools},
|
| 376 |
+
{"role": "user", "content": "question"},
|
| 377 |
+
{"role": "assistant", "content": "summary", "tool_calls": calls},
|
| 378 |
+
]
|
| 379 |
+
prompt = encode_messages(messages, thinking_mode="chat")
|
| 380 |
+
for name in ("lookup", "search::lookup", "files::lookup"):
|
| 381 |
+
assert f'"name": "{name}"' in prompt
|
| 382 |
+
assert f'<|DSML| invoke name="{name}">' in prompt
|
| 383 |
+
|
| 384 |
+
completion = render_message(2, messages, thinking_mode="chat")
|
| 385 |
+
parsed = parse_message_from_completion_text(completion, thinking_mode="chat")
|
| 386 |
+
assert "namespace" not in parsed["tool_calls"][0]
|
| 387 |
+
assert [call.get("namespace") for call in parsed["tool_calls"]] == [None, "search", "files"]
|
| 388 |
+
assert all(call["function"]["name"] == "lookup" for call in parsed["tool_calls"])
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def test_v41_does_not_duplicate_a_qualified_namespace() -> None:
|
| 392 |
+
tool = make_tool()
|
| 393 |
+
tool["function"]["name"] = "search::lookup"
|
| 394 |
+
tool["namespace"] = {"name": "search", "description": "Search tools."}
|
| 395 |
+
schema = enc.tools_from_openai_format([tool])[0]
|
| 396 |
+
assert schema["name"] == "search::lookup"
|
| 397 |
+
assert schema["description"] == "Search tools.\nLook up a value"
|
| 398 |
+
|
| 399 |
+
messages = make_tool_call_messages()
|
| 400 |
+
call = messages[1]["tool_calls"][0]
|
| 401 |
+
call["function"]["name"] = "search::lookup"
|
| 402 |
+
call["namespace"] = "search"
|
| 403 |
+
assert render_message(1, messages, thinking_mode="thinking") == (
|
| 404 |
+
V41_TOOL_CALL_OUTPUT.replace('name="lookup"', 'name="search::lookup"')
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
@pytest.mark.parametrize(
|
| 409 |
+
("name", "namespace", "error"),
|
| 410 |
+
[
|
| 411 |
+
("search::lookup", "files", "Conflicting tool namespaces"),
|
| 412 |
+
("search::nested::lookup", None, "Tool name must not contain"),
|
| 413 |
+
("lookup", "search::nested", "Tool namespace must not contain"),
|
| 414 |
+
],
|
| 415 |
+
)
|
| 416 |
+
def test_v41_rejects_ambiguous_tool_namespaces(name: str, namespace: Any, error: str) -> None:
|
| 417 |
+
tool = make_tool()
|
| 418 |
+
tool["function"]["name"] = name
|
| 419 |
+
tool["namespace"] = namespace
|
| 420 |
+
with pytest.raises(AssertionError, match=error):
|
| 421 |
+
enc.tools_from_openai_format([tool])
|
| 422 |
+
|
| 423 |
+
messages = make_tool_call_messages()
|
| 424 |
+
call = messages[1]["tool_calls"][0]
|
| 425 |
+
call["function"]["name"] = name
|
| 426 |
+
call["namespace"] = namespace
|
| 427 |
+
with pytest.raises(AssertionError, match=error):
|
| 428 |
+
render_message(1, messages, thinking_mode="thinking")
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
# ============================================================
|
| 432 |
+
# Multi-turn flow
|
| 433 |
+
# ============================================================
|
| 434 |
+
|
| 435 |
+
def test_v41_drop_thinking_without_tools() -> None:
|
| 436 |
+
prompt = encode_messages(
|
| 437 |
+
[
|
| 438 |
+
{"role": "user", "content": "q1"},
|
| 439 |
+
{"role": "assistant", "content": "a1", "reasoning_content": "r1"},
|
| 440 |
+
{"role": "user", "content": "q2"},
|
| 441 |
+
],
|
| 442 |
+
thinking_mode="thinking",
|
| 443 |
+
drop_thinking=True,
|
| 444 |
+
)
|
| 445 |
+
# Earlier turn reasoning dropped, </think> form; new turn opens <think>
|
| 446 |
+
assert '<|User|>q1<|Assistant|></think>a1<|end▁of▁sentence|>' in prompt
|
| 447 |
+
assert 'r1' not in prompt
|
| 448 |
+
assert prompt.endswith('<|User|>q2<|Assistant|><think>')
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
# ============================================================
|
| 452 |
+
# Preprocessing
|
| 453 |
+
# ============================================================
|
| 454 |
+
|
| 455 |
+
def test_merge_tool_messages_creates_tool_result_blocks() -> None:
|
| 456 |
+
merged = merge_tool_messages([
|
| 457 |
+
{"role": "assistant", "content": "", "tool_calls": []},
|
| 458 |
+
{"role": "tool", "tool_call_id": "a", "content": "r1"},
|
| 459 |
+
{"role": "tool", "tool_call_id": "b", "content": "r2"},
|
| 460 |
+
])
|
| 461 |
+
assert len(merged) == 2
|
| 462 |
+
assert merged[1]["role"] == "user"
|
| 463 |
+
assert [b["type"] for b in merged[1]["content_blocks"]] == ["tool_result", "tool_result"]
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def test_v41_task_sp_token() -> None:
|
| 467 |
+
prompt = encode_messages(
|
| 468 |
+
[{"role": "user", "content": "classify me", "task": "query"}],
|
| 469 |
+
thinking_mode="chat",
|
| 470 |
+
)
|
| 471 |
+
assert prompt.endswith("classify me<|query|>")
|
| 472 |
+
assert "<|Assistant|>" not in prompt
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
# ============================================================
|
| 476 |
+
# Golden fixtures from encoding/tests
|
| 477 |
+
# ============================================================
|
| 478 |
+
|
| 479 |
+
ENCODING_DIR = Path(__file__).resolve().parent
|
| 480 |
+
ENCODING_FIXTURES_DIR = ENCODING_DIR / "tests"
|
| 481 |
+
INFERENCE_EXAMPLES_DIR = ENCODING_DIR.parent / "inference" / "examples"
|
| 482 |
+
|
| 483 |
+
FIXTURE_CASE_IDS = sorted(
|
| 484 |
+
int(p.stem.split("_")[-1])
|
| 485 |
+
for p in ENCODING_FIXTURES_DIR.glob("test_input_*.json")
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
@pytest.mark.parametrize("case_id", FIXTURE_CASE_IDS)
|
| 490 |
+
def test_examples_encoding_golden_outputs(case_id: int) -> None:
|
| 491 |
+
"""Each tests/encoding input must encode to its checked-in golden output."""
|
| 492 |
+
input_file = ENCODING_FIXTURES_DIR / f"test_input_{case_id}.json"
|
| 493 |
+
output_file = ENCODING_FIXTURES_DIR / f"test_output_{case_id}.txt"
|
| 494 |
+
assert output_file.exists(), f"missing golden output: {output_file.name} (run tests/encoding/regen_outputs.py)"
|
| 495 |
+
|
| 496 |
+
case = enc.load_cases(str(input_file))[0]
|
| 497 |
+
prompt, _ = enc.encode_case(case, thinking_mode="chat")
|
| 498 |
+
|
| 499 |
+
assert prompt == output_file.read_text(), (
|
| 500 |
+
f"{output_file.name} is stale; regenerate with tests/encoding/regen_outputs.py"
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def test_examples_v41_output_uses_v41_format_markers() -> None:
|
| 505 |
+
"""Sanity-check the V4.1 goldens actually exercise V4.1-specific format."""
|
| 506 |
+
# case 1: tool calls with spaced DSML tags
|
| 507 |
+
out1 = (ENCODING_FIXTURES_DIR / "test_output_1.txt").read_text()
|
| 508 |
+
assert '<|DSML| calls>' in out1 and '<|DSML| invoke name="get_weather">' in out1
|
| 509 |
+
assert '<|DSML|tool_calls>' not in out1
|
| 510 |
+
|
| 511 |
+
# case 5: numeric reasoning effort behind the system token
|
| 512 |
+
out5 = (ENCODING_FIXTURES_DIR / "test_output_5.txt").read_text()
|
| 513 |
+
assert out5.startswith(
|
| 514 |
+
'<|begin▁of▁sentence|>' + REASONING_EFFORT_TEMPLATE.format(budget=100)
|
| 515 |
+
)
|
| 516 |
+
assert out5.count(IMAGE_PLACEHOLDER) == 2
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def test_examples_vl_txt_and_json_encode_identically() -> None:
|
| 520 |
+
"""The TXT (last block of example.txt) and JSON vision examples must encode identically."""
|
| 521 |
+
txt = (INFERENCE_EXAMPLES_DIR / "example.txt").read_text().rstrip("\n").split("\n\n")[-1]
|
| 522 |
+
messages = [{"role": "user", "content": enc.parse_tagged_text(txt)}]
|
| 523 |
+
p1, m1 = encode_messages(messages, thinking_mode="chat", return_multi_modal_data=True)
|
| 524 |
+
|
| 525 |
+
case = enc.load_cases(str(INFERENCE_EXAMPLES_DIR / "example_harmony.json"))[0]
|
| 526 |
+
p2, m2 = enc.encode_case(case, thinking_mode="chat")
|
| 527 |
+
|
| 528 |
+
assert p1 == p2
|
| 529 |
+
assert m1["images"] == m2
|
| 530 |
+
assert len(m2) == 2
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def test_examples_harmony_cases_encode() -> None:
|
| 534 |
+
"""All example_harmony.json cases encode without error."""
|
| 535 |
+
cases = enc.load_cases(str(INFERENCE_EXAMPLES_DIR / "example_harmony.json"))
|
| 536 |
+
assert len(cases) == 4
|
| 537 |
+
|
| 538 |
+
# case 1 (vision) is covered by test_examples_vl_txt_and_json_encode_identically
|
| 539 |
+
|
| 540 |
+
# cases are pure OpenAI format: mode/effort are passed at call time
|
| 541 |
+
prompt = encode_messages(
|
| 542 |
+
cases[1]["messages"], thinking_mode="thinking", reasoning_effort=75
|
| 543 |
+
)
|
| 544 |
+
assert REASONING_EFFORT_TEMPLATE.format(budget=75) in prompt
|
| 545 |
+
|
| 546 |
+
# case 3: tools with spaced DSML tags
|
| 547 |
+
prompt, _ = enc.encode_case(cases[2], thinking_mode="chat")
|
| 548 |
+
assert '<|DSML| calls>' in prompt
|
| 549 |
+
|
| 550 |
+
# case 4: mid-conversation system message triggers assistant header
|
| 551 |
+
prompt, _ = enc.encode_case(cases[3], thinking_mode="chat")
|
| 552 |
+
assert '<|System|>Mid-conversation instruction update' in prompt
|
| 553 |
+
assert prompt.endswith('<|Assistant|></think>')
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
if __name__ == "__main__":
|
| 557 |
+
import sys
|
| 558 |
+
sys.exit(pytest.main([__file__, "-v"]))
|
encoding/tests/test_input_1.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"thinking_mode": "thinking",
|
| 3 |
+
"tools": [
|
| 4 |
+
{
|
| 5 |
+
"type": "function",
|
| 6 |
+
"function": {
|
| 7 |
+
"name": "get_weather",
|
| 8 |
+
"description": "Get the weather for a specific location",
|
| 9 |
+
"parameters": {
|
| 10 |
+
"type": "object",
|
| 11 |
+
"properties": {
|
| 12 |
+
"location": {
|
| 13 |
+
"type": "string",
|
| 14 |
+
"description": "The city name"
|
| 15 |
+
},
|
| 16 |
+
"unit": {
|
| 17 |
+
"type": "string",
|
| 18 |
+
"enum": ["celsius", "fahrenheit"],
|
| 19 |
+
"description": "Temperature unit"
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"required": ["location"]
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"type": "function",
|
| 28 |
+
"function": {
|
| 29 |
+
"name": "search",
|
| 30 |
+
"description": "Search the web for information",
|
| 31 |
+
"parameters": {
|
| 32 |
+
"type": "object",
|
| 33 |
+
"properties": {
|
| 34 |
+
"query": {
|
| 35 |
+
"type": "string",
|
| 36 |
+
"description": "Search query"
|
| 37 |
+
},
|
| 38 |
+
"num_results": {
|
| 39 |
+
"type": "integer",
|
| 40 |
+
"description": "Number of results to return"
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
"required": ["query"]
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
],
|
| 48 |
+
"messages": [
|
| 49 |
+
{
|
| 50 |
+
"role": "system",
|
| 51 |
+
"content": "You are a helpful assistant."
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"role": "user",
|
| 55 |
+
"content": "What's the weather like in Beijing?"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"role": "assistant",
|
| 59 |
+
"reasoning_content": "The user wants the weather in Beijing. I should call get_weather.",
|
| 60 |
+
"content": "",
|
| 61 |
+
"tool_calls": [
|
| 62 |
+
{
|
| 63 |
+
"type": "function",
|
| 64 |
+
"function": {
|
| 65 |
+
"name": "get_weather",
|
| 66 |
+
"arguments": "{\"location\": \"Beijing\", \"unit\": \"celsius\"}"
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"role": "tool",
|
| 73 |
+
"tool_call_id": "call_0",
|
| 74 |
+
"content": "{\"temperature\": 22, \"condition\": \"sunny\", \"humidity\": 45}"
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"role": "assistant",
|
| 78 |
+
"reasoning_content": "Got the weather data. Let me format a nice response.",
|
| 79 |
+
"content": "The weather in Beijing is currently sunny with a temperature of 22\u00b0C and 45% humidity."
|
| 80 |
+
}
|
| 81 |
+
]
|
| 82 |
+
}
|
encoding/tests/test_input_2.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"role": "system",
|
| 4 |
+
"content": "You are a helpful assistant."
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"role": "user",
|
| 8 |
+
"content": "Hello"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"role": "assistant",
|
| 12 |
+
"reasoning_content": "The user said hello, I should greet back.",
|
| 13 |
+
"content": "Hi there! How can I help you?"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"role": "user",
|
| 17 |
+
"content": "What is the capital of France?"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"role": "assistant",
|
| 21 |
+
"reasoning_content": "The user asks about the capital of France. It is Paris.",
|
| 22 |
+
"content": "The capital of France is Paris."
|
| 23 |
+
}
|
| 24 |
+
]
|
encoding/tests/test_input_3.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"role": "system",
|
| 4 |
+
"content": "该助手为DeepSeek,由深度求索公司创造。"
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"role": "latest_reminder",
|
| 8 |
+
"content": "2026-02-21,星期六,广州,App,中文"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"role": "system",
|
| 12 |
+
"content": "CITATION FORMAT: 【{cursor_id}†L{start_line_id}(-L{end_line_id})?】",
|
| 13 |
+
"tools": [
|
| 14 |
+
{
|
| 15 |
+
"type": "function",
|
| 16 |
+
"function": {
|
| 17 |
+
"name": "search",
|
| 18 |
+
"description": "Web search. Split multiple queries with '||'.",
|
| 19 |
+
"parameters": {
|
| 20 |
+
"type": "object",
|
| 21 |
+
"properties": {
|
| 22 |
+
"queries": {
|
| 23 |
+
"type": "string",
|
| 24 |
+
"description": "query1||query2"
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
"required": ["queries"],
|
| 28 |
+
"additionalProperties": false
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"type": "function",
|
| 34 |
+
"function": {
|
| 35 |
+
"name": "open",
|
| 36 |
+
"description": "Batch open IDs (format 【{id}†...】) or URLs.",
|
| 37 |
+
"parameters": {
|
| 38 |
+
"type": "object",
|
| 39 |
+
"properties": {
|
| 40 |
+
"open_list": {
|
| 41 |
+
"type": "array",
|
| 42 |
+
"items": {
|
| 43 |
+
"type": "object",
|
| 44 |
+
"properties": {
|
| 45 |
+
"id": {
|
| 46 |
+
"description": "ID or URL",
|
| 47 |
+
"anyOf": [{"type": "integer"}, {"type": "string"}],
|
| 48 |
+
"default": -1
|
| 49 |
+
},
|
| 50 |
+
"loc": {"type": "integer", "description": "Start line", "default": -1},
|
| 51 |
+
"num_lines": {"type": "integer", "description": "", "default": -1}
|
| 52 |
+
},
|
| 53 |
+
"additionalProperties": false
|
| 54 |
+
},
|
| 55 |
+
"description": ""
|
| 56 |
+
}
|
| 57 |
+
},
|
| 58 |
+
"required": ["open_list"],
|
| 59 |
+
"additionalProperties": false
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
]
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"role": "user",
|
| 67 |
+
"content": "小柴胡冲剂和布洛芬能一起吃吗?"
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"role": "assistant",
|
| 71 |
+
"content": "",
|
| 72 |
+
"reasoning_content": "用户想知道小柴胡冲剂和布洛芬能否一起服用。",
|
| 73 |
+
"tool_calls": [
|
| 74 |
+
{
|
| 75 |
+
"type": "function",
|
| 76 |
+
"function": {
|
| 77 |
+
"name": "search",
|
| 78 |
+
"arguments": "{\"queries\": \"小柴胡冲剂 布洛芬 相互作用 一起吃\"}"
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"role": "tool",
|
| 85 |
+
"content": "[0]"
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"role": "assistant",
|
| 89 |
+
"content": "请及时就医。",
|
| 90 |
+
"reasoning_content": "现在开始组织回答。",
|
| 91 |
+
"tool_calls": []
|
| 92 |
+
}
|
| 93 |
+
]
|
encoding/tests/test_input_4.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"role": "system",
|
| 4 |
+
"content": "该助手为DeepSeek-V3,由深度求索公司创造。\n今天是2025年10月17日,星期五。"
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"role": "latest_reminder",
|
| 8 |
+
"content": "2024-11-15,上海市,App,中文"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"role": "user",
|
| 12 |
+
"content": "热海大滚锅是世界著名温泉吗"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"role": "assistant",
|
| 16 |
+
"content": "热海大滚锅在中国乃至全球的地热奇观中占有重要地位,但“世界著名”的称号更侧重于它作为独特的地质现象和旅游景点。",
|
| 17 |
+
"mask": 1
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"role": "user",
|
| 21 |
+
"content": "世界著名温泉有哪些",
|
| 22 |
+
"task": "action"
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"role": "assistant",
|
| 26 |
+
"content": "Search"
|
| 27 |
+
}
|
| 28 |
+
]
|
encoding/tests/test_input_5.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"thinking_mode": "thinking",
|
| 3 |
+
"reasoning_effort": "max",
|
| 4 |
+
"messages": [
|
| 5 |
+
{
|
| 6 |
+
"role": "system",
|
| 7 |
+
"content": "You are a helpful vision assistant."
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"role": "user",
|
| 11 |
+
"content": [
|
| 12 |
+
{
|
| 13 |
+
"type": "text",
|
| 14 |
+
"text": "请按“第一张、第二张”的顺序回答:第一张图"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"type": "image_url",
|
| 18 |
+
"image_url": {
|
| 19 |
+
"url": "examples/images/carrots.jpeg"
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"type": "text",
|
| 24 |
+
"text": "和第二张图"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"type": "image_url",
|
| 28 |
+
"image_url": {
|
| 29 |
+
"url": "examples/images/corn.jpeg"
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"type": "text",
|
| 34 |
+
"text": "中分别是什么食材?它们通常食用的部位分别是什么?"
|
| 35 |
+
}
|
| 36 |
+
]
|
| 37 |
+
}
|
| 38 |
+
]
|
| 39 |
+
}
|
encoding/tests/test_output_1.txt
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|><|System|>Reasoning Effort: 75 (range 1-100, the higher the value, the more thorough the reasoning)
|
| 2 |
+
|
| 3 |
+
You are a helpful assistant.
|
| 4 |
+
|
| 5 |
+
## Tools
|
| 6 |
+
|
| 7 |
+
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML| calls>" block like the following:
|
| 8 |
+
|
| 9 |
+
<|DSML| calls>
|
| 10 |
+
<|DSML| invoke name="$TOOL_NAME">
|
| 11 |
+
<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
|
| 12 |
+
...
|
| 13 |
+
</|DSML| invoke>
|
| 14 |
+
<|DSML| invoke name="$TOOL_NAME2">
|
| 15 |
+
...
|
| 16 |
+
</|DSML| invoke>
|
| 17 |
+
</|DSML| calls>
|
| 18 |
+
|
| 19 |
+
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
|
| 20 |
+
|
| 21 |
+
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
|
| 22 |
+
|
| 23 |
+
Otherwise, output directly after </think> with tool calls or final response.
|
| 24 |
+
|
| 25 |
+
### Available Tool Schemas
|
| 26 |
+
|
| 27 |
+
{"name": "get_weather", "description": "Get the weather for a specific location", "parameters": {"type": "object", "properties": {"location": {"type": "string", "description": "The city name"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Temperature unit"}}, "required": ["location"]}}
|
| 28 |
+
{"name": "search", "description": "Search the web for information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Search query"}, "num_results": {"type": "integer", "description": "Number of results to return"}}, "required": ["query"]}}
|
| 29 |
+
|
| 30 |
+
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
|
| 31 |
+
<|User|>What's the weather like in Beijing?<|Assistant|><think>The user wants the weather in Beijing. I should call get_weather.</think>
|
| 32 |
+
|
| 33 |
+
<|DSML| calls>
|
| 34 |
+
<|DSML| invoke name="get_weather">
|
| 35 |
+
<|DSML| parameter name="location" string="true">Beijing</|DSML| parameter>
|
| 36 |
+
<|DSML| parameter name="unit" string="true">celsius</|DSML| parameter>
|
| 37 |
+
</|DSML| invoke>
|
| 38 |
+
</|DSML| calls><|end▁of▁sentence|><|User|><tool_result>{"temperature": 22, "condition": "sunny", "humidity": 45}</tool_result><|Assistant|><think>Got the weather data. Let me format a nice response.</think>The weather in Beijing is currently sunny with a temperature of 22°C and 45% humidity.<|end▁of▁sentence|>
|
encoding/tests/test_output_2.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|><|System|>You are a helpful assistant.<|User|>Hello<|Assistant|></think>Hi there! How can I help you?<|end▁of▁sentence|><|User|>What is the capital of France?<|Assistant|></think>The capital of France is Paris.<|end▁of▁sentence|>
|
encoding/tests/test_output_3.txt
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|><|System|>该助手为DeepSeek,由深度求索公司创造。<|latest_reminder|>2026-02-21,星期六,广州,App,中文<|System|>CITATION FORMAT: 【{cursor_id}†L{start_line_id}(-L{end_line_id})?】
|
| 2 |
+
|
| 3 |
+
## Tools
|
| 4 |
+
|
| 5 |
+
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML| calls>" block like the following:
|
| 6 |
+
|
| 7 |
+
<|DSML| calls>
|
| 8 |
+
<|DSML| invoke name="$TOOL_NAME">
|
| 9 |
+
<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
|
| 10 |
+
...
|
| 11 |
+
</|DSML| invoke>
|
| 12 |
+
<|DSML| invoke name="$TOOL_NAME2">
|
| 13 |
+
...
|
| 14 |
+
</|DSML| invoke>
|
| 15 |
+
</|DSML| calls>
|
| 16 |
+
|
| 17 |
+
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
|
| 18 |
+
|
| 19 |
+
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
|
| 20 |
+
|
| 21 |
+
Otherwise, output directly after </think> with tool calls or final response.
|
| 22 |
+
|
| 23 |
+
### Available Tool Schemas
|
| 24 |
+
|
| 25 |
+
{"name": "search", "description": "Web search. Split multiple queries with '||'.", "parameters": {"type": "object", "properties": {"queries": {"type": "string", "description": "query1||query2"}}, "required": ["queries"], "additionalProperties": false}}
|
| 26 |
+
{"name": "open", "description": "Batch open IDs (format 【{id}†...】) or URLs.", "parameters": {"type": "object", "properties": {"open_list": {"type": "array", "items": {"type": "object", "properties": {"id": {"description": "ID or URL", "anyOf": [{"type": "integer"}, {"type": "string"}], "default": -1}, "loc": {"type": "integer", "description": "Start line", "default": -1}, "num_lines": {"type": "integer", "description": "", "default": -1}}, "additionalProperties": false}, "description": ""}}, "required": ["open_list"], "additionalProperties": false}}
|
| 27 |
+
|
| 28 |
+
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
|
| 29 |
+
<|User|>小柴胡冲剂和布洛芬能一起吃吗?<|Assistant|></think>
|
| 30 |
+
|
| 31 |
+
<|DSML| calls>
|
| 32 |
+
<|DSML| invoke name="search">
|
| 33 |
+
<|DSML| parameter name="queries" string="true">小柴胡冲剂 布洛芬 相互作用 一起吃</|DSML| parameter>
|
| 34 |
+
</|DSML| invoke>
|
| 35 |
+
</|DSML| calls><|end▁of▁sentence|><|User|><tool_result>[0]</tool_result><|Assistant|></think>请及时就医。<|end▁of▁sentence|>
|
encoding/tests/test_output_4.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|><|System|>该助手为DeepSeek-V3,由深度求索公司创造。
|
| 2 |
+
今天是2025年10月17日,星期五。<|latest_reminder|>2024-11-15,上海市,App,中文<|User|>热海大滚锅是世界著名温泉吗<|Assistant|></think>热海大滚锅在中国乃至全球的地热奇观中占有重要地位,但“世界著名”的称号更侧重于它作为独特的地质现象和旅游景点。<|end▁of▁sentence|><|User|>世界著名温泉有哪些<|Assistant|></think><|action|>Search<|end▁of▁sentence|>
|
encoding/tests/test_output_5.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|><|System|>Reasoning Effort: 100 (range 1-100, the higher the value, the more thorough the reasoning)
|
| 2 |
+
|
| 3 |
+
You are a helpful vision assistant.<|User|>请按“第一张、第二张”的顺序回答:第一张图
|
| 4 |
+
|
| 5 |
+
<|deepseek_image|>
|
| 6 |
+
|
| 7 |
+
和第二张图
|
| 8 |
+
|
| 9 |
+
<|deepseek_image|>
|
| 10 |
+
|
| 11 |
+
中分别是什么食材?它们通常食用的部位分别是什么?<|Assistant|><think>
|
evaluation/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Running DeepSWE with `dsh-minimal` and `mini-swe-agent`
|
| 2 |
+
|
| 3 |
+
## 1. Prerequisites
|
| 4 |
+
|
| 5 |
+
- Docker, running and able to pull images
|
| 6 |
+
- Python 3.12+ and [uv](https://docs.astral.sh/uv/)
|
| 7 |
+
- An endpoint and key for any DeepSeek-API-compatible service (the DeepSeek official API is used as the example below)
|
| 8 |
+
|
| 9 |
+
```sh
|
| 10 |
+
export DEEPSEEK_API_KEY=sk-your-key-here
|
| 11 |
+
export DEEPSEEK_BASE_URL=https://api.deepseek.com
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
## 2. Get Pier and DeepSWE
|
| 15 |
+
|
| 16 |
+
```sh
|
| 17 |
+
git clone https://github.com/datacurve-ai/pier.git
|
| 18 |
+
git -C pier checkout 0c802fc067a425345b24d1c69411aa98acf61a1d
|
| 19 |
+
|
| 20 |
+
git clone https://github.com/datacurve-ai/deep-swe.git
|
| 21 |
+
git -C deep-swe checkout 0b9fabbb63b9104d678fe965e1632f2dd9eaa2ea
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
## 3. Patch and install Pier
|
| 25 |
+
|
| 26 |
+
`dsh-minimal.patch` ships next to this document. Treat it as a **reference patch** and adapt it to your own setup.
|
| 27 |
+
|
| 28 |
+
```sh
|
| 29 |
+
cd pier
|
| 30 |
+
git apply /path/to/dsh-minimal.patch
|
| 31 |
+
uv sync
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
What the patch changes:
|
| 35 |
+
|
| 36 |
+
- **Adds the `dsh-minimal` agent**, which drives the Harness SDK and folds its event stream into a Pier ATIF trajectory. The SDK artifact is never installed into the image: step 4's `--mounts-json` bind-mounts it read-only into the sandbox, so no trial installs anything.
|
| 37 |
+
- **Appends a runtime-constraints section to the task instruction for both agents**: work in `/app`, leave `/tests` alone, no network or package mirror.
|
| 38 |
+
- **Passes test-runner concurrency caps into the container**: Docker's `--cpus` is only a quota, so `nproc` inside the container reports the host's core count and test runners size their worker pools from that rather than from the container's share.
|
| 39 |
+
- **Enables IPv6 loopback in the container**: Docker disables it by default, so suites that bind `::1` are skipped and scored as failures.
|
| 40 |
+
- **Makes `--mounts-json` additive instead of replacing the default mounts**, keeping the `/logs` binds that carry agent logs and collected patches.
|
| 41 |
+
|
| 42 |
+
## 4. Run the suite
|
| 43 |
+
|
| 44 |
+
Both agents take the same task set, concurrency, and `--no-delete` (which keeps the task images cached between trials). Repeat each run with a different `--job-name` and average the results.
|
| 45 |
+
|
| 46 |
+
Each trial's container takes the 2 CPUs and 8 GB its task declares, so size `-n` against the host's cores and memory.
|
| 47 |
+
|
| 48 |
+
### `mini-swe-agent`
|
| 49 |
+
|
| 50 |
+
Pier installs it into each task image at trial time, so no host-side preparation is needed.
|
| 51 |
+
|
| 52 |
+
```sh
|
| 53 |
+
uv run pier run \
|
| 54 |
+
-p ../deep-swe/tasks \
|
| 55 |
+
--agent mini-swe-agent \
|
| 56 |
+
--model deepseek/deepseek-flash \
|
| 57 |
+
--ak reasoning_effort=max \
|
| 58 |
+
--ak cost_limit=0 \
|
| 59 |
+
--ae DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
|
| 60 |
+
--ae DEEPSEEK_BASE_URL="$DEEPSEEK_BASE_URL" \
|
| 61 |
+
-n 32 --no-delete -r 2 --job-name deepswe-mini-run1 -y
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
- `--model` takes a litellm-style `provider/model` string.
|
| 65 |
+
|
| 66 |
+
### `dsh-minimal`
|
| 67 |
+
|
| 68 |
+
Install the Harness SDK artifact once on the host, then bind-mount it read-only into every container.
|
| 69 |
+
|
| 70 |
+
```sh
|
| 71 |
+
mkdir -p ~/dsh-minimal && cd ~/dsh-minimal
|
| 72 |
+
uv pip install --target dsh-dist \
|
| 73 |
+
--python-version 3.12 --python-platform x86_64-manylinux_2_28 \
|
| 74 |
+
'deepseek-harness-sdk==0.1.5.*'
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
```sh
|
| 78 |
+
uv run pier run \
|
| 79 |
+
-p ../deep-swe/tasks \
|
| 80 |
+
--agent dsh-minimal \
|
| 81 |
+
--model deepseek-flash \
|
| 82 |
+
--ak reasoning_effort=max \
|
| 83 |
+
--ae DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
|
| 84 |
+
--ae DEEPSEEK_BASE_URL="$DEEPSEEK_BASE_URL" \
|
| 85 |
+
--mounts-json '[{"type":"bind","source":"'"$HOME"'/dsh-minimal/dsh-dist","target":"/opt/dsh-minimal","read_only":true}]' \
|
| 86 |
+
-n 32 --no-delete --job-name deepswe-dsh-run1 -y
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
- In `--mounts-json`, `source` is the absolute path of the `dsh-dist` directory above; `target` is always `/opt/dsh-minimal`.
|
| 90 |
+
|
| 91 |
+
## 5. Read the results
|
| 92 |
+
|
| 93 |
+
```
|
| 94 |
+
jobs/<job-name>/
|
| 95 |
+
result.json pass rate and token totals
|
| 96 |
+
<task>__<id>/
|
| 97 |
+
result.json reward, fail-to-pass / pass-to-pass counts, tokens
|
| 98 |
+
agent/trajectory.json full ATIF trajectory (dsh-minimal)
|
| 99 |
+
agent/mini-swe-agent.trajectory.json mini-swe-agent trajectory
|
| 100 |
+
verifier/ reward.json and test output
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
Browse a job with `uv run pier view jobs/<job-name>`.
|
evaluation/dsh-minimal.patch
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/src/pier/agents/factory.py b/src/pier/agents/factory.py
|
| 2 |
+
index 665fadd..503a45d 100644
|
| 3 |
+
--- a/src/pier/agents/factory.py
|
| 4 |
+
+++ b/src/pier/agents/factory.py
|
| 5 |
+
@@ -6,6 +6,7 @@ from pier.agents.installed.antigravity_sdk import AntigravitySDK
|
| 6 |
+
from pier.agents.installed.claude_code import ClaudeCode
|
| 7 |
+
from pier.agents.installed.codex import Codex
|
| 8 |
+
from pier.agents.installed.cursor_cli import CursorCli
|
| 9 |
+
+from pier.agents.installed.dsh_minimal import DshMinimal
|
| 10 |
+
from pier.agents.installed.gemini_cli import GeminiCli
|
| 11 |
+
from pier.agents.installed.mini_swe_agent import MiniSweAgent
|
| 12 |
+
from pier.agents.installed.opencode import OpenCode
|
| 13 |
+
@@ -24,6 +25,7 @@ class AgentFactory:
|
| 14 |
+
ClaudeCode,
|
| 15 |
+
Codex,
|
| 16 |
+
CursorCli,
|
| 17 |
+
+ DshMinimal,
|
| 18 |
+
GeminiCli,
|
| 19 |
+
MiniSweAgent,
|
| 20 |
+
OpenCode,
|
| 21 |
+
diff --git a/src/pier/agents/installed/base.py b/src/pier/agents/installed/base.py
|
| 22 |
+
index 16f6a84..1fd1050 100644
|
| 23 |
+
--- a/src/pier/agents/installed/base.py
|
| 24 |
+
+++ b/src/pier/agents/installed/base.py
|
| 25 |
+
@@ -15,6 +15,18 @@ from pier.utils.env import parse_bool_env_value
|
| 26 |
+
from pier.utils.templating import render_prompt_template
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
+RUNTIME_CONSTRAINTS = (
|
| 30 |
+
+ "\n## Runtime constraints\n"
|
| 31 |
+
+ "- Work in `/app`; do not modify files under `/tests`.\n"
|
| 32 |
+
+ "- No network or mirror access; use only dependencies already in the image.\n"
|
| 33 |
+
+)
|
| 34 |
+
+
|
| 35 |
+
+
|
| 36 |
+
+def with_runtime_constraints(instruction: str) -> str:
|
| 37 |
+
+ """Append the sandbox constraints to a task instruction."""
|
| 38 |
+
+ return instruction.rstrip("\n") + "\n" + RUNTIME_CONSTRAINTS
|
| 39 |
+
+
|
| 40 |
+
+
|
| 41 |
+
class NonZeroAgentExitCodeError(RuntimeError):
|
| 42 |
+
"""Raised when the agent process exits with a non-zero exit code."""
|
| 43 |
+
|
| 44 |
+
@@ -394,12 +406,14 @@ class BaseInstalledAgent(BaseAgent, ABC):
|
| 45 |
+
return instruction
|
| 46 |
+
|
| 47 |
+
@abstractmethod
|
| 48 |
+
- def install_spec(self) -> AgentInstallSpec:
|
| 49 |
+
- """Declarative install steps executed at setup and inlined into Dockerfile builds."""
|
| 50 |
+
+ def install_spec(self) -> AgentInstallSpec | None:
|
| 51 |
+
+ """Declarative install steps executed at setup and inlined into Dockerfile
|
| 52 |
+
+ builds, or ``None`` for an agent already present in the environment."""
|
| 53 |
+
|
| 54 |
+
async def install(self, environment: BaseEnvironment) -> None:
|
| 55 |
+
"""Run each step from :meth:`install_spec` with matching privilege."""
|
| 56 |
+
- for step in self.install_spec().steps:
|
| 57 |
+
+ spec = self.install_spec()
|
| 58 |
+
+ for step in spec.steps if spec is not None else ():
|
| 59 |
+
if step.user == "root":
|
| 60 |
+
await self.exec_as_root(environment, command=step.run, env=step.env)
|
| 61 |
+
else:
|
| 62 |
+
diff --git a/src/pier/agents/installed/dsh_minimal.py b/src/pier/agents/installed/dsh_minimal.py
|
| 63 |
+
new file mode 100644
|
| 64 |
+
index 0000000..c84a1b1
|
| 65 |
+
--- /dev/null
|
| 66 |
+
+++ b/src/pier/agents/installed/dsh_minimal.py
|
| 67 |
+
@@ -0,0 +1,131 @@
|
| 68 |
+
+import shlex
|
| 69 |
+
+from pathlib import Path
|
| 70 |
+
+from typing import Any, override
|
| 71 |
+
+
|
| 72 |
+
+from pier.agents.installed.base import (
|
| 73 |
+
+ BaseInstalledAgent,
|
| 74 |
+
+ with_prompt_template,
|
| 75 |
+
+ with_runtime_constraints,
|
| 76 |
+
+)
|
| 77 |
+
+from pier.agents.network import allowlist_from_urls
|
| 78 |
+
+from pier.environments.base import BaseEnvironment
|
| 79 |
+
+from pier.models.agent.context import AgentContext
|
| 80 |
+
+from pier.models.agent.name import AgentName
|
| 81 |
+
+from pier.models.agent.network import NetworkAllowlist
|
| 82 |
+
+from pier.models.trajectories import Trajectory
|
| 83 |
+
+from pier.utils.trajectory_metrics import populate_context_from_final_metrics
|
| 84 |
+
+
|
| 85 |
+
+DIST = "/opt/dsh-minimal"
|
| 86 |
+
+DEFAULT_MODEL = "deepseek-flash"
|
| 87 |
+
+DEFAULT_BASE_URL = "https://api.deepseek.com"
|
| 88 |
+
+REASONING_EFFORTS = ("low", "high", "max")
|
| 89 |
+
+
|
| 90 |
+
+
|
| 91 |
+
+class DshMinimal(BaseInstalledAgent):
|
| 92 |
+
+ """DeepSeek Harness `sdk-minimal` profile driven through its Python SDK.
|
| 93 |
+
+
|
| 94 |
+
+ The SDK and its bundled runtime executable come from a `pip install --target`
|
| 95 |
+
+ tree bind-mounted read-only at ``DIST``, so no trial installs anything.
|
| 96 |
+
+ """
|
| 97 |
+
+
|
| 98 |
+
+ SUPPORTS_ATIF = True
|
| 99 |
+
+ _TRAJECTORY = "trajectory.json"
|
| 100 |
+
+ # The distribution mount is read-only, so the runner lands beside it.
|
| 101 |
+
+ _RUNNER = "/tmp/dsh-minimal-runner.py"
|
| 102 |
+
+
|
| 103 |
+
+ def __init__(
|
| 104 |
+
+ self, *args: Any, reasoning_effort: str = "max", **kwargs: Any
|
| 105 |
+
+ ) -> None:
|
| 106 |
+
+ if reasoning_effort not in REASONING_EFFORTS:
|
| 107 |
+
+ raise ValueError(
|
| 108 |
+
+ f"reasoning_effort must be one of {REASONING_EFFORTS}, "
|
| 109 |
+
+ f"got {reasoning_effort!r}"
|
| 110 |
+
+ )
|
| 111 |
+
+ self._reasoning_effort = reasoning_effort
|
| 112 |
+
+ kwargs["model_name"] = kwargs.get("model_name") or DEFAULT_MODEL
|
| 113 |
+
+ super().__init__(*args, **kwargs)
|
| 114 |
+
+
|
| 115 |
+
+ @staticmethod
|
| 116 |
+
+ @override
|
| 117 |
+
+ def name() -> str:
|
| 118 |
+
+ return AgentName.DSH_MINIMAL.value
|
| 119 |
+
+
|
| 120 |
+
+ @override
|
| 121 |
+
+ def install_spec(self) -> None:
|
| 122 |
+
+ """The distribution is bind-mounted, so no image layer is needed."""
|
| 123 |
+
+ return None
|
| 124 |
+
+
|
| 125 |
+
+ @override
|
| 126 |
+
+ def get_version_command(self) -> str:
|
| 127 |
+
+ return f"sed -n 's/^Version: //p' {DIST}/deepseek_harness_sdk-*.dist-info/METADATA"
|
| 128 |
+
+
|
| 129 |
+
+ @override
|
| 130 |
+
+ async def setup(self, environment: BaseEnvironment) -> None:
|
| 131 |
+
+ await super().setup(environment)
|
| 132 |
+
+ runner = Path(__file__).with_name("dsh_minimal_runner.py")
|
| 133 |
+
+ await environment.upload_file(runner, self._RUNNER)
|
| 134 |
+
+ await self.exec_as_root(environment, f"chmod a+r {self._RUNNER}")
|
| 135 |
+
+
|
| 136 |
+
+ def _base_url(self) -> str:
|
| 137 |
+
+ return self._get_env("DEEPSEEK_BASE_URL") or DEFAULT_BASE_URL
|
| 138 |
+
+
|
| 139 |
+
+ @override
|
| 140 |
+
+ def network_allowlist(self) -> NetworkAllowlist:
|
| 141 |
+
+ return allowlist_from_urls([self._base_url()])
|
| 142 |
+
+
|
| 143 |
+
+ @override
|
| 144 |
+
+ def populate_context_post_run(self, context: AgentContext) -> None:
|
| 145 |
+
+ path = self.logs_dir / self._TRAJECTORY
|
| 146 |
+
+ if not path.exists():
|
| 147 |
+
+ self.logger.debug("No dsh-minimal trajectory found at %s", path)
|
| 148 |
+
+ return
|
| 149 |
+
+ try:
|
| 150 |
+
+ trajectory = Trajectory.model_validate_json(path.read_text())
|
| 151 |
+
+ except (OSError, ValueError):
|
| 152 |
+
+ self.logger.exception("Failed to parse dsh-minimal trajectory")
|
| 153 |
+
+ return
|
| 154 |
+
+ if trajectory.final_metrics is not None:
|
| 155 |
+
+ populate_context_from_final_metrics(context, trajectory.final_metrics)
|
| 156 |
+
+ context.n_agent_steps = sum(step.source == "agent" for step in trajectory.steps)
|
| 157 |
+
+
|
| 158 |
+
+ @override
|
| 159 |
+
+ @with_prompt_template
|
| 160 |
+
+ async def run(
|
| 161 |
+
+ self,
|
| 162 |
+
+ instruction: str,
|
| 163 |
+
+ environment: BaseEnvironment,
|
| 164 |
+
+ context: AgentContext,
|
| 165 |
+
+ ) -> None:
|
| 166 |
+
+ api_key = self._get_env("DEEPSEEK_API_KEY")
|
| 167 |
+
+ if not api_key:
|
| 168 |
+
+ raise ValueError("DEEPSEEK_API_KEY environment variable must be set")
|
| 169 |
+
+
|
| 170 |
+
+ instruction = with_runtime_constraints(instruction)
|
| 171 |
+
+ agent_dir = environment.env_paths.agent_dir.as_posix()
|
| 172 |
+
+ env = self.build_process_env(
|
| 173 |
+
+ {
|
| 174 |
+
+ "DEEPSEEK_API_KEY": api_key,
|
| 175 |
+
+ "DEEPSEEK_BASE_URL": self._base_url(),
|
| 176 |
+
+ # The Node runtime reaches the model only through Pier's egress
|
| 177 |
+
+ # proxy, and its fetch ignores the proxy variables without this.
|
| 178 |
+
+ "NODE_USE_ENV_PROXY": "1",
|
| 179 |
+
+ "PYTHONPATH": DIST,
|
| 180 |
+
+ "DSH_MODEL": self._parsed_model_name,
|
| 181 |
+
+ "DSH_REASONING_EFFORT": self._reasoning_effort,
|
| 182 |
+
+ # Required, and kept off the /logs bind mount: it materializes many
|
| 183 |
+
+ # files the host cannot read anyway. `trajectory.json` is the record.
|
| 184 |
+
+ "DSH_HOME": "/tmp/dsh-home",
|
| 185 |
+
+ "SESSION_ID": environment.session_id,
|
| 186 |
+
+ }
|
| 187 |
+
+ )
|
| 188 |
+
+
|
| 189 |
+
+ await self.exec_as_agent(
|
| 190 |
+
+ environment,
|
| 191 |
+
+ command=(
|
| 192 |
+
+ f"python3 {self._RUNNER} "
|
| 193 |
+
+ f"--instruction {shlex.quote(instruction)} "
|
| 194 |
+
+ f"--trajectory-path {agent_dir}/{self._TRAJECTORY} "
|
| 195 |
+
+ f"2>&1 </dev/null | stdbuf -oL tee {agent_dir}/dsh-minimal.txt"
|
| 196 |
+
+ ),
|
| 197 |
+
+ env=env,
|
| 198 |
+
+ )
|
| 199 |
+
diff --git a/src/pier/agents/installed/dsh_minimal_runner.py b/src/pier/agents/installed/dsh_minimal_runner.py
|
| 200 |
+
new file mode 100644
|
| 201 |
+
index 0000000..8b69d7d
|
| 202 |
+
--- /dev/null
|
| 203 |
+
+++ b/src/pier/agents/installed/dsh_minimal_runner.py
|
| 204 |
+
@@ -0,0 +1,307 @@
|
| 205 |
+
+"""Run the DeepSeek Harness `sdk-minimal` profile and persist its stream as ATIF v1.7."""
|
| 206 |
+
+
|
| 207 |
+
+from __future__ import annotations
|
| 208 |
+
+
|
| 209 |
+
+import argparse
|
| 210 |
+
+import json
|
| 211 |
+
+import os
|
| 212 |
+
+import sys
|
| 213 |
+
+import traceback
|
| 214 |
+
+from datetime import datetime, timezone
|
| 215 |
+
+from importlib.metadata import PackageNotFoundError, version
|
| 216 |
+
+from pathlib import Path
|
| 217 |
+
+from typing import Any
|
| 218 |
+
+
|
| 219 |
+
+from deepseek_harness import DeepSeekHarness
|
| 220 |
+
+from deepseek_harness.models import Notification
|
| 221 |
+
+
|
| 222 |
+
+PROVIDER = "deepseek-official"
|
| 223 |
+
+
|
| 224 |
+
+
|
| 225 |
+
+def _iso(epoch_ms: Any) -> str | None:
|
| 226 |
+
+ if not isinstance(epoch_ms, (int, float)):
|
| 227 |
+
+ return None
|
| 228 |
+
+ return datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc).isoformat()
|
| 229 |
+
+
|
| 230 |
+
+
|
| 231 |
+
+def _text(blocks: Any, kind: str) -> str:
|
| 232 |
+
+ if not isinstance(blocks, list):
|
| 233 |
+
+ return ""
|
| 234 |
+
+ return "".join(
|
| 235 |
+
+ str(block.get("text") or "")
|
| 236 |
+
+ for block in blocks
|
| 237 |
+
+ if isinstance(block, dict) and block.get("type") == kind
|
| 238 |
+
+ )
|
| 239 |
+
+
|
| 240 |
+
+
|
| 241 |
+
+def _flatten(blocks: Any) -> str:
|
| 242 |
+
+ if isinstance(blocks, str):
|
| 243 |
+
+ return blocks
|
| 244 |
+
+ if not isinstance(blocks, list):
|
| 245 |
+
+ return json.dumps(blocks, ensure_ascii=False)
|
| 246 |
+
+ return "".join(
|
| 247 |
+
+ str(block.get("text") or "")
|
| 248 |
+
+ if isinstance(block, dict) and block.get("type") == "text"
|
| 249 |
+
+ else json.dumps(block, ensure_ascii=False)
|
| 250 |
+
+ for block in blocks
|
| 251 |
+
+ )
|
| 252 |
+
+
|
| 253 |
+
+
|
| 254 |
+
+def _arguments(raw: Any) -> dict[str, Any]:
|
| 255 |
+
+ """ATIF wants an object; the wire carries the model's raw argument string."""
|
| 256 |
+
+ if isinstance(raw, dict):
|
| 257 |
+
+ return raw
|
| 258 |
+
+ if isinstance(raw, str):
|
| 259 |
+
+ try:
|
| 260 |
+
+ parsed = json.loads(raw)
|
| 261 |
+
+ except json.JSONDecodeError:
|
| 262 |
+
+ return {"_unparsed_arguments": raw}
|
| 263 |
+
+ return parsed if isinstance(parsed, dict) else {"_arguments": parsed}
|
| 264 |
+
+ return {}
|
| 265 |
+
+
|
| 266 |
+
+
|
| 267 |
+
+def _metrics(usage: dict[str, Any]) -> dict[str, Any]:
|
| 268 |
+
+ """The adapter reports disjoint counts, so the cache legs rejoin prompt tokens."""
|
| 269 |
+
+ cache_read = usage.get("cacheReadTokens") or 0
|
| 270 |
+
+ cache_write = usage.get("cacheWriteTokens") or 0
|
| 271 |
+
+ metrics = {
|
| 272 |
+
+ "prompt_tokens": (usage.get("inputTokens") or 0) + cache_read + cache_write,
|
| 273 |
+
+ "completion_tokens": usage.get("outputTokens") or 0,
|
| 274 |
+
+ "cached_tokens": cache_read,
|
| 275 |
+
+ }
|
| 276 |
+
+ if usage.get("reasoningTokens") is not None:
|
| 277 |
+
+ metrics["extra"] = {"reasoning_tokens": usage["reasoningTokens"]}
|
| 278 |
+
+ return metrics
|
| 279 |
+
+
|
| 280 |
+
+
|
| 281 |
+
+class Collector:
|
| 282 |
+
+ """Fold root-session events into one ATIF step per model call."""
|
| 283 |
+
+
|
| 284 |
+
+ def __init__(self, instruction: str, model: str, effort: str, session: str) -> None:
|
| 285 |
+
+ self.model = model
|
| 286 |
+
+ self.effort = effort
|
| 287 |
+
+ self.session = session
|
| 288 |
+
+ self.steps: list[dict[str, Any]] = [
|
| 289 |
+
+ {
|
| 290 |
+
+ "step_id": 1,
|
| 291 |
+
+ "timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
| 292 |
+
+ "source": "user",
|
| 293 |
+
+ "message": instruction,
|
| 294 |
+
+ }
|
| 295 |
+
+ ]
|
| 296 |
+
+ # (turn, step) identifies one model call; callId maps a later `tool/result`
|
| 297 |
+
+ # back to the step that issued it, which is never the step it arrives in.
|
| 298 |
+
+ self._by_call: dict[tuple[int, int], dict[str, Any]] = {}
|
| 299 |
+
+ self._by_call_id: dict[str, dict[str, Any]] = {}
|
| 300 |
+
+ self.turn_end_reasons: list[dict[str, Any]] = []
|
| 301 |
+
+ self.llm_retries = 0
|
| 302 |
+
+
|
| 303 |
+
+ def _step(self, turn: Any, step: Any, timestamp: str | None) -> dict[str, Any]:
|
| 304 |
+
+ key = (int(turn or 0), int(step or 0))
|
| 305 |
+
+ if key not in self._by_call:
|
| 306 |
+
+ created = {
|
| 307 |
+
+ "step_id": len(self.steps) + 1,
|
| 308 |
+
+ "timestamp": timestamp,
|
| 309 |
+
+ "source": "agent",
|
| 310 |
+
+ "message": "",
|
| 311 |
+
+ "model_name": self.model,
|
| 312 |
+
+ "reasoning_effort": self.effort,
|
| 313 |
+
+ "llm_call_count": 1,
|
| 314 |
+
+ }
|
| 315 |
+
+ self.steps.append(created)
|
| 316 |
+
+ self._by_call[key] = created
|
| 317 |
+
+ return self._by_call[key]
|
| 318 |
+
+
|
| 319 |
+
+ def _tool_call(self, step: dict[str, Any], call_id: Any, name: Any, raw: Any) -> None:
|
| 320 |
+
+ if not isinstance(call_id, str) or not call_id:
|
| 321 |
+
+ return
|
| 322 |
+
+ calls = step.setdefault("tool_calls", [])
|
| 323 |
+
+ entry = {
|
| 324 |
+
+ "tool_call_id": call_id,
|
| 325 |
+
+ "function_name": str(name),
|
| 326 |
+
+ "arguments": _arguments(raw),
|
| 327 |
+
+ }
|
| 328 |
+
+ for index, existing in enumerate(calls):
|
| 329 |
+
+ if existing["tool_call_id"] == call_id:
|
| 330 |
+
+ calls[index] = entry
|
| 331 |
+
+ break
|
| 332 |
+
+ else:
|
| 333 |
+
+ calls.append(entry)
|
| 334 |
+
+ self._by_call_id[call_id] = step
|
| 335 |
+
+
|
| 336 |
+
+ def record(self, event: dict[str, Any]) -> None:
|
| 337 |
+
+ kind = event.get("type")
|
| 338 |
+
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
| 339 |
+
+ timestamp = _iso(event.get("time"))
|
| 340 |
+
+
|
| 341 |
+
+ if kind == "assistant/message":
|
| 342 |
+
+ step = self._step(data.get("turn"), data.get("step"), timestamp)
|
| 343 |
+
+ message = data.get("message") if isinstance(data.get("message"), dict) else {}
|
| 344 |
+
+ content = message.get("content")
|
| 345 |
+
+ step["message"] = _text(content, "text")
|
| 346 |
+
+ if reasoning := _text(content, "reasoning"):
|
| 347 |
+
+ step["reasoning_content"] = reasoning
|
| 348 |
+
+ source = message.get("source")
|
| 349 |
+
+ if isinstance(source, dict) and isinstance(source.get("model"), str):
|
| 350 |
+
+ step["model_name"] = source["model"]
|
| 351 |
+
+ if isinstance(data.get("usage"), dict):
|
| 352 |
+
+ step["metrics"] = _metrics(data["usage"])
|
| 353 |
+
+ if data.get("interrupted"):
|
| 354 |
+
+ step.setdefault("extra", {})["interrupted"] = True
|
| 355 |
+
+ for block in content if isinstance(content, list) else []:
|
| 356 |
+
+ if isinstance(block, dict) and block.get("type") == "tool-call":
|
| 357 |
+
+ self._tool_call(
|
| 358 |
+
+ step, block.get("id"), block.get("name"), block.get("arguments")
|
| 359 |
+
+ )
|
| 360 |
+
+ elif kind == "tool/call":
|
| 361 |
+
+ step = self._step(data.get("turn"), data.get("step"), timestamp)
|
| 362 |
+
+ self._tool_call(
|
| 363 |
+
+ step, data.get("callId"), data.get("name"), data.get("arguments")
|
| 364 |
+
+ )
|
| 365 |
+
+ elif kind == "tool/result":
|
| 366 |
+
+ self._result(data)
|
| 367 |
+
+ elif kind == "turn/end":
|
| 368 |
+
+ reason = data.get("reason")
|
| 369 |
+
+ self.turn_end_reasons.append(reason if isinstance(reason, dict) else {})
|
| 370 |
+
+ elif kind == "llm/retry":
|
| 371 |
+
+ self.llm_retries += 1
|
| 372 |
+
+
|
| 373 |
+
+ def _result(self, data: dict[str, Any]) -> None:
|
| 374 |
+
+ message = data.get("message") if isinstance(data.get("message"), dict) else {}
|
| 375 |
+
+ for block in message.get("content") or []:
|
| 376 |
+
+ if not isinstance(block, dict) or block.get("type") != "tool-result":
|
| 377 |
+
+ continue
|
| 378 |
+
+ step = self._by_call_id.get(block.get("toolCallId"))
|
| 379 |
+
+ if step is None:
|
| 380 |
+
+ continue
|
| 381 |
+
+ entry: dict[str, Any] = {
|
| 382 |
+
+ "source_call_id": block["toolCallId"],
|
| 383 |
+
+ "content": _flatten(block.get("content")),
|
| 384 |
+
+ }
|
| 385 |
+
+ if block.get("isError"):
|
| 386 |
+
+ entry["extra"] = {"is_error": True, "error": data.get("error")}
|
| 387 |
+
+ results = step.setdefault("observation", {"results": []})["results"]
|
| 388 |
+
+ for index, existing in enumerate(results):
|
| 389 |
+
+ if existing["source_call_id"] == entry["source_call_id"]:
|
| 390 |
+
+ results[index] = entry
|
| 391 |
+
+ break
|
| 392 |
+
+ else:
|
| 393 |
+
+ results.append(entry)
|
| 394 |
+
+
|
| 395 |
+
+ def trajectory(self, finish_reason: str | None, failure: str | None) -> dict[str, Any]:
|
| 396 |
+
+ prompt = completion = cached = peak = 0
|
| 397 |
+
+ for step in self.steps:
|
| 398 |
+
+ metrics = step.get("metrics")
|
| 399 |
+
+ if not isinstance(metrics, dict):
|
| 400 |
+
+ continue
|
| 401 |
+
+ prompt += metrics["prompt_tokens"]
|
| 402 |
+
+ completion += metrics["completion_tokens"]
|
| 403 |
+
+ cached += metrics["cached_tokens"]
|
| 404 |
+
+ peak = max(peak, metrics["prompt_tokens"])
|
| 405 |
+
+ extra = {
|
| 406 |
+
+ "finish_reason": finish_reason,
|
| 407 |
+
+ "turn_end_reasons": self.turn_end_reasons,
|
| 408 |
+
+ "llm_retry_count": self.llm_retries,
|
| 409 |
+
+ }
|
| 410 |
+
+ if failure is not None:
|
| 411 |
+
+ extra["failure"] = failure
|
| 412 |
+
+ return {
|
| 413 |
+
+ "schema_version": "ATIF-v1.7",
|
| 414 |
+
+ "session_id": self.session,
|
| 415 |
+
+ "agent": {"name": "dsh-minimal", "version": _version(), "model_name": self.model},
|
| 416 |
+
+ "steps": self.steps,
|
| 417 |
+
+ "final_metrics": {
|
| 418 |
+
+ "total_prompt_tokens": prompt,
|
| 419 |
+
+ "total_completion_tokens": completion,
|
| 420 |
+
+ "total_cached_tokens": cached,
|
| 421 |
+
+ "total_steps": len(self.steps),
|
| 422 |
+
+ # The sdk-minimal profile has no compaction, so no summary step
|
| 423 |
+
+ # can replace a transcript prefix.
|
| 424 |
+
+ "extra": {"peak_context_tokens": peak, "summarization_count": 0},
|
| 425 |
+
+ },
|
| 426 |
+
+ "extra": extra,
|
| 427 |
+
+ }
|
| 428 |
+
+
|
| 429 |
+
+
|
| 430 |
+
+def _version() -> str:
|
| 431 |
+
+ try:
|
| 432 |
+
+ return version("deepseek-harness-sdk")
|
| 433 |
+
+ except PackageNotFoundError:
|
| 434 |
+
+ return "unknown"
|
| 435 |
+
+
|
| 436 |
+
+
|
| 437 |
+
+def _write(path: Path, value: Any) -> None:
|
| 438 |
+
+ path.parent.mkdir(parents=True, exist_ok=True)
|
| 439 |
+
+ temporary = path.with_suffix(f"{path.suffix}.tmp")
|
| 440 |
+
+ temporary.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n")
|
| 441 |
+
+ temporary.replace(path)
|
| 442 |
+
+
|
| 443 |
+
+
|
| 444 |
+
+def main() -> None:
|
| 445 |
+
+ parser = argparse.ArgumentParser()
|
| 446 |
+
+ parser.add_argument("--instruction", required=True)
|
| 447 |
+
+ parser.add_argument("--trajectory-path", required=True)
|
| 448 |
+
+ args = parser.parse_args()
|
| 449 |
+
+
|
| 450 |
+
+ # Task images can leave the agent user on a 077 umask; the session log and
|
| 451 |
+
+ # trajectory have to stay readable to the host user that collects /logs.
|
| 452 |
+
+ os.umask(0o022)
|
| 453 |
+
+
|
| 454 |
+
+ model = os.environ["DSH_MODEL"]
|
| 455 |
+
+ effort = os.environ.get("DSH_REASONING_EFFORT", "max")
|
| 456 |
+
+ session = os.environ.get("SESSION_ID") or "pier-session"
|
| 457 |
+
+ path = Path(args.trajectory_path)
|
| 458 |
+
+ collector = Collector(args.instruction, model, effort, session)
|
| 459 |
+
+ state: dict[str, Any] = {"finish_reason": None, "failure": None}
|
| 460 |
+
+
|
| 461 |
+
+ def checkpoint() -> None:
|
| 462 |
+
+ _write(path, collector.trajectory(state["finish_reason"], state["failure"]))
|
| 463 |
+
+
|
| 464 |
+
+ def on_notification(notification: Notification) -> None:
|
| 465 |
+
+ payload = notification.payload
|
| 466 |
+
+ event = payload.get("event") if isinstance(payload, dict) else None
|
| 467 |
+
+ if (
|
| 468 |
+
+ notification.method != "session.event"
|
| 469 |
+
+ or not isinstance(event, dict)
|
| 470 |
+
+ # Token deltas repeat what their `assistant/message` already carries.
|
| 471 |
+
+ or event.get("type") == "assistant/chunk"
|
| 472 |
+
+ or payload.get("sessionId") != session
|
| 473 |
+
+ ):
|
| 474 |
+
+ return
|
| 475 |
+
+ collector.record(event)
|
| 476 |
+
+ checkpoint()
|
| 477 |
+
+
|
| 478 |
+
+ checkpoint()
|
| 479 |
+
+ print(f"dsh-minimal: model={model} effort={effort} session={session}")
|
| 480 |
+
+ try:
|
| 481 |
+
+ with DeepSeekHarness(
|
| 482 |
+
+ profile="sdk-minimal",
|
| 483 |
+
+ provider=PROVIDER,
|
| 484 |
+
+ model=model,
|
| 485 |
+
+ reasoning_effort=effort,
|
| 486 |
+
+ cwd=os.getcwd(),
|
| 487 |
+
+ dsh_home=os.environ["DSH_HOME"],
|
| 488 |
+
+ ) as harness:
|
| 489 |
+
+ result = harness.run(
|
| 490 |
+
+ args.instruction, session_id=session, on_notification=on_notification
|
| 491 |
+
+ )
|
| 492 |
+
+ state["finish_reason"] = result.finish_reason
|
| 493 |
+
+ print(f"dsh-minimal: finish_reason={result.finish_reason}")
|
| 494 |
+
+ print(f"dsh-minimal: final_response={result.final_response}")
|
| 495 |
+
+ except Exception as error: # noqa: BLE001 - the trajectory must survive any failure
|
| 496 |
+
+ state["failure"] = f"{type(error).__name__}: {error}"
|
| 497 |
+
+ traceback.print_exc()
|
| 498 |
+
+ finally:
|
| 499 |
+
+ checkpoint()
|
| 500 |
+
+
|
| 501 |
+
+ # `max-tokens` is a bounded model outcome the verifier can still grade; anything
|
| 502 |
+
+ # else non-terminal means the harness never produced a usable turn.
|
| 503 |
+
+ if state["failure"] is not None or state["finish_reason"] not in (
|
| 504 |
+
+ "completed",
|
| 505 |
+
+ "max-tokens",
|
| 506 |
+
+ ):
|
| 507 |
+
+ sys.exit(1)
|
| 508 |
+
+
|
| 509 |
+
+
|
| 510 |
+
+if __name__ == "__main__":
|
| 511 |
+
+ main()
|
| 512 |
+
diff --git a/src/pier/agents/installed/mini_swe_agent.py b/src/pier/agents/installed/mini_swe_agent.py
|
| 513 |
+
index 75d25b6..d283f66 100644
|
| 514 |
+
--- a/src/pier/agents/installed/mini_swe_agent.py
|
| 515 |
+
+++ b/src/pier/agents/installed/mini_swe_agent.py
|
| 516 |
+
@@ -11,6 +11,7 @@ from pier.agents.installed.base import (
|
| 517 |
+
BaseInstalledAgent,
|
| 518 |
+
CliFlag,
|
| 519 |
+
with_prompt_template,
|
| 520 |
+
+ with_runtime_constraints,
|
| 521 |
+
)
|
| 522 |
+
from pier.agents.network import allowlist_from_urls, collect_url_values
|
| 523 |
+
from pier.agents.utils import get_api_key_var_names_from_model_name
|
| 524 |
+
@@ -821,7 +822,7 @@ mini-swe-agent --help
|
| 525 |
+
async def run(
|
| 526 |
+
self, instruction: str, environment: BaseEnvironment, context: AgentContext
|
| 527 |
+
) -> None:
|
| 528 |
+
- augmented_instruction = instruction
|
| 529 |
+
+ augmented_instruction = with_runtime_constraints(instruction)
|
| 530 |
+
if self.mcp_servers:
|
| 531 |
+
mcp_info = "\n\nMCP Servers:\nThe following MCP servers are available for this task.\n"
|
| 532 |
+
for s in self.mcp_servers:
|
| 533 |
+
diff --git a/src/pier/environments/docker/__init__.py b/src/pier/environments/docker/__init__.py
|
| 534 |
+
index 4bb8f11..04a8c6e 100644
|
| 535 |
+
--- a/src/pier/environments/docker/__init__.py
|
| 536 |
+
+++ b/src/pier/environments/docker/__init__.py
|
| 537 |
+
@@ -25,6 +25,9 @@ def write_resources_compose_file(
|
| 538 |
+
*,
|
| 539 |
+
cpu_request: int | None = None,
|
| 540 |
+
cpu_limit: int | None = None,
|
| 541 |
+
+ environment: dict[str, str] | None = None,
|
| 542 |
+
+ sysctls: dict[str, str] | None = None,
|
| 543 |
+
+ volumes: list[str] | None = None,
|
| 544 |
+
memory_request_mb: int | None = None,
|
| 545 |
+
memory_limit_mb: int | None = None,
|
| 546 |
+
) -> Path:
|
| 547 |
+
@@ -46,7 +49,13 @@ def write_resources_compose_file(
|
| 548 |
+
if reservations:
|
| 549 |
+
resources["reservations"] = reservations
|
| 550 |
+
|
| 551 |
+
- main = {"deploy": {"resources": resources}} if resources else {}
|
| 552 |
+
+ main: dict[str, object] = {"deploy": {"resources": resources}} if resources else {}
|
| 553 |
+
+ if environment:
|
| 554 |
+
+ main["environment"] = dict(environment)
|
| 555 |
+
+ if sysctls:
|
| 556 |
+
+ main["sysctls"] = dict(sysctls)
|
| 557 |
+
+ if volumes:
|
| 558 |
+
+ main["volumes"] = list(volumes)
|
| 559 |
+
compose = {"services": {"main": main}}
|
| 560 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 561 |
+
path.write_text(json.dumps(compose, indent=2))
|
| 562 |
+
diff --git a/src/pier/environments/docker/docker.py b/src/pier/environments/docker/docker.py
|
| 563 |
+
index 2ed4e92..bc671be 100644
|
| 564 |
+
--- a/src/pier/environments/docker/docker.py
|
| 565 |
+
+++ b/src/pier/environments/docker/docker.py
|
| 566 |
+
@@ -31,6 +31,11 @@ from pier.environments.docker import (
|
| 567 |
+
write_mounts_compose_file,
|
| 568 |
+
write_resources_compose_file,
|
| 569 |
+
)
|
| 570 |
+
+from pier.environments.docker.parallelism import (
|
| 571 |
+
+ CPU_CLAMP_PATH,
|
| 572 |
+
+ cpu_clamp_source,
|
| 573 |
+
+ parallelism_env,
|
| 574 |
+
+)
|
| 575 |
+
from pier.models.environment_type import EnvironmentType
|
| 576 |
+
from pier.models.task.config import EnvironmentConfig, TaskOS
|
| 577 |
+
from pier.models.trial.config import ResourceMode, ServiceVolumeConfig
|
| 578 |
+
@@ -102,6 +107,11 @@ class DockerEnvironment(BaseEnvironment):
|
| 579 |
+
|
| 580 |
+
_DOCKER_COMPOSE_WINDOWS_KEEPALIVE_PATH = COMPOSE_WINDOWS_KEEPALIVE_PATH
|
| 581 |
+
|
| 582 |
+
+ # Docker leaves IPv6 off in the container netns, so loopback has no ::1 and
|
| 583 |
+
+ # suites that bind it skip where a real Linux host passes. These environments
|
| 584 |
+
+ # always get their own netns, which is what net.* sysctls require.
|
| 585 |
+
+ _LINUX_SYSCTLS = {"net.ipv6.conf.all.disable_ipv6": "0"}
|
| 586 |
+
+
|
| 587 |
+
# Class-level lock per image name to prevent parallel builds of the same image.
|
| 588 |
+
_image_build_locks: dict[str, asyncio.Lock] = {}
|
| 589 |
+
|
| 590 |
+
@@ -160,6 +170,7 @@ class DockerEnvironment(BaseEnvironment):
|
| 591 |
+
task_env_config: EnvironmentConfig,
|
| 592 |
+
keep_containers: bool = False,
|
| 593 |
+
mounts_json: list[ServiceVolumeConfig] | None = None,
|
| 594 |
+
+ mounts_override: list[ServiceVolumeConfig] | None = None,
|
| 595 |
+
*args,
|
| 596 |
+
**kwargs,
|
| 597 |
+
):
|
| 598 |
+
@@ -193,8 +204,13 @@ class DockerEnvironment(BaseEnvironment):
|
| 599 |
+
self._windows_container_name: str | None = None
|
| 600 |
+
self._platform = UnixOps(self)
|
| 601 |
+
|
| 602 |
+
+ # Configured mounts are additive so the default /logs binds survive and
|
| 603 |
+
+ # agent logs and artifacts stay host-visible. A separate verifier
|
| 604 |
+
+ # environment must not share those directories, so it overrides the set.
|
| 605 |
+
self._mounts_json = (
|
| 606 |
+
- mounts_json if mounts_json is not None else self._default_log_mounts()
|
| 607 |
+
+ [*self._default_log_mounts(), *(mounts_json or [])]
|
| 608 |
+
+ if mounts_override is None
|
| 609 |
+
+ else mounts_override
|
| 610 |
+
)
|
| 611 |
+
self._mounts_compose_path: Path | None = None
|
| 612 |
+
self._resources_compose_temp_dir: tempfile.TemporaryDirectory | None = None
|
| 613 |
+
@@ -427,12 +443,16 @@ class DockerEnvironment(BaseEnvironment):
|
| 614 |
+
Path(self._resources_compose_temp_dir.name)
|
| 615 |
+
/ f"{self.session_id}-{RESOURCES_COMPOSE_NAME}"
|
| 616 |
+
)
|
| 617 |
+
+ cpu_limit = self._resource_limit_value("cpu", auto_mode=ResourceMode.LIMIT)
|
| 618 |
+
return write_resources_compose_file(
|
| 619 |
+
path,
|
| 620 |
+
+ environment=parallelism_env(cpu_limit),
|
| 621 |
+
+ volumes=self._write_cpu_clamp(path.parent, cpu_limit),
|
| 622 |
+
+ sysctls=None if self._is_windows_container else self._LINUX_SYSCTLS,
|
| 623 |
+
cpu_request=self._resource_request_value(
|
| 624 |
+
"cpu", auto_mode=ResourceMode.LIMIT
|
| 625 |
+
),
|
| 626 |
+
- cpu_limit=self._resource_limit_value("cpu", auto_mode=ResourceMode.LIMIT),
|
| 627 |
+
+ cpu_limit=cpu_limit,
|
| 628 |
+
memory_request_mb=self._resource_request_value(
|
| 629 |
+
"memory", auto_mode=ResourceMode.LIMIT
|
| 630 |
+
),
|
| 631 |
+
@@ -441,6 +461,15 @@ class DockerEnvironment(BaseEnvironment):
|
| 632 |
+
),
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
+ def _write_cpu_clamp(self, directory: Path, cpu_limit: int | None) -> list[str]:
|
| 636 |
+
+ """Mount the preload that `parallelism_env` points NODE_OPTIONS at."""
|
| 637 |
+
+ if not cpu_limit or self._is_windows_container:
|
| 638 |
+
+ return []
|
| 639 |
+
+ source = directory / "pier-node-cpu-clamp.js"
|
| 640 |
+
+ source.write_text(cpu_clamp_source(cpu_limit))
|
| 641 |
+
+ source.chmod(0o644)
|
| 642 |
+
+ return [f"{source}:{CPU_CLAMP_PATH}:ro"]
|
| 643 |
+
+
|
| 644 |
+
def _cleanup_resources_compose_file(self) -> None:
|
| 645 |
+
if self._resources_compose_temp_dir is None:
|
| 646 |
+
return
|
| 647 |
+
diff --git a/src/pier/environments/docker/parallelism.py b/src/pier/environments/docker/parallelism.py
|
| 648 |
+
new file mode 100644
|
| 649 |
+
index 0000000..b21ae7c
|
| 650 |
+
--- /dev/null
|
| 651 |
+
+++ b/src/pier/environments/docker/parallelism.py
|
| 652 |
+
@@ -0,0 +1,34 @@
|
| 653 |
+
+"""Tell test runners how many CPUs the container actually gets.
|
| 654 |
+
+
|
| 655 |
+
+Docker's ``--cpus`` is a bandwidth quota, so ``nproc`` still reports every host
|
| 656 |
+
+core and runner worker pools oversubscribe the container.
|
| 657 |
+
+"""
|
| 658 |
+
+
|
| 659 |
+
+from __future__ import annotations
|
| 660 |
+
+
|
| 661 |
+
+CPU_CLAMP_PATH = "/opt/pier-node-cpu-clamp.js"
|
| 662 |
+
+
|
| 663 |
+
+
|
| 664 |
+
+def cpu_clamp_source(cpus: int) -> str:
|
| 665 |
+
+ """A ``node --require`` preload; the Node runners take no cap from the environment."""
|
| 666 |
+
+ return (
|
| 667 |
+
+ "const os = require('node:os')\n"
|
| 668 |
+
+ f"const limit = {cpus}\n"
|
| 669 |
+
+ "const cpus = os.cpus\n"
|
| 670 |
+
+ "os.cpus = () => cpus.call(os).slice(0, limit)\n"
|
| 671 |
+
+ "os.availableParallelism = () => limit\n"
|
| 672 |
+
+ )
|
| 673 |
+
+
|
| 674 |
+
+
|
| 675 |
+
+def parallelism_env(cpus: int | None) -> dict[str, str]:
|
| 676 |
+
+ """Worker-count caps for the test runners, derived from the CPU limit."""
|
| 677 |
+
+ if not cpus or cpus < 1:
|
| 678 |
+
+ return {}
|
| 679 |
+
+ n = str(cpus)
|
| 680 |
+
+ return {
|
| 681 |
+
+ "GOMAXPROCS": n,
|
| 682 |
+
+ "CARGO_BUILD_JOBS": n,
|
| 683 |
+
+ "NEXTEST_TEST_THREADS": n,
|
| 684 |
+
+ "PYTEST_XDIST_AUTO_NUM_WORKERS": n,
|
| 685 |
+
+ "NODE_OPTIONS": f"--require {CPU_CLAMP_PATH}",
|
| 686 |
+
+ }
|
| 687 |
+
diff --git a/src/pier/models/agent/name.py b/src/pier/models/agent/name.py
|
| 688 |
+
index d84516a..41ac147 100644
|
| 689 |
+
--- a/src/pier/models/agent/name.py
|
| 690 |
+
+++ b/src/pier/models/agent/name.py
|
| 691 |
+
@@ -8,6 +8,7 @@ class AgentName(str, Enum):
|
| 692 |
+
ANTIGRAVITY_SDK = "antigravity-sdk"
|
| 693 |
+
CODEX = "codex"
|
| 694 |
+
CURSOR_CLI = "cursor-cli"
|
| 695 |
+
+ DSH_MINIMAL = "dsh-minimal"
|
| 696 |
+
GEMINI_CLI = "gemini-cli"
|
| 697 |
+
MINI_SWE_AGENT = "mini-swe-agent"
|
| 698 |
+
SWE_AGENT = "swe-agent"
|
| 699 |
+
diff --git a/src/pier/trial/trial.py b/src/pier/trial/trial.py
|
| 700 |
+
index fd5d4a2..126f0fa 100644
|
| 701 |
+
--- a/src/pier/trial/trial.py
|
| 702 |
+
+++ b/src/pier/trial/trial.py
|
| 703 |
+
@@ -402,7 +402,7 @@ class Trial:
|
| 704 |
+
trial_paths=self._trial_paths,
|
| 705 |
+
task_env_config=env_config,
|
| 706 |
+
logger=self._logger,
|
| 707 |
+
- mounts_json=self._verifier_env_mounts(env_config),
|
| 708 |
+
+ mounts_override=self._verifier_env_mounts(env_config),
|
| 709 |
+
agent_install_spec=None,
|
| 710 |
+
network_allowlist=None,
|
| 711 |
+
default_user=(
|
inference/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal inference
|
| 2 |
+
|
| 3 |
+
A readable reference implementation rather than a production serving engine. The
|
| 4 |
+
model code covers the vision encoder and aligner, sliding-window plus compressed
|
| 5 |
+
sparse attention with its two-level indexer, engram n-gram lookups, MoE,
|
| 6 |
+
Hyper-Connections, and the DSpark forward path. Generation itself is plain
|
| 7 |
+
autoregressive sampling.
|
| 8 |
+
|
| 9 |
+
## Install
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
python -m pip install -r requirements.txt
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
## Convert Hugging Face weights
|
| 16 |
+
|
| 17 |
+
The runtime uses one converted checkpoint file per tensor-parallel rank. From
|
| 18 |
+
this directory:
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
export HF_CKPT_PATH=/path/to/DeepSeek-V4.1-Flash-HF
|
| 22 |
+
export SAVE_PATH=/path/to/DeepSeek-V4.1-Flash-TP8
|
| 23 |
+
export MP=8
|
| 24 |
+
|
| 25 |
+
python convert.py \
|
| 26 |
+
--hf-ckpt-path "${HF_CKPT_PATH}" \
|
| 27 |
+
--save-path "${SAVE_PATH}" \
|
| 28 |
+
--model-parallel "${MP}" \
|
| 29 |
+
--expert-dtype fp4 \
|
| 30 |
+
--tokenizer-path "${HF_CKPT_PATH}"
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Expert counts are inferred from the weight names, so they do not need to be
|
| 34 |
+
passed. `--tokenizer-path` points at whichever directory holds `tokenizer.json`
|
| 35 |
+
and `tokenizer_config.json`; they are copied into the converted checkpoint.
|
| 36 |
+
|
| 37 |
+
## Run the equivalent TXT and JSON examples
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
export CKPT_PATH=/path/to/DeepSeek-V4.1-Flash-TP8
|
| 41 |
+
export MP=8
|
| 42 |
+
|
| 43 |
+
INPUT_FILE=examples/example.txt ./run.sh
|
| 44 |
+
INPUT_FILE=examples/example_harmony.json ./run.sh
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
The two files express the same interleaved two-image prompt, so they produce
|
| 48 |
+
identical encoded prompts and input token IDs.
|
| 49 |
+
|
| 50 |
+
For interactive chat:
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
torchrun --nproc-per-node "${MP}" generate.py \
|
| 54 |
+
--ckpt-path "${CKPT_PATH}" \
|
| 55 |
+
--config config.json \
|
| 56 |
+
--interactive \
|
| 57 |
+
--temperature 0.6
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
For multi-node execution, pass the usual `torchrun --nnodes`, `--node-rank`,
|
| 61 |
+
`--master-addr`, and `--master-port` arguments before `generate.py`.
|
| 62 |
+
|
| 63 |
+
## Self-test
|
| 64 |
+
|
| 65 |
+
`model.py` builds a small model from the `ModelArgs` defaults and runs a prefill
|
| 66 |
+
plus 22 decode steps, exercising the real dense-fp8 / MoE-fp4 kernels. Weights
|
| 67 |
+
are uninitialized, so it checks shapes and kernel plumbing, not numerics:
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
python model.py
|
| 71 |
+
```
|
inference/convert.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import shutil
|
| 5 |
+
from argparse import ArgumentParser
|
| 6 |
+
from glob import glob
|
| 7 |
+
from tqdm import tqdm, trange
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from safetensors.torch import safe_open, save_file
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
FP4_TABLE = torch.tensor(
|
| 14 |
+
[0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 0.0, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0, -6.0], dtype=torch.float32
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def cast_e2m1fn_to_e4m3fn(x: torch.Tensor, scale: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 19 |
+
"""
|
| 20 |
+
Casts a tensor from e2m1fn to e4m3fn losslessly.
|
| 21 |
+
"""
|
| 22 |
+
assert x.dtype == torch.int8
|
| 23 |
+
assert x.ndim == 2
|
| 24 |
+
out_dim, in_dim = x.size()
|
| 25 |
+
in_dim *= 2
|
| 26 |
+
fp8_block_size = 32
|
| 27 |
+
fp4_block_size = 32
|
| 28 |
+
assert in_dim % fp8_block_size == 0 and out_dim % fp8_block_size == 0
|
| 29 |
+
assert scale.size(0) == out_dim and scale.size(1) == in_dim // fp4_block_size
|
| 30 |
+
|
| 31 |
+
x = x.view(torch.uint8)
|
| 32 |
+
low = x & 0x0F
|
| 33 |
+
high = (x >> 4) & 0x0F
|
| 34 |
+
x = torch.stack([FP4_TABLE[low.long()], FP4_TABLE[high.long()]], dim=-1).flatten(2)
|
| 35 |
+
|
| 36 |
+
# max_fp4 (6.0) * MAX_OFFSET must fit in e4m3fn (max 448)
|
| 37 |
+
# 6.0 * 2^6 = 384 < 448; 6.0 * 2^7 = 768 > 448; so MAX_OFFSET_BITS = 6
|
| 38 |
+
MAX_OFFSET_BITS = 6
|
| 39 |
+
|
| 40 |
+
bOut = out_dim // fp8_block_size
|
| 41 |
+
bIn = in_dim // fp8_block_size
|
| 42 |
+
# bOut, bIn, fp8_block_size, fp8_block_size
|
| 43 |
+
x = x.view(bOut, fp8_block_size, bIn, fp8_block_size).transpose(1, 2)
|
| 44 |
+
# bOut, bIn, fp8_block_size * (fp8_block_size // fp4_block_size)
|
| 45 |
+
scale = scale.float().view(bOut, fp8_block_size, bIn, -1).transpose(1, 2).flatten(2)
|
| 46 |
+
## bOut, bIn, 1
|
| 47 |
+
scale_max_offset_bits = scale.amax(dim=-1, keepdim=True) / (2**MAX_OFFSET_BITS)
|
| 48 |
+
# bOut, bIn, fp8_block_size * (fp8_block_size // fp4_block_size)
|
| 49 |
+
offset = scale / scale_max_offset_bits
|
| 50 |
+
# bOut, bIn, fp8_block_size, fp8_block_size
|
| 51 |
+
offset = offset.unflatten(-1, (fp8_block_size, -1)).repeat_interleave(fp4_block_size, dim=-1)
|
| 52 |
+
x = (x * offset).transpose(1, 2).reshape(out_dim, in_dim)
|
| 53 |
+
return x.to(torch.float8_e4m3fn), scale_max_offset_bits.squeeze(-1).to(torch.float8_e8m0fnu)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
mapping = {
|
| 57 |
+
"embed": ("embed", 0),
|
| 58 |
+
"wq_b": ("wq_b", 0),
|
| 59 |
+
"wo_a": ("wo_a", 0),
|
| 60 |
+
"wo_b": ("wo_b", 1),
|
| 61 |
+
"head": ("head", 0),
|
| 62 |
+
"attn_sink": ("attn_sink", 0),
|
| 63 |
+
"weights_proj": ("weights_proj", 0),
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def infer_num_experts(names) -> tuple[int, int]:
|
| 68 |
+
"""Number of routed experts in the backbone and in the MTP layers, from the weight names."""
|
| 69 |
+
counts = [0, 0]
|
| 70 |
+
for name in names:
|
| 71 |
+
name = name.removeprefix("model.")
|
| 72 |
+
match = re.search(r"(?:mlp|ffn)\.experts\.(\d+)\.", name)
|
| 73 |
+
if match:
|
| 74 |
+
is_mtp = name.startswith("mtp.")
|
| 75 |
+
counts[is_mtp] = max(counts[is_mtp], int(match.group(1)) + 1)
|
| 76 |
+
assert counts[0], "no routed experts found in the checkpoint"
|
| 77 |
+
return counts[0], counts[1] or counts[0]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def main(hf_ckpt_path, save_path, mp, expert_dtype, tokenizer_path=None):
|
| 81 |
+
"""Shard an exported HuggingFace checkpoint into `mp` files for this inference stack."""
|
| 82 |
+
torch.set_num_threads(8)
|
| 83 |
+
state_dicts = [{} for _ in range(mp)]
|
| 84 |
+
os.makedirs(save_path, exist_ok=True)
|
| 85 |
+
|
| 86 |
+
index_path = os.path.join(hf_ckpt_path, "model.safetensors.index.json")
|
| 87 |
+
expected_names = set(json.load(open(index_path))["weight_map"]) if os.path.exists(index_path) else None
|
| 88 |
+
seen_names = set()
|
| 89 |
+
|
| 90 |
+
all_names = expected_names
|
| 91 |
+
if all_names is None:
|
| 92 |
+
all_names = set()
|
| 93 |
+
for file_path in glob(os.path.join(hf_ckpt_path, "*.safetensors")):
|
| 94 |
+
with safe_open(file_path, framework="pt", device="cpu") as f:
|
| 95 |
+
all_names.update(f.keys())
|
| 96 |
+
n_experts, mtp_n_experts = infer_num_experts(all_names)
|
| 97 |
+
assert n_experts % mp == 0 and mtp_n_experts % mp == 0, (n_experts, mtp_n_experts, mp)
|
| 98 |
+
print(f"{n_experts=} {mtp_n_experts=}")
|
| 99 |
+
|
| 100 |
+
for file_path in tqdm(glob(os.path.join(hf_ckpt_path, "*.safetensors"))):
|
| 101 |
+
with safe_open(file_path, framework="pt", device="cpu") as f:
|
| 102 |
+
for source_name in f.keys():
|
| 103 |
+
seen_names.add(source_name)
|
| 104 |
+
name = source_name
|
| 105 |
+
if name.startswith("model."):
|
| 106 |
+
name = name[len("model.") :]
|
| 107 |
+
param: torch.Tensor = f.get_tensor(source_name)
|
| 108 |
+
# an MTP layer ties its token embedding and output head to the backbone's
|
| 109 |
+
if name.startswith("mtp.") and name.split(".", 2)[-1] in ("embed.weight", "head.weight"):
|
| 110 |
+
continue
|
| 111 |
+
name = name.replace("self_attn", "attn")
|
| 112 |
+
if not name.startswith("vision."):
|
| 113 |
+
name = name.replace("mlp", "ffn")
|
| 114 |
+
name = name.replace("weight_scale_inv", "scale")
|
| 115 |
+
name = name.replace("e_score_correction_bias", "bias")
|
| 116 |
+
if any(
|
| 117 |
+
x in name for x in ["hc", "attn_sink", "tie2eid", "tid2eid", "ape", "image_"]
|
| 118 |
+
): # without .weight
|
| 119 |
+
key = name.split(".")[-1]
|
| 120 |
+
else:
|
| 121 |
+
key = name.split(".")[-2]
|
| 122 |
+
if key in mapping:
|
| 123 |
+
new_key, dim = mapping[key]
|
| 124 |
+
else:
|
| 125 |
+
new_key, dim = key, None
|
| 126 |
+
name = name.replace(key, new_key)
|
| 127 |
+
for i in range(mp):
|
| 128 |
+
new_param = param
|
| 129 |
+
if "experts" in name and "shared_experts" not in name:
|
| 130 |
+
current_n_experts = mtp_n_experts if name.startswith("mtp.") else n_experts
|
| 131 |
+
n_local_experts = current_n_experts // mp
|
| 132 |
+
idx = int(name.split(".")[-3])
|
| 133 |
+
if idx < i * n_local_experts or idx >= (i + 1) * n_local_experts:
|
| 134 |
+
continue
|
| 135 |
+
elif ".engram.embed." in name:
|
| 136 |
+
shard_size = (param.size(0) + mp - 1) // mp
|
| 137 |
+
new_param = param[i * shard_size : (i + 1) * shard_size].contiguous()
|
| 138 |
+
if new_param.size(0) < shard_size:
|
| 139 |
+
pad_value = 1 if name.endswith(".scale") else 0
|
| 140 |
+
padding = param.new_full((shard_size - new_param.size(0), param.size(1)), pad_value)
|
| 141 |
+
new_param = torch.cat([new_param, padding])
|
| 142 |
+
elif dim is not None:
|
| 143 |
+
assert param.size(dim) % mp == 0, f"Dimension {dim} must be divisible by {mp}"
|
| 144 |
+
shard_size = param.size(dim) // mp
|
| 145 |
+
new_param = param.narrow(dim, i * shard_size, shard_size).contiguous()
|
| 146 |
+
state_dicts[i][name] = new_param
|
| 147 |
+
|
| 148 |
+
if expected_names is not None:
|
| 149 |
+
assert seen_names == expected_names, (
|
| 150 |
+
f"checkpoint shards incomplete: {len(expected_names - seen_names)} tensors missing, "
|
| 151 |
+
f"{len(seen_names - expected_names)} unexpected (source may be mid-upload)"
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
for i in trange(mp):
|
| 155 |
+
names = list(state_dicts[i].keys())
|
| 156 |
+
for name in names:
|
| 157 |
+
if name.endswith("wo_a.weight"):
|
| 158 |
+
weight = state_dicts[i][name]
|
| 159 |
+
scale = state_dicts[i].pop(name.replace("weight", "scale"))
|
| 160 |
+
assert weight.size(0) % scale.size(0) == 0
|
| 161 |
+
assert weight.size(1) % scale.size(1) == 0
|
| 162 |
+
out_block_size = weight.size(0) // scale.size(0)
|
| 163 |
+
in_block_size = weight.size(1) // scale.size(1)
|
| 164 |
+
assert (out_block_size, in_block_size) in ((32, 32), (128, 128)), (
|
| 165 |
+
name,
|
| 166 |
+
weight.shape,
|
| 167 |
+
scale.shape,
|
| 168 |
+
)
|
| 169 |
+
weight = (
|
| 170 |
+
weight.unflatten(0, (-1, out_block_size)).unflatten(-1, (-1, in_block_size)).float()
|
| 171 |
+
* scale[:, None, :, None].float()
|
| 172 |
+
)
|
| 173 |
+
state_dicts[i][name] = weight.flatten(2, 3).flatten(0, 1).bfloat16()
|
| 174 |
+
elif "experts" in name and state_dicts[i][name].dtype == torch.int8:
|
| 175 |
+
if expert_dtype == "fp8":
|
| 176 |
+
scale_name = name.replace("weight", "scale")
|
| 177 |
+
weight = state_dicts[i].pop(name)
|
| 178 |
+
scale = state_dicts[i].pop(scale_name)
|
| 179 |
+
state_dicts[i][name], state_dicts[i][scale_name] = cast_e2m1fn_to_e4m3fn(weight, scale)
|
| 180 |
+
else:
|
| 181 |
+
state_dicts[i][name] = state_dicts[i][name].view(torch.float4_e2m1fn_x2)
|
| 182 |
+
save_file(state_dicts[i], os.path.join(save_path, f"model{i}-mp{mp}.safetensors"))
|
| 183 |
+
|
| 184 |
+
tokenizer_path = tokenizer_path or hf_ckpt_path
|
| 185 |
+
for file in ["tokenizer.json", "tokenizer_config.json"]:
|
| 186 |
+
old_file_path = os.path.join(tokenizer_path, file)
|
| 187 |
+
new_file_path = os.path.join(save_path, file)
|
| 188 |
+
if os.path.exists(old_file_path):
|
| 189 |
+
shutil.copyfile(old_file_path, new_file_path)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
parser = ArgumentParser()
|
| 194 |
+
parser.add_argument("--hf-ckpt-path", type=str, required=True)
|
| 195 |
+
parser.add_argument("--save-path", type=str, required=True)
|
| 196 |
+
parser.add_argument("--model-parallel", type=int, required=True)
|
| 197 |
+
parser.add_argument("--expert-dtype", type=str, choices=["fp8", "fp4"], default=None)
|
| 198 |
+
parser.add_argument(
|
| 199 |
+
"--tokenizer-path",
|
| 200 |
+
type=str,
|
| 201 |
+
default=None,
|
| 202 |
+
help="Optional tokenizer directory when the HF checkpoint does not contain tokenizer files",
|
| 203 |
+
)
|
| 204 |
+
args = parser.parse_args()
|
| 205 |
+
main(args.hf_ckpt_path, args.save_path, args.model_parallel, args.expert_dtype, args.tokenizer_path)
|
inference/engram.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from sympy import isprime
|
| 6 |
+
from torch import nn
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def find_next_prime(start: int, seen_primes: set[int]) -> int:
|
| 10 |
+
"""The smallest prime above `start` that has not been handed out yet."""
|
| 11 |
+
candidate = start + 1
|
| 12 |
+
while not isprime(candidate) or candidate in seen_primes:
|
| 13 |
+
candidate += 1
|
| 14 |
+
return candidate
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def build_compressed_token_map(tokenizer) -> tuple[list[int], int]:
|
| 18 |
+
"""Map every token id onto a smaller id space where tokens that normalize alike collapse together.
|
| 19 |
+
|
| 20 |
+
N-grams are hashed over these compressed ids, so " The", "the" and "THE" all hash the same way.
|
| 21 |
+
Returns the lookup plus the size of the compressed vocab -- and that size matters beyond bounds
|
| 22 |
+
checking, because every hash multiplier is derived from it.
|
| 23 |
+
"""
|
| 24 |
+
from tokenizers import Regex, normalizers
|
| 25 |
+
|
| 26 |
+
# a private-use char, so a token that is exactly one space survives Strip() instead of
|
| 27 |
+
# collapsing to the empty string and merging with unrelated tokens
|
| 28 |
+
sentinel = "\ue000"
|
| 29 |
+
normalizer = normalizers.Sequence(
|
| 30 |
+
[
|
| 31 |
+
normalizers.NFKC(),
|
| 32 |
+
normalizers.NFD(),
|
| 33 |
+
normalizers.StripAccents(),
|
| 34 |
+
normalizers.Lowercase(),
|
| 35 |
+
normalizers.Replace(Regex(r"[ \t\r\n]+"), " "),
|
| 36 |
+
normalizers.Replace(Regex(r"^ $"), sentinel),
|
| 37 |
+
normalizers.Strip(),
|
| 38 |
+
normalizers.Replace(sentinel, " "),
|
| 39 |
+
]
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# the raw Rust tokenizer, matching what training decodes with (no clean_up_tokenization_spaces)
|
| 43 |
+
backend = tokenizer.backend_tokenizer
|
| 44 |
+
key_to_new: dict[str, int] = {}
|
| 45 |
+
lookup = [0] * len(tokenizer)
|
| 46 |
+
for token_id in range(len(tokenizer)):
|
| 47 |
+
text = backend.decode([token_id], skip_special_tokens=False)
|
| 48 |
+
if "\ufffd" in text:
|
| 49 |
+
# a partial UTF-8 byte token: nothing to normalize, so key it by its raw form
|
| 50 |
+
key = backend.id_to_token(token_id)
|
| 51 |
+
else:
|
| 52 |
+
normalized = normalizer.normalize_str(text)
|
| 53 |
+
key = normalized if normalized else text
|
| 54 |
+
|
| 55 |
+
new_id = key_to_new.get(key)
|
| 56 |
+
if new_id is None:
|
| 57 |
+
new_id = len(key_to_new)
|
| 58 |
+
key_to_new[key] = new_id
|
| 59 |
+
lookup[token_id] = new_id
|
| 60 |
+
|
| 61 |
+
return lookup, len(key_to_new)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def compute_hash_multipliers(
|
| 65 |
+
layer_ids: tuple[int, ...], max_ngram_size: int, tokenizer_vocab_size: int
|
| 66 |
+
) -> torch.Tensor:
|
| 67 |
+
"""One multiplier per (layer, lookback), from a per-layer RNG so layers hash differently.
|
| 68 |
+
|
| 69 |
+
Kept odd, and bounded so that `token_id * multiplier` cannot overflow int64.
|
| 70 |
+
"""
|
| 71 |
+
max_long = np.iinfo(np.int64).max
|
| 72 |
+
multiplier_bound = max(1, (max_long // tokenizer_vocab_size) // 2)
|
| 73 |
+
rows = []
|
| 74 |
+
for layer_id in layer_ids:
|
| 75 |
+
generator = np.random.default_rng(10007 * layer_id)
|
| 76 |
+
values = generator.integers(
|
| 77 |
+
low=0,
|
| 78 |
+
high=multiplier_bound,
|
| 79 |
+
size=(max_ngram_size,),
|
| 80 |
+
dtype=np.int64,
|
| 81 |
+
)
|
| 82 |
+
rows.append(torch.tensor(values * 2 + 1))
|
| 83 |
+
return torch.stack(rows)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass(frozen=True)
|
| 87 |
+
class EngramLayout:
|
| 88 |
+
"""Bucket layout of the n-gram hash tables.
|
| 89 |
+
|
| 90 |
+
A position is hashed as `max_ngram_size - 1` n-grams (2-gram .. max_ngram_size-gram), each split
|
| 91 |
+
over `n_heads` heads. Every (n-gram size, head) pair owns its own prime-sized bucket range in the
|
| 92 |
+
layer's table; the primes are drawn in order and never reused, which keeps the ranges disjoint.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
max_ngram_size: int
|
| 96 |
+
layer_ids: tuple[int, ...]
|
| 97 |
+
num_embeddings: tuple[int, ...] # table rows, per engram layer
|
| 98 |
+
primes: tuple[tuple[tuple[int, ...], ...], ...] # [layer][n-gram size][head] bucket modulus
|
| 99 |
+
n_heads: int
|
| 100 |
+
head_dim: int
|
| 101 |
+
|
| 102 |
+
@classmethod
|
| 103 |
+
def from_args(cls, args) -> "EngramLayout | None":
|
| 104 |
+
layer_ids = tuple(args.engram_layer_ids)
|
| 105 |
+
if not layer_ids:
|
| 106 |
+
return None
|
| 107 |
+
max_ngram_size, n_heads = args.engram_max_ngram_size, args.engram_n_heads
|
| 108 |
+
primes, seen = [], set()
|
| 109 |
+
for _ in layer_ids:
|
| 110 |
+
per_ngram = []
|
| 111 |
+
for _ in range(max_ngram_size - 1):
|
| 112 |
+
sizes, current = [], args.engram_vocab_size - 1
|
| 113 |
+
for _ in range(n_heads):
|
| 114 |
+
current = find_next_prime(current, seen)
|
| 115 |
+
seen.add(current)
|
| 116 |
+
sizes.append(current)
|
| 117 |
+
per_ngram.append(tuple(sizes))
|
| 118 |
+
primes.append(tuple(per_ngram))
|
| 119 |
+
return cls(
|
| 120 |
+
max_ngram_size=max_ngram_size,
|
| 121 |
+
layer_ids=layer_ids,
|
| 122 |
+
num_embeddings=tuple(args.engram_num_embeddings),
|
| 123 |
+
primes=tuple(primes),
|
| 124 |
+
n_heads=n_heads,
|
| 125 |
+
head_dim=args.engram_head_dim,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class NgramHashState(nn.Module):
|
| 130 |
+
"""Maps each position to the hash ids of the n-grams ending there.
|
| 131 |
+
|
| 132 |
+
Ids go through the compressed table, then each position is hashed with the `max_ngram_size - 1`
|
| 133 |
+
tokens before it. Look-back stops at the start of the sequence and at any dead token (an image
|
| 134 |
+
span, cached as DEAD), so an n-gram never spans one. The cache carries all of this across the
|
| 135 |
+
prefill/decode split.
|
| 136 |
+
"""
|
| 137 |
+
|
| 138 |
+
DEAD = -1
|
| 139 |
+
|
| 140 |
+
def __init__(self, args, layout: EngramLayout, tokenizer):
|
| 141 |
+
super().__init__()
|
| 142 |
+
self.layout = layout
|
| 143 |
+
# every hash multiplier derives from the compressed vocab size, so a mismatch there would
|
| 144 |
+
# silently rehash the whole table
|
| 145 |
+
token_map, vocab_size = build_compressed_token_map(tokenizer)
|
| 146 |
+
assert vocab_size == args.engram_compressed_vocab_size, (vocab_size, args.engram_compressed_vocab_size)
|
| 147 |
+
self.pad_id = token_map[args.engram_pad_id]
|
| 148 |
+
flat = [[p for per_ngram in layer for p in per_ngram] for layer in layout.primes]
|
| 149 |
+
offsets = [np.cumsum([0, *sizes[:-1]]) for sizes in flat]
|
| 150 |
+
multipliers = compute_hash_multipliers(layout.layer_ids, layout.max_ngram_size, vocab_size)
|
| 151 |
+
self.register_buffer("primes", torch.tensor(layout.primes), persistent=False)
|
| 152 |
+
self.register_buffer("offsets", torch.tensor(np.array(offsets)), persistent=False)
|
| 153 |
+
self.register_buffer("multipliers", multipliers, persistent=False)
|
| 154 |
+
self.register_buffer("token_map", torch.tensor(token_map), persistent=False)
|
| 155 |
+
self.register_buffer(
|
| 156 |
+
"cache", torch.empty(args.max_batch_size, args.max_seq_len, dtype=torch.int64), persistent=False
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
@torch.inference_mode()
|
| 160 |
+
def forward(self, input_ids: torch.Tensor, start_pos: int, token_mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 161 |
+
"""token_mask: [B, L], False for tokens that take no part in an n-gram (image spans).
|
| 162 |
+
Returns the hash ids, shaped [B, L, n_engram_layers, n_hash_cols]."""
|
| 163 |
+
batch, seqlen = input_ids.shape
|
| 164 |
+
compressed = self.token_map[input_ids]
|
| 165 |
+
if token_mask is not None:
|
| 166 |
+
compressed = torch.where(token_mask, compressed, self.DEAD)
|
| 167 |
+
self.cache[:batch, start_pos : start_pos + seqlen] = compressed
|
| 168 |
+
|
| 169 |
+
positions = torch.arange(start_pos, start_pos + seqlen, device=input_ids.device).expand(batch, seqlen)
|
| 170 |
+
tokens, blocked = [], torch.zeros_like(positions, dtype=torch.bool)
|
| 171 |
+
for shift in range(self.layout.max_ngram_size):
|
| 172 |
+
source = self.cache[:batch].gather(1, (positions - shift).clamp_min(0))
|
| 173 |
+
blocked = blocked | (positions < shift) | (source == self.DEAD)
|
| 174 |
+
tokens.append(torch.where(blocked, self.pad_id, source))
|
| 175 |
+
tokens = torch.stack(tokens, dim=-1) # [B, L, max_ngram_size]
|
| 176 |
+
|
| 177 |
+
# XOR the multiplied ids together one lookback at a time, so the running value after step i
|
| 178 |
+
# is the hash of the (i+1)-gram; each lands in its own prime-sized bucket range
|
| 179 |
+
products = tokens.unsqueeze(2) * self.multipliers # [B, L, n_engram_layers, max_ngram_size]
|
| 180 |
+
rolling, hashes = products[..., 0], []
|
| 181 |
+
for i in range(1, self.layout.max_ngram_size):
|
| 182 |
+
rolling = torch.bitwise_xor(rolling, products[..., i])
|
| 183 |
+
hashes.append(rolling.unsqueeze(-1) % self.primes[:, i - 1])
|
| 184 |
+
return torch.cat(hashes, dim=-1) + self.offsets
|
inference/examples/example.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
中国的首都是哪里?
|
| 2 |
+
|
| 3 |
+
列出100以内的所有素数。
|
| 4 |
+
|
| 5 |
+
DeepSeek是做什么的公司?
|
| 6 |
+
|
| 7 |
+
请按“第一张、第二张”的顺序回答:第一张图<image>examples/images/carrots.jpeg</image>和第二张图<image>examples/images/corn.jpeg</image>中分别是什么食材?它们通常食用的部位分别是什么?
|
inference/examples/example_harmony.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"messages": [
|
| 4 |
+
{
|
| 5 |
+
"role": "user",
|
| 6 |
+
"content": [
|
| 7 |
+
{
|
| 8 |
+
"type": "text",
|
| 9 |
+
"text": "请按“第一张、第二张”的顺序回答:第一张图"
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"type": "image_url",
|
| 13 |
+
"image_url": {
|
| 14 |
+
"url": "examples/images/carrots.jpeg"
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"type": "text",
|
| 19 |
+
"text": "和第二张图"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"type": "image_url",
|
| 23 |
+
"image_url": {
|
| 24 |
+
"url": "examples/images/corn.jpeg"
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"type": "text",
|
| 29 |
+
"text": "中分别是什么食材?它们通常食用的部位分别是什么?"
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
| 33 |
+
]
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"messages": [
|
| 37 |
+
{
|
| 38 |
+
"role": "system",
|
| 39 |
+
"content": "You are a helpful assistant."
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"role": "user",
|
| 43 |
+
"content": "中国的首都是哪里?"
|
| 44 |
+
}
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"tools": [
|
| 49 |
+
{
|
| 50 |
+
"type": "function",
|
| 51 |
+
"function": {
|
| 52 |
+
"name": "get_weather",
|
| 53 |
+
"description": "Get the weather for a specific location",
|
| 54 |
+
"parameters": {
|
| 55 |
+
"type": "object",
|
| 56 |
+
"properties": {
|
| 57 |
+
"location": {"type": "string", "description": "The city name"},
|
| 58 |
+
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
|
| 59 |
+
},
|
| 60 |
+
"required": ["location"]
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
],
|
| 65 |
+
"messages": [
|
| 66 |
+
{
|
| 67 |
+
"role": "system",
|
| 68 |
+
"content": "You are a helpful assistant."
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"role": "user",
|
| 72 |
+
"content": "What's the weather like in Beijing?"
|
| 73 |
+
}
|
| 74 |
+
]
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"messages": [
|
| 78 |
+
{
|
| 79 |
+
"role": "system",
|
| 80 |
+
"content": "You are a helpful assistant."
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"role": "user",
|
| 84 |
+
"content": "Hello"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"role": "assistant",
|
| 88 |
+
"content": "Hi there! How can I help you?"
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"role": "system",
|
| 92 |
+
"content": "Mid-conversation instruction update: reply in Chinese only. (deepseek_v41 only)"
|
| 93 |
+
}
|
| 94 |
+
]
|
| 95 |
+
}
|
| 96 |
+
]
|
inference/examples/images/carrots.jpeg
ADDED
|
Git LFS Details
|
inference/examples/images/corn.jpeg
ADDED
|
inference/generate.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
from argparse import ArgumentParser
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import torch.distributed as dist
|
| 9 |
+
from transformers import AutoTokenizer
|
| 10 |
+
from safetensors.torch import load_model
|
| 11 |
+
|
| 12 |
+
from model import Transformer, ModelArgs
|
| 13 |
+
|
| 14 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 15 |
+
sys.path.insert(0, os.path.abspath(os.path.join(current_dir, "../encoding")))
|
| 16 |
+
|
| 17 |
+
from encoding import (
|
| 18 |
+
encode_case,
|
| 19 |
+
encode_messages,
|
| 20 |
+
load_cases,
|
| 21 |
+
parse_message_from_completion_text,
|
| 22 |
+
parse_tagged_text,
|
| 23 |
+
to_json,
|
| 24 |
+
)
|
| 25 |
+
from image_processor import TEXT, prepare_vl_inputs
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@torch.inference_mode()
|
| 29 |
+
def generate(
|
| 30 |
+
model: Transformer,
|
| 31 |
+
prompt_tokens: List[List[int]],
|
| 32 |
+
max_new_tokens: int,
|
| 33 |
+
eos_id: int,
|
| 34 |
+
prompt_token_types: List[List[int]] | None = None,
|
| 35 |
+
images=None,
|
| 36 |
+
) -> List[List[int]]:
|
| 37 |
+
"""Batch generation with right-padded prompts.
|
| 38 |
+
|
| 39 |
+
The first forward pass processes [:min_prompt_len] tokens (prefill phase).
|
| 40 |
+
Subsequent passes generate one token at a time (decode phase). For positions
|
| 41 |
+
still within a prompt, the ground-truth token overrides the model's prediction.
|
| 42 |
+
|
| 43 |
+
`prompt_token_types` and `images` come from image_processor.prepare_vl_inputs. Image spans are
|
| 44 |
+
only visible to the prefill pass, so they must end before the shortest prompt does.
|
| 45 |
+
"""
|
| 46 |
+
prompt_lens = [len(t) for t in prompt_tokens]
|
| 47 |
+
assert max(prompt_lens) <= model.max_seq_len, (
|
| 48 |
+
f"Prompt length exceeds model maximum sequence length (max_seq_len={model.max_seq_len})"
|
| 49 |
+
)
|
| 50 |
+
total_len = min(model.max_seq_len, max_new_tokens + max(prompt_lens))
|
| 51 |
+
tokens = torch.full((len(prompt_tokens), total_len), -1, dtype=torch.long)
|
| 52 |
+
for i, t in enumerate(prompt_tokens):
|
| 53 |
+
tokens[i, : len(t)] = torch.tensor(t, dtype=torch.long)
|
| 54 |
+
|
| 55 |
+
token_types = None
|
| 56 |
+
if images is not None:
|
| 57 |
+
token_types = torch.full((len(prompt_tokens), total_len), TEXT, dtype=torch.long)
|
| 58 |
+
for i, types in enumerate(prompt_token_types):
|
| 59 |
+
token_types[i, : len(types)] = torch.tensor(types, dtype=torch.long)
|
| 60 |
+
for sample in images:
|
| 61 |
+
for img in sample or ():
|
| 62 |
+
assert img.start + img.types.numel() <= min(prompt_lens), "image spans must fit in the prefill chunk"
|
| 63 |
+
|
| 64 |
+
prev_pos = 0
|
| 65 |
+
finished = torch.tensor([False] * len(prompt_tokens))
|
| 66 |
+
prompt_mask = tokens != -1
|
| 67 |
+
for cur_pos in range(min(prompt_lens), total_len):
|
| 68 |
+
with_images = images is not None and prev_pos == 0
|
| 69 |
+
next_token = model.forward(
|
| 70 |
+
tokens[:, prev_pos:cur_pos],
|
| 71 |
+
prev_pos,
|
| 72 |
+
images=images if with_images else None,
|
| 73 |
+
token_types=token_types[:, prev_pos:cur_pos] if with_images else None,
|
| 74 |
+
)[0]
|
| 75 |
+
next_token = torch.where(prompt_mask[:, cur_pos], tokens[:, cur_pos], next_token)
|
| 76 |
+
tokens[:, cur_pos] = next_token
|
| 77 |
+
finished |= torch.logical_and(~prompt_mask[:, cur_pos], next_token == eos_id)
|
| 78 |
+
prev_pos = cur_pos
|
| 79 |
+
if finished.all():
|
| 80 |
+
break
|
| 81 |
+
completion_tokens = []
|
| 82 |
+
for i, toks in enumerate(tokens.tolist()):
|
| 83 |
+
toks = toks[prompt_lens[i] : prompt_lens[i] + max_new_tokens]
|
| 84 |
+
if eos_id in toks:
|
| 85 |
+
toks = toks[: toks.index(eos_id)]
|
| 86 |
+
completion_tokens.append(toks)
|
| 87 |
+
return completion_tokens
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def prepare_case(case, thinking_mode, tokenizer, args):
|
| 91 |
+
"""Encode one message case and expand any image placeholders."""
|
| 92 |
+
if case.get("context"):
|
| 93 |
+
raise ValueError("Standalone inference does not support context without a prefilled KV cache")
|
| 94 |
+
prompt, image_records = encode_case(case, thinking_mode)
|
| 95 |
+
tokens, token_types, images = prepare_vl_inputs(prompt, image_records, tokenizer, args)
|
| 96 |
+
return prompt, tokens, token_types, images
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def main(
|
| 100 |
+
ckpt_path: str,
|
| 101 |
+
config: str,
|
| 102 |
+
input_file: str = "",
|
| 103 |
+
interactive: bool = True,
|
| 104 |
+
max_new_tokens: int = 100,
|
| 105 |
+
temperature: float = 1.0,
|
| 106 |
+
thinking_mode: str = "chat",
|
| 107 |
+
) -> None:
|
| 108 |
+
world_size = int(os.getenv("WORLD_SIZE", "1"))
|
| 109 |
+
rank = int(os.getenv("RANK", "0"))
|
| 110 |
+
local_rank = int(os.getenv("LOCAL_RANK", "0"))
|
| 111 |
+
if world_size > 1:
|
| 112 |
+
dist.init_process_group("nccl")
|
| 113 |
+
global print
|
| 114 |
+
if rank != 0:
|
| 115 |
+
print = lambda *_, **__: None
|
| 116 |
+
torch.cuda.set_device(local_rank)
|
| 117 |
+
torch.cuda.memory._set_allocator_settings("expandable_segments:True")
|
| 118 |
+
torch.set_default_dtype(torch.bfloat16)
|
| 119 |
+
torch.set_num_threads(8)
|
| 120 |
+
torch.manual_seed(33377335)
|
| 121 |
+
with open(config) as f:
|
| 122 |
+
args = ModelArgs(**json.load(f))
|
| 123 |
+
args.temperature = temperature
|
| 124 |
+
if interactive:
|
| 125 |
+
args.max_batch_size = 1
|
| 126 |
+
args.max_seq_len = 64 * 1024
|
| 127 |
+
print(args)
|
| 128 |
+
tokenizer = AutoTokenizer.from_pretrained(ckpt_path)
|
| 129 |
+
print("build model")
|
| 130 |
+
with torch.device("cuda"):
|
| 131 |
+
model = Transformer(args, tokenizer)
|
| 132 |
+
print("load model")
|
| 133 |
+
load_model(model, os.path.join(ckpt_path, f"model{rank}-mp{world_size}.safetensors"))
|
| 134 |
+
torch.set_default_device("cuda")
|
| 135 |
+
print("I'm DeepSeek 👋")
|
| 136 |
+
|
| 137 |
+
if interactive:
|
| 138 |
+
messages = []
|
| 139 |
+
while True:
|
| 140 |
+
if world_size == 1:
|
| 141 |
+
prompt = input(">>> ")
|
| 142 |
+
elif rank == 0:
|
| 143 |
+
prompt = input(">>> ")
|
| 144 |
+
objects = [prompt]
|
| 145 |
+
dist.broadcast_object_list(objects, 0)
|
| 146 |
+
else:
|
| 147 |
+
objects = [None]
|
| 148 |
+
dist.broadcast_object_list(objects, 0)
|
| 149 |
+
prompt = objects[0]
|
| 150 |
+
if prompt == "/exit":
|
| 151 |
+
break
|
| 152 |
+
elif prompt == "/clear":
|
| 153 |
+
messages.clear()
|
| 154 |
+
continue
|
| 155 |
+
messages.append({"role": "user", "content": prompt})
|
| 156 |
+
prompt_tokens = tokenizer.encode(encode_messages(messages, thinking_mode=thinking_mode))
|
| 157 |
+
completion_tokens = generate(model, [prompt_tokens], max_new_tokens, tokenizer.eos_token_id)
|
| 158 |
+
completion = tokenizer.decode(completion_tokens[0])
|
| 159 |
+
print(completion)
|
| 160 |
+
messages.append(parse_message_from_completion_text(completion, thinking_mode=thinking_mode))
|
| 161 |
+
else:
|
| 162 |
+
if input_file.endswith(".json"):
|
| 163 |
+
# Harmony input: a JSON file with one or more OpenAI-format cases
|
| 164 |
+
# ({"messages": [...], "tools": [...]} or a bare message list).
|
| 165 |
+
cases = load_cases(input_file)
|
| 166 |
+
raw_prompts = [to_json(case["messages"]) for case in cases]
|
| 167 |
+
else:
|
| 168 |
+
# Plain-text input: blank-line-separated prompts, optionally with
|
| 169 |
+
# <image>path</image> tags.
|
| 170 |
+
with open(input_file) as f:
|
| 171 |
+
raw_prompts = f.read().rstrip("\n").split("\n\n")
|
| 172 |
+
cases = [{"messages": [{"role": "user", "content": parse_tagged_text(prompt)}]} for prompt in raw_prompts]
|
| 173 |
+
|
| 174 |
+
prompt_tokens, prompt_token_types, images = [], [], []
|
| 175 |
+
for case in cases:
|
| 176 |
+
_, tokens, token_types, image_inputs = prepare_case(case, thinking_mode, tokenizer, args)
|
| 177 |
+
prompt_tokens.append(tokens)
|
| 178 |
+
prompt_token_types.append(token_types)
|
| 179 |
+
images.append(image_inputs)
|
| 180 |
+
|
| 181 |
+
if any(images):
|
| 182 |
+
# image spans must be prefilled in one chunk, so VL prompts are generated one at a time
|
| 183 |
+
completion_tokens = [
|
| 184 |
+
generate(model, [tok], max_new_tokens, tokenizer.eos_token_id, [types], [image])[0]
|
| 185 |
+
for tok, types, image in zip(prompt_tokens, prompt_token_types, images)
|
| 186 |
+
]
|
| 187 |
+
else:
|
| 188 |
+
completion_tokens = generate(model, prompt_tokens, max_new_tokens, tokenizer.eos_token_id)
|
| 189 |
+
completions = tokenizer.batch_decode(completion_tokens)
|
| 190 |
+
for raw_prompt, completion in zip(raw_prompts, completions):
|
| 191 |
+
print("Prompt:", raw_prompt)
|
| 192 |
+
print("Completion:", completion)
|
| 193 |
+
print()
|
| 194 |
+
|
| 195 |
+
if world_size > 1:
|
| 196 |
+
dist.destroy_process_group()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
if __name__ == "__main__":
|
| 200 |
+
parser = ArgumentParser()
|
| 201 |
+
parser.add_argument("--ckpt-path", type=str, required=True)
|
| 202 |
+
parser.add_argument("--config", type=str, required=True)
|
| 203 |
+
parser.add_argument("--input-file", type=str, default="")
|
| 204 |
+
parser.add_argument("--interactive", action="store_true")
|
| 205 |
+
parser.add_argument("--max-new-tokens", type=int, default=200)
|
| 206 |
+
parser.add_argument("--temperature", type=float, default=1.0)
|
| 207 |
+
parser.add_argument("--thinking-mode", type=str, default="chat", choices=["chat", "thinking"])
|
| 208 |
+
args = parser.parse_args()
|
| 209 |
+
assert args.input_file or args.interactive, "Either input-file or interactive mode must be specified"
|
| 210 |
+
main(
|
| 211 |
+
args.ckpt_path,
|
| 212 |
+
args.config,
|
| 213 |
+
args.input_file,
|
| 214 |
+
args.interactive,
|
| 215 |
+
args.max_new_tokens,
|
| 216 |
+
args.temperature,
|
| 217 |
+
args.thinking_mode,
|
| 218 |
+
)
|
inference/image_processor.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image preprocessing.
|
| 2 |
+
|
| 3 |
+
An image becomes a `n_vit_h x n_vit_w` patch grid for the ViT and a `n_llm_h x n_llm_w` token grid
|
| 4 |
+
after the 3x3 aligner downsample, which the LLM sees as
|
| 5 |
+
|
| 6 |
+
[IMAGE_START] + ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h + [IMAGE_END]
|
| 7 |
+
|
| 8 |
+
Every one of those positions carries `image_token_id` in `input_ids`; only the token type tells them
|
| 9 |
+
apart. The IMAGE slots are filled with aligner rows in reading order.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import base64
|
| 13 |
+
import io
|
| 14 |
+
import math
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from urllib.request import urlopen
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
from PIL import Image, ImageOps
|
| 21 |
+
|
| 22 |
+
TEXT = -1
|
| 23 |
+
IMAGE_START, IMAGE, IMAGE_NEW_LINE, IMAGE_END = range(4)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class ImageInput:
|
| 28 |
+
start: int
|
| 29 |
+
patches: torch.Tensor
|
| 30 |
+
n_vit_h: int
|
| 31 |
+
n_vit_w: int
|
| 32 |
+
types: torch.Tensor
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def num_image_tokens(n_llm_h: int, n_llm_w: int) -> int:
|
| 36 |
+
return n_llm_h * (n_llm_w + 1) + 2
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def llm_grid(best_height: int, best_width: int, patch_size: int, downsample_ratio: int):
|
| 40 |
+
"""Token grid the aligner produces from a patch grid of this pixel size."""
|
| 41 |
+
return math.ceil((best_height // patch_size) / downsample_ratio), math.ceil(
|
| 42 |
+
(best_width // patch_size) / downsample_ratio
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token):
|
| 47 |
+
"""Largest aspect-preserving pixel size whose token grid still fits in max_n_token."""
|
| 48 |
+
r = height / width
|
| 49 |
+
max_w_float = math.sqrt((max_n_token - 2) / r + 0.25) - 0.5
|
| 50 |
+
max_h_float = max_w_float * r
|
| 51 |
+
cell = patch_size * downsample_ratio
|
| 52 |
+
if max_w_float < 1.0: # very tall: collapse to a single column
|
| 53 |
+
return (max_n_token - 2) // 2 * cell, cell
|
| 54 |
+
if max_h_float < 1.0: # very wide: collapse to a single row
|
| 55 |
+
return cell, (max_n_token - 3) * cell
|
| 56 |
+
beta = min(math.floor(max_w_float) * cell / width, math.floor(max_h_float) * cell / height)
|
| 57 |
+
return math.floor(height * beta / patch_size) * patch_size, math.floor(width * beta / patch_size) * patch_size
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def safe_resize(height, width, best_height, best_width, patch_size, downsample_ratio, max_n_token):
|
| 61 |
+
"""Shrink the pixel size until the image costs at most max_n_token LLM tokens."""
|
| 62 |
+
n_llm_h, n_llm_w = llm_grid(best_height, best_width, patch_size, downsample_ratio)
|
| 63 |
+
if num_image_tokens(n_llm_h, n_llm_w) > max_n_token:
|
| 64 |
+
best_height, best_width = solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token)
|
| 65 |
+
n_llm_h, n_llm_w = llm_grid(best_height, best_width, patch_size, downsample_ratio)
|
| 66 |
+
assert num_image_tokens(n_llm_h, n_llm_w) <= max_n_token
|
| 67 |
+
return n_llm_h, n_llm_w, best_height, best_width
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def load_image_bytes(record) -> bytes:
|
| 71 |
+
"""Load image bytes from raw/base64 data, an Anthropic source, URL, or path."""
|
| 72 |
+
data = record.get("data")
|
| 73 |
+
if isinstance(data, bytes):
|
| 74 |
+
return data
|
| 75 |
+
if isinstance(data, str):
|
| 76 |
+
return base64.b64decode(data)
|
| 77 |
+
|
| 78 |
+
source = record.get("source")
|
| 79 |
+
if isinstance(source, dict):
|
| 80 |
+
if source.get("data") is not None:
|
| 81 |
+
return base64.b64decode(source["data"])
|
| 82 |
+
if source.get("url"):
|
| 83 |
+
return load_image_bytes({"url": source["url"]})
|
| 84 |
+
|
| 85 |
+
url = record.get("url")
|
| 86 |
+
if isinstance(url, str) and url:
|
| 87 |
+
if url.startswith("data:"):
|
| 88 |
+
header, _, payload = url.partition(",")
|
| 89 |
+
if ";base64" not in header:
|
| 90 |
+
raise ValueError(f"Unsupported data URL encoding: {header}")
|
| 91 |
+
return base64.b64decode(payload)
|
| 92 |
+
if url.startswith(("http://", "https://")):
|
| 93 |
+
with urlopen(url, timeout=30) as response:
|
| 94 |
+
return response.read()
|
| 95 |
+
with open(url, "rb") as file:
|
| 96 |
+
return file.read()
|
| 97 |
+
|
| 98 |
+
raise ValueError(f"Cannot load image from record: {list(record.keys())}")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def plan_image_grid(width: int, height: int, args):
|
| 102 |
+
"""Resize plan for an image of the given original size; a pure function of its arguments."""
|
| 103 |
+
p = args.vision_patch_size
|
| 104 |
+
if args.vision_max_wh_ratio is not None and width > height * args.vision_max_wh_ratio:
|
| 105 |
+
width = height * args.vision_max_wh_ratio
|
| 106 |
+
if 0 < width * height < args.vision_min_pixels:
|
| 107 |
+
ratio = (args.vision_min_pixels / (width * height)) ** 0.5
|
| 108 |
+
width = int(width * ratio)
|
| 109 |
+
height = int(height * ratio)
|
| 110 |
+
best_width = math.ceil(width / p) * p
|
| 111 |
+
best_height = math.ceil(height / p) * p
|
| 112 |
+
return safe_resize(height, width, best_height, best_width, p, args.vision_downsample_ratio, args.vision_max_n_token)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def load_image(record, args):
|
| 116 |
+
"""Load and transform one image record into ViT patches."""
|
| 117 |
+
p = args.vision_patch_size
|
| 118 |
+
with Image.open(io.BytesIO(load_image_bytes(record))) as source:
|
| 119 |
+
image = source.convert("RGB")
|
| 120 |
+
n_llm_h, n_llm_w, best_height, best_width = plan_image_grid(image.width, image.height, args)
|
| 121 |
+
n_vit_h, n_vit_w = best_height // p, best_width // p
|
| 122 |
+
if args.vision_max_wh_ratio is not None and image.width >= args.vision_max_wh_ratio * image.height:
|
| 123 |
+
image = image.resize((best_width, best_height))
|
| 124 |
+
else:
|
| 125 |
+
image = ImageOps.pad(image, (best_width, best_height), color=(127, 127, 127))
|
| 126 |
+
x = torch.from_numpy(np.asarray(image, dtype=np.float32)).permute(2, 0, 1) / 255
|
| 127 |
+
x = ((x - 0.5) / 0.5).to(torch.bfloat16)
|
| 128 |
+
patches = x.reshape(3, n_vit_h, p, n_vit_w, p).permute(1, 3, 0, 2, 4).reshape(n_vit_h * n_vit_w, 3, p, p)
|
| 129 |
+
return patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def image_token_types(n_llm_h: int, n_llm_w: int) -> torch.Tensor:
|
| 133 |
+
"""Default layout: the aligner grid in reading order, one IMAGE_NEW_LINE per row."""
|
| 134 |
+
types = [IMAGE_START]
|
| 135 |
+
types += ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h
|
| 136 |
+
types.append(IMAGE_END)
|
| 137 |
+
return torch.tensor(types, dtype=torch.int64)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def prepare_vl_inputs(prompt, images, tokenizer, args):
|
| 141 |
+
"""Tokenize `prompt`, expanding each image placeholder token into its image span.
|
| 142 |
+
|
| 143 |
+
Returns (tokens, token_types, image_inputs). Image-span positions carry `args.image_token_id` in
|
| 144 |
+
`tokens` and are distinguished only by `token_types` (TEXT elsewhere). `image_inputs` is None when
|
| 145 |
+
the prompt has no images."""
|
| 146 |
+
from encoding import IMAGE_PLACEHOLDER
|
| 147 |
+
|
| 148 |
+
# The placeholder is spelled differently across tokenizer revisions, so the id comes from the
|
| 149 |
+
# config; only cross-check it when this tokenizer does know the training-time spelling.
|
| 150 |
+
image_token_id = args.image_token_id
|
| 151 |
+
placeholder_id = tokenizer.convert_tokens_to_ids(IMAGE_PLACEHOLDER)
|
| 152 |
+
if placeholder_id is not None and placeholder_id != tokenizer.unk_token_id:
|
| 153 |
+
assert placeholder_id == image_token_id, (placeholder_id, image_token_id)
|
| 154 |
+
prompt_tokens = tokenizer.encode(prompt)
|
| 155 |
+
num_placeholders = sum(token == image_token_id for token in prompt_tokens)
|
| 156 |
+
if num_placeholders != len(images):
|
| 157 |
+
raise ValueError(f"Found {num_placeholders} image tokens but got {len(images)} images")
|
| 158 |
+
if num_placeholders and not args.vision_enabled:
|
| 159 |
+
raise ValueError("The model config has no vision tower (vision_n_layers == 0) but the prompt contains images")
|
| 160 |
+
|
| 161 |
+
tokens, token_types, image_inputs = [], [], []
|
| 162 |
+
image_iter = iter(images)
|
| 163 |
+
for tok in prompt_tokens:
|
| 164 |
+
if tok != image_token_id:
|
| 165 |
+
tokens.append(tok)
|
| 166 |
+
token_types.append(TEXT)
|
| 167 |
+
continue
|
| 168 |
+
patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w = load_image(next(image_iter), args)
|
| 169 |
+
types = image_token_types(n_llm_h, n_llm_w)
|
| 170 |
+
image_inputs.append(ImageInput(len(tokens), patches, n_vit_h, n_vit_w, types))
|
| 171 |
+
tokens += [image_token_id] * types.numel()
|
| 172 |
+
token_types += types.tolist()
|
| 173 |
+
return tokens, token_types, image_inputs or None
|
inference/kernel.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import tilelang
|
| 3 |
+
import tilelang.language as T
|
| 4 |
+
from typing import Tuple, Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
tilelang.set_log_level("WARNING")
|
| 8 |
+
|
| 9 |
+
pass_configs = {
|
| 10 |
+
tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True,
|
| 11 |
+
tilelang.PassConfigKey.TL_DISABLE_TMA_LOWER: True,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
FP8 = "float8_e4m3"
|
| 15 |
+
FP4 = "float4_e2m1fn"
|
| 16 |
+
FE8M0 = "float8_e8m0fnu"
|
| 17 |
+
BF16 = "bfloat16"
|
| 18 |
+
FP32 = "float32"
|
| 19 |
+
INT32 = "int32"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def fast_log2_ceil(x):
|
| 23 |
+
"""Compute ceil(log2(x)) via IEEE 754 bit manipulation. Avoids slow log/ceil intrinsics."""
|
| 24 |
+
bits_x = T.reinterpret("uint32", x)
|
| 25 |
+
exp_x = (bits_x >> 23) & 0xFF
|
| 26 |
+
man_bits = bits_x & ((1 << 23) - 1)
|
| 27 |
+
return T.Cast("int32", exp_x - 127 + T.if_then_else(man_bits != 0, 1, 0))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def fast_pow2(x):
|
| 31 |
+
"""Compute 2^x for integer x via IEEE 754 bit manipulation."""
|
| 32 |
+
bits_x = (x + 127) << 23
|
| 33 |
+
return T.reinterpret("float32", bits_x)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def fast_round_scale(amax, fp8_max_inv):
|
| 37 |
+
return fast_pow2(fast_log2_ceil(amax * fp8_max_inv))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@tilelang.jit(pass_configs=pass_configs)
|
| 41 |
+
def act_quant_kernel(N, block_size=128, in_dtype=BF16, out_dtype=FP8, scale_dtype=FP32, round_scale=False, inplace=False):
|
| 42 |
+
"""Block-wise FP8 quantization. inplace=True does fused quant+dequant back to BF16."""
|
| 43 |
+
M = T.symbolic("M")
|
| 44 |
+
fp8_min = -448.0
|
| 45 |
+
fp8_max = 448.0
|
| 46 |
+
fp8_max_inv = 1 / fp8_max
|
| 47 |
+
num_stages = 0 if round_scale or inplace else 2
|
| 48 |
+
blk_m = 32
|
| 49 |
+
group_size = block_size
|
| 50 |
+
# Internal computation in FP32; scale_dtype controls output storage format.
|
| 51 |
+
compute_dtype = FP32
|
| 52 |
+
out_dtype = in_dtype if inplace else out_dtype
|
| 53 |
+
|
| 54 |
+
@T.prim_func
|
| 55 |
+
def act_quant_kernel_(
|
| 56 |
+
X: T.Tensor[(M, N), in_dtype],
|
| 57 |
+
Y: T.Tensor[(M, N), out_dtype],
|
| 58 |
+
S: T.Tensor[(M, T.ceildiv(N, group_size)), scale_dtype],
|
| 59 |
+
):
|
| 60 |
+
with T.Kernel(T.ceildiv(M, blk_m), T.ceildiv(N, group_size), threads=128) as (
|
| 61 |
+
pid_m,
|
| 62 |
+
pid_n,
|
| 63 |
+
):
|
| 64 |
+
x_shared = T.alloc_shared((blk_m, group_size), in_dtype)
|
| 65 |
+
x_local = T.alloc_fragment((blk_m, group_size), in_dtype)
|
| 66 |
+
amax_local = T.alloc_fragment((blk_m,), compute_dtype)
|
| 67 |
+
s_local = T.alloc_fragment((blk_m,), compute_dtype)
|
| 68 |
+
y_local = T.alloc_fragment((blk_m, group_size), out_dtype)
|
| 69 |
+
y_shared = T.alloc_shared((blk_m, group_size), out_dtype)
|
| 70 |
+
|
| 71 |
+
for _ in T.Pipelined(1, num_stages=num_stages):
|
| 72 |
+
T.copy(X[pid_m * blk_m, pid_n * group_size], x_shared)
|
| 73 |
+
T.copy(x_shared, x_local)
|
| 74 |
+
T.reduce_absmax(x_local, amax_local, dim=1)
|
| 75 |
+
for i in T.Parallel(blk_m):
|
| 76 |
+
amax_local[i] = T.max(amax_local[i], 1e-4)
|
| 77 |
+
if round_scale:
|
| 78 |
+
s_local[i] = fast_round_scale(amax_local[i], fp8_max_inv)
|
| 79 |
+
else:
|
| 80 |
+
s_local[i] = amax_local[i] * fp8_max_inv
|
| 81 |
+
if inplace:
|
| 82 |
+
for i, j in T.Parallel(blk_m, group_size):
|
| 83 |
+
y_local[i, j] = T.Cast(
|
| 84 |
+
out_dtype,
|
| 85 |
+
T.Cast(compute_dtype, T.Cast(FP8, T.clamp(x_local[i, j] / s_local[i], fp8_min, fp8_max))) * s_local[i],
|
| 86 |
+
)
|
| 87 |
+
else:
|
| 88 |
+
for i, j in T.Parallel(blk_m, group_size):
|
| 89 |
+
y_local[i, j] = T.clamp(x_local[i, j] / s_local[i], fp8_min, fp8_max)
|
| 90 |
+
for i in T.Parallel(blk_m):
|
| 91 |
+
S[pid_m * blk_m + i, pid_n] = T.Cast(scale_dtype, s_local[i])
|
| 92 |
+
T.copy(y_local, y_shared)
|
| 93 |
+
T.copy(y_shared, Y[pid_m * blk_m, pid_n * group_size])
|
| 94 |
+
|
| 95 |
+
return act_quant_kernel_
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def act_quant(
|
| 99 |
+
x: torch.Tensor,
|
| 100 |
+
block_size: int = 128,
|
| 101 |
+
scale_fmt: Optional[str] = None,
|
| 102 |
+
scale_dtype: torch.dtype = torch.float32,
|
| 103 |
+
inplace: bool = False,
|
| 104 |
+
) -> torch.Tensor:
|
| 105 |
+
"""Block-wise FP8 quantization. inplace=True does fused quant+dequant back to BF16.
|
| 106 |
+
When scale_fmt is set, scales are rounded to power-of-2 (MXFP)."""
|
| 107 |
+
N = x.size(-1)
|
| 108 |
+
assert N % block_size == 0
|
| 109 |
+
tl_dtype = FE8M0 if scale_dtype == torch.float8_e8m0fnu else FP32
|
| 110 |
+
z = x.contiguous()
|
| 111 |
+
y = torch.empty_like(z) if inplace else torch.empty_like(z, dtype=torch.float8_e4m3fn)
|
| 112 |
+
s = z.new_empty(*z.size()[:-1], N // block_size, dtype=scale_dtype)
|
| 113 |
+
kernel = act_quant_kernel(
|
| 114 |
+
N,
|
| 115 |
+
block_size,
|
| 116 |
+
scale_dtype=tl_dtype,
|
| 117 |
+
round_scale=scale_fmt is not None,
|
| 118 |
+
inplace=inplace,
|
| 119 |
+
)
|
| 120 |
+
kernel(z.view(-1, N), y.view(-1, N), s.view(-1, N // block_size))
|
| 121 |
+
if inplace:
|
| 122 |
+
x.copy_(y)
|
| 123 |
+
return x
|
| 124 |
+
return y, s
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@tilelang.jit(pass_configs=pass_configs)
|
| 128 |
+
def fp4_quant_kernel(N, block_size=32, in_dtype=BF16, scale_dtype=FE8M0, inplace=False):
|
| 129 |
+
"""Block-wise FP4 with power-of-2 or E4M3 scales; optionally dequantize in place."""
|
| 130 |
+
M = T.symbolic("M")
|
| 131 |
+
fp4_max = 6.0
|
| 132 |
+
fp4_max_inv = 1.0 / fp4_max
|
| 133 |
+
blk_m = 32
|
| 134 |
+
group_size = block_size
|
| 135 |
+
compute_dtype = FP32
|
| 136 |
+
out_dtype = in_dtype if inplace else FP4
|
| 137 |
+
|
| 138 |
+
@T.prim_func
|
| 139 |
+
def fp4_quant_kernel_(
|
| 140 |
+
X: T.Tensor[(M, N), in_dtype],
|
| 141 |
+
Y: T.Tensor[(M, N), out_dtype],
|
| 142 |
+
S: T.Tensor[(M, T.ceildiv(N, group_size)), scale_dtype],
|
| 143 |
+
):
|
| 144 |
+
with T.Kernel(T.ceildiv(M, blk_m), T.ceildiv(N, group_size), threads=128) as (
|
| 145 |
+
pid_m,
|
| 146 |
+
pid_n,
|
| 147 |
+
):
|
| 148 |
+
x_shared = T.alloc_shared((blk_m, group_size), in_dtype)
|
| 149 |
+
x_local = T.alloc_fragment((blk_m, group_size), in_dtype)
|
| 150 |
+
amax_local = T.alloc_fragment((blk_m,), compute_dtype)
|
| 151 |
+
s_local = T.alloc_fragment((blk_m,), compute_dtype)
|
| 152 |
+
y_local = T.alloc_fragment((blk_m, group_size), out_dtype)
|
| 153 |
+
y_shared = T.alloc_shared((blk_m, group_size), out_dtype)
|
| 154 |
+
|
| 155 |
+
for _ in T.Pipelined(1, num_stages=2):
|
| 156 |
+
T.copy(X[pid_m * blk_m, pid_n * group_size], x_shared)
|
| 157 |
+
T.copy(x_shared, x_local)
|
| 158 |
+
T.reduce_absmax(x_local, amax_local, dim=1)
|
| 159 |
+
for i in T.Parallel(blk_m):
|
| 160 |
+
if scale_dtype == FP8:
|
| 161 |
+
# Training's compressed KV: keep even an all-zero group's scale nonzero.
|
| 162 |
+
amax_local[i] = T.max(amax_local[i], 6 * (2**-9))
|
| 163 |
+
s_local[i] = T.Cast(compute_dtype, T.Cast(FP8, amax_local[i] / fp4_max))
|
| 164 |
+
else:
|
| 165 |
+
amax_local[i] = T.max(amax_local[i], 6 * (2**-126))
|
| 166 |
+
s_local[i] = fast_round_scale(amax_local[i], fp4_max_inv)
|
| 167 |
+
if inplace:
|
| 168 |
+
for i, j in T.Parallel(blk_m, group_size):
|
| 169 |
+
y_local[i, j] = T.Cast(
|
| 170 |
+
out_dtype,
|
| 171 |
+
T.Cast(compute_dtype, T.Cast(FP4, T.clamp(x_local[i, j] / s_local[i], -fp4_max, fp4_max))) * s_local[i],
|
| 172 |
+
)
|
| 173 |
+
else:
|
| 174 |
+
for i, j in T.Parallel(blk_m, group_size):
|
| 175 |
+
y_local[i, j] = T.clamp(x_local[i, j] / s_local[i], -fp4_max, fp4_max)
|
| 176 |
+
for i in T.Parallel(blk_m):
|
| 177 |
+
S[pid_m * blk_m + i, pid_n] = T.Cast(scale_dtype, s_local[i])
|
| 178 |
+
T.copy(y_local, y_shared)
|
| 179 |
+
T.copy(y_shared, Y[pid_m * blk_m, pid_n * group_size])
|
| 180 |
+
|
| 181 |
+
return fp4_quant_kernel_
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def fp4_act_quant(
|
| 185 |
+
x: torch.Tensor,
|
| 186 |
+
block_size: int = 32,
|
| 187 |
+
inplace: bool = False,
|
| 188 |
+
scale_dtype: torch.dtype = torch.float8_e8m0fnu,
|
| 189 |
+
) -> torch.Tensor:
|
| 190 |
+
"""FP4 with E8M0 scales for the indexer or E4M3 scales for compressed KV.
|
| 191 |
+
inplace=True writes the dequantized values back to x."""
|
| 192 |
+
assert scale_dtype in (torch.float8_e8m0fnu, torch.float8_e4m3fn)
|
| 193 |
+
N = x.size(-1)
|
| 194 |
+
assert N % block_size == 0
|
| 195 |
+
z = x.contiguous()
|
| 196 |
+
y = torch.empty_like(z) if inplace else z.new_empty(*z.shape[:-1], N // 2, dtype=torch.float4_e2m1fn_x2)
|
| 197 |
+
s = z.new_empty(*z.size()[:-1], N // block_size, dtype=scale_dtype)
|
| 198 |
+
tl_dtype = FP8 if scale_dtype == torch.float8_e4m3fn else FE8M0
|
| 199 |
+
kernel = fp4_quant_kernel(N, block_size, scale_dtype=tl_dtype, inplace=inplace)
|
| 200 |
+
kernel(z.view(-1, N), y.view(-1, y.size(-1)), s.view(-1, N // block_size))
|
| 201 |
+
if inplace:
|
| 202 |
+
x.copy_(y)
|
| 203 |
+
return x
|
| 204 |
+
return y, s
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@tilelang.jit(pass_configs=pass_configs)
|
| 208 |
+
def fp8_gemm_kernel(
|
| 209 |
+
N,
|
| 210 |
+
K,
|
| 211 |
+
block_size=128,
|
| 212 |
+
out_dtype=BF16,
|
| 213 |
+
accum_dtype=FP32,
|
| 214 |
+
scale_dtype=FP32,
|
| 215 |
+
):
|
| 216 |
+
assert out_dtype in [BF16, FP32]
|
| 217 |
+
assert block_size in [32, 128]
|
| 218 |
+
|
| 219 |
+
M = T.symbolic("M")
|
| 220 |
+
group_size = block_size
|
| 221 |
+
block_M = 32
|
| 222 |
+
block_N = 128
|
| 223 |
+
block_K = group_size
|
| 224 |
+
num_stages = 4 if block_K == 128 else 2
|
| 225 |
+
|
| 226 |
+
@T.prim_func
|
| 227 |
+
def fp8_gemm_kernel_(
|
| 228 |
+
A: T.Tensor[(M, K), FP8],
|
| 229 |
+
B: T.Tensor[(N, K), FP8],
|
| 230 |
+
C: T.Tensor[(M, N), out_dtype],
|
| 231 |
+
scales_a: T.Tensor[(M, T.ceildiv(K, group_size)), scale_dtype],
|
| 232 |
+
scales_b: T.Tensor[(T.ceildiv(N, group_size), T.ceildiv(K, group_size)), scale_dtype],
|
| 233 |
+
):
|
| 234 |
+
with T.Kernel(T.ceildiv(N, block_N), T.ceildiv(M, block_M), threads=128) as (
|
| 235 |
+
bx,
|
| 236 |
+
by,
|
| 237 |
+
):
|
| 238 |
+
A_shared = T.alloc_shared((block_M, block_K), FP8)
|
| 239 |
+
B_shared = T.alloc_shared((block_N, block_K), FP8)
|
| 240 |
+
C_shared = T.alloc_shared((block_M, block_N), out_dtype)
|
| 241 |
+
C_local = T.alloc_fragment((block_M, block_N), accum_dtype)
|
| 242 |
+
C_local_accum = T.alloc_fragment((block_M, block_N), accum_dtype)
|
| 243 |
+
scale_a_frag = T.alloc_fragment((block_M,), FP32)
|
| 244 |
+
scale_b_frag = T.alloc_fragment((block_N,), FP32)
|
| 245 |
+
|
| 246 |
+
# Improve L2 Cache
|
| 247 |
+
T.use_swizzle(panel_size=10)
|
| 248 |
+
T.clear(C_local)
|
| 249 |
+
T.clear(C_local_accum)
|
| 250 |
+
|
| 251 |
+
K_iters = T.ceildiv(K, block_K)
|
| 252 |
+
for k in T.Pipelined(K_iters, num_stages=num_stages):
|
| 253 |
+
T.copy(A[by * block_M, k * block_K], A_shared)
|
| 254 |
+
T.copy(B[bx * block_N, k * block_K], B_shared)
|
| 255 |
+
|
| 256 |
+
# Activation scales are per row and K block. Weight scales are
|
| 257 |
+
# per (N block, K block), where the block can be 32 or 128.
|
| 258 |
+
for i in T.Parallel(block_M):
|
| 259 |
+
scale_a_frag[i] = T.Cast(FP32, scales_a[by * block_M + i, k])
|
| 260 |
+
for j in T.Parallel(block_N):
|
| 261 |
+
scale_b_frag[j] = T.Cast(
|
| 262 |
+
FP32,
|
| 263 |
+
scales_b[(bx * block_N + j) // group_size, k],
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
T.gemm(A_shared, B_shared, C_local, transpose_B=True)
|
| 267 |
+
# Separate accumulator for scale-corrected results.
|
| 268 |
+
for i, j in T.Parallel(block_M, block_N):
|
| 269 |
+
C_local_accum[i, j] += C_local[i, j] * scale_a_frag[i] * scale_b_frag[j]
|
| 270 |
+
T.clear(C_local)
|
| 271 |
+
T.copy(C_local_accum, C_shared)
|
| 272 |
+
T.copy(C_shared, C[by * block_M, bx * block_N])
|
| 273 |
+
|
| 274 |
+
return fp8_gemm_kernel_
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def fp8_gemm(
|
| 278 |
+
a: torch.Tensor,
|
| 279 |
+
a_s: torch.Tensor,
|
| 280 |
+
b: torch.Tensor,
|
| 281 |
+
b_s: torch.Tensor,
|
| 282 |
+
scale_dtype: torch.dtype = torch.float32,
|
| 283 |
+
block_size: int = 128,
|
| 284 |
+
) -> torch.Tensor:
|
| 285 |
+
"""C[M,N] = A[M,K] @ B[N,K]^T with per-block FP8 scaling."""
|
| 286 |
+
assert a.is_contiguous() and b.is_contiguous(), "Input tensors must be contiguous"
|
| 287 |
+
assert a_s.is_contiguous() and b_s.is_contiguous(), "Scaling factor tensors must be contiguous"
|
| 288 |
+
assert block_size in (32, 128)
|
| 289 |
+
tl_dtype = FE8M0 if scale_dtype == torch.float8_e8m0fnu else FP32
|
| 290 |
+
K = a.size(-1)
|
| 291 |
+
M = a.numel() // K
|
| 292 |
+
N = b.size(0)
|
| 293 |
+
assert K % block_size == 0
|
| 294 |
+
assert a_s.numel() == M * (K // block_size)
|
| 295 |
+
assert b_s.shape == (
|
| 296 |
+
(N + block_size - 1) // block_size,
|
| 297 |
+
K // block_size,
|
| 298 |
+
)
|
| 299 |
+
c = a.new_empty(*a.size()[:-1], N, dtype=torch.get_default_dtype())
|
| 300 |
+
kernel = fp8_gemm_kernel(
|
| 301 |
+
N,
|
| 302 |
+
K,
|
| 303 |
+
block_size=block_size,
|
| 304 |
+
scale_dtype=tl_dtype,
|
| 305 |
+
)
|
| 306 |
+
kernel(a.view(M, K), b, c.view(M, N), a_s.view(M, -1), b_s)
|
| 307 |
+
return c
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@tilelang.jit(pass_configs=pass_configs)
|
| 311 |
+
def sparse_attn_kernel(h: int, d: int, scale=None):
|
| 312 |
+
"""Sparse multi-head attention via index gathering + online softmax (FlashAttention-style).
|
| 313 |
+
For each (batch, seq_pos), gathers top-k KV positions by index, computes attention
|
| 314 |
+
with numerically stable running max/sum, and includes a learnable attn_sink bias."""
|
| 315 |
+
b = T.symbolic("b")
|
| 316 |
+
m = T.symbolic("m")
|
| 317 |
+
n = T.symbolic("n")
|
| 318 |
+
topk = T.symbolic("topk")
|
| 319 |
+
if scale is None:
|
| 320 |
+
scale = (1.0 / d) ** 0.5
|
| 321 |
+
|
| 322 |
+
num_stages = 2
|
| 323 |
+
threads = 256
|
| 324 |
+
block = 64
|
| 325 |
+
num_blocks = tilelang.cdiv(topk, block)
|
| 326 |
+
|
| 327 |
+
@T.prim_func
|
| 328 |
+
def sparse_attn_kernel_(
|
| 329 |
+
q: T.Tensor[(b, m, h, d), BF16],
|
| 330 |
+
kv: T.Tensor[(b, n, d), BF16],
|
| 331 |
+
o: T.Tensor[(b, m, h, d), BF16],
|
| 332 |
+
attn_sink: T.Tensor[(h,), FP32],
|
| 333 |
+
topk_idxs: T.Tensor[(b, m, topk), INT32],
|
| 334 |
+
):
|
| 335 |
+
with T.Kernel(m, b, threads=threads) as (bx, by):
|
| 336 |
+
q_shared = T.alloc_shared((h, d), BF16)
|
| 337 |
+
kv_shared = T.alloc_shared((block, d), BF16)
|
| 338 |
+
o_shared = T.alloc_shared((h, d), BF16)
|
| 339 |
+
acc_s_cast = T.alloc_shared((h, block), BF16)
|
| 340 |
+
|
| 341 |
+
idxs = T.alloc_fragment(block, INT32)
|
| 342 |
+
acc_s = T.alloc_fragment((h, block), FP32)
|
| 343 |
+
acc_o = T.alloc_fragment((h, d), FP32)
|
| 344 |
+
scores_max = T.alloc_fragment(h, FP32)
|
| 345 |
+
scores_max_prev = T.alloc_fragment(h, FP32)
|
| 346 |
+
scores_scale = T.alloc_fragment(h, FP32)
|
| 347 |
+
scores_sum = T.alloc_fragment(h, FP32)
|
| 348 |
+
sum_exp = T.alloc_fragment(h, FP32)
|
| 349 |
+
|
| 350 |
+
T.clear(acc_o)
|
| 351 |
+
T.clear(sum_exp)
|
| 352 |
+
# Finite lower bound instead of -inf: a row with no valid index (all -1) would otherwise
|
| 353 |
+
# produce exp(-inf - (-inf)) = NaN. With a finite bound such rows yield an all-zero output,
|
| 354 |
+
# matching the training kernel's convention.
|
| 355 |
+
T.fill(scores_max, -1e30)
|
| 356 |
+
T.copy(q[by, bx, :, :], q_shared)
|
| 357 |
+
|
| 358 |
+
for t in T.Pipelined(num_blocks, num_stages=num_stages):
|
| 359 |
+
for i in T.Parallel(block):
|
| 360 |
+
idxs[i] = T.if_then_else(t * block + i < topk, topk_idxs[by, bx, t * block + i], -1)
|
| 361 |
+
for i, j in T.Parallel(block, d):
|
| 362 |
+
kv_shared[i, j] = T.if_then_else(idxs[i] != -1, kv[by, idxs[i], j], 0)
|
| 363 |
+
for i, j in T.Parallel(h, block):
|
| 364 |
+
acc_s[i, j] = T.if_then_else(idxs[j] != -1, 0, -T.infinity(FP32))
|
| 365 |
+
T.gemm(q_shared, kv_shared, acc_s, transpose_B=True, policy=T.GemmWarpPolicy.FullRow)
|
| 366 |
+
for i, j in T.Parallel(h, block):
|
| 367 |
+
acc_s[i, j] *= scale
|
| 368 |
+
T.copy(scores_max, scores_max_prev)
|
| 369 |
+
T.reduce_max(acc_s, scores_max, dim=1, clear=False)
|
| 370 |
+
for i in T.Parallel(h):
|
| 371 |
+
scores_scale[i] = T.exp(scores_max_prev[i] - scores_max[i])
|
| 372 |
+
for i, j in T.Parallel(h, block):
|
| 373 |
+
acc_s[i, j] = T.exp(acc_s[i, j] - scores_max[i])
|
| 374 |
+
T.reduce_sum(acc_s, scores_sum, dim=1)
|
| 375 |
+
for i in T.Parallel(h):
|
| 376 |
+
sum_exp[i] = sum_exp[i] * scores_scale[i] + scores_sum[i]
|
| 377 |
+
T.copy(acc_s, acc_s_cast)
|
| 378 |
+
for i, j in T.Parallel(h, d):
|
| 379 |
+
acc_o[i, j] *= scores_scale[i]
|
| 380 |
+
T.gemm(acc_s_cast, kv_shared, acc_o, policy=T.GemmWarpPolicy.FullRow)
|
| 381 |
+
|
| 382 |
+
for i in T.Parallel(h):
|
| 383 |
+
sum_exp[i] += T.exp(attn_sink[i] - scores_max[i])
|
| 384 |
+
for i, j in T.Parallel(h, d):
|
| 385 |
+
acc_o[i, j] /= sum_exp[i]
|
| 386 |
+
T.copy(acc_o, o_shared)
|
| 387 |
+
T.copy(o_shared, o[by, bx, :, :])
|
| 388 |
+
|
| 389 |
+
return sparse_attn_kernel_
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def sparse_attn(q: torch.Tensor, kv: torch.Tensor, attn_sink: torch.Tensor, topk_idxs: torch.Tensor, softmax_scale: float) -> torch.Tensor:
|
| 393 |
+
b, s, h, d = q.size()
|
| 394 |
+
# Pad heads to 16 for kernel efficiency (stripped after)
|
| 395 |
+
if h < 16:
|
| 396 |
+
q = torch.cat([q, q.new_zeros(b, s, 16 - h, d)], dim=2)
|
| 397 |
+
attn_sink = torch.cat([attn_sink, attn_sink.new_zeros(16 - h)])
|
| 398 |
+
o = torch.empty_like(q)
|
| 399 |
+
kernel = sparse_attn_kernel(q.size(2), d, softmax_scale)
|
| 400 |
+
kernel(q, kv, o, attn_sink, topk_idxs)
|
| 401 |
+
if h < 16:
|
| 402 |
+
o = o.narrow(2, 0, h).contiguous()
|
| 403 |
+
return o
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
@tilelang.jit(pass_configs=pass_configs)
|
| 407 |
+
def hc_split_sinkhorn_kernel(hc: int, sinkhorn_iters: int, eps: float):
|
| 408 |
+
n = T.symbolic("n")
|
| 409 |
+
mix_hc = (2 + hc) * hc
|
| 410 |
+
threads = 64
|
| 411 |
+
|
| 412 |
+
@T.prim_func
|
| 413 |
+
def hc_split_sinkhorn_kernel_(
|
| 414 |
+
mixes: T.Tensor[(n, mix_hc), FP32],
|
| 415 |
+
hc_scale: T.Tensor[(3,), FP32],
|
| 416 |
+
hc_base: T.Tensor[(mix_hc,), FP32],
|
| 417 |
+
pre: T.Tensor[(n, hc), FP32],
|
| 418 |
+
post: T.Tensor[(n, hc), FP32],
|
| 419 |
+
comb: T.Tensor[(n, hc, hc), FP32],
|
| 420 |
+
):
|
| 421 |
+
with T.Kernel(n, threads=threads) as i:
|
| 422 |
+
mixes_shared = T.alloc_shared(mix_hc, FP32)
|
| 423 |
+
comb_frag = T.alloc_fragment((hc, hc), FP32)
|
| 424 |
+
T.copy(mixes[i, :], mixes_shared)
|
| 425 |
+
|
| 426 |
+
for j in T.Parallel(hc):
|
| 427 |
+
pre[i, j] = T.sigmoid(mixes_shared[j] * hc_scale[0] + hc_base[j]) + eps
|
| 428 |
+
for j in T.Parallel(hc):
|
| 429 |
+
post[i, j] = 2 * T.sigmoid(mixes_shared[j + hc] * hc_scale[1] + hc_base[j + hc])
|
| 430 |
+
for j, k in T.Parallel(hc, hc):
|
| 431 |
+
comb_frag[j, k] = mixes_shared[j * hc + k + hc * 2] * hc_scale[2] + hc_base[j * hc + k + hc * 2]
|
| 432 |
+
|
| 433 |
+
row_sum = T.alloc_fragment(hc, FP32)
|
| 434 |
+
col_sum = T.alloc_fragment(hc, FP32)
|
| 435 |
+
|
| 436 |
+
# comb = comb.softmax(-1) + eps
|
| 437 |
+
row_max = T.alloc_fragment(hc, FP32)
|
| 438 |
+
T.reduce_max(comb_frag, row_max, dim=1)
|
| 439 |
+
for j, k in T.Parallel(hc, hc):
|
| 440 |
+
comb_frag[j, k] = T.exp(comb_frag[j, k] - row_max[j])
|
| 441 |
+
T.reduce_sum(comb_frag, row_sum, dim=1)
|
| 442 |
+
for j, k in T.Parallel(hc, hc):
|
| 443 |
+
comb_frag[j, k] = comb_frag[j, k] / row_sum[j] + eps
|
| 444 |
+
|
| 445 |
+
# comb = comb / (comb.sum(-2) + eps)
|
| 446 |
+
T.reduce_sum(comb_frag, col_sum, dim=0)
|
| 447 |
+
for j, k in T.Parallel(hc, hc):
|
| 448 |
+
comb_frag[j, k] = comb_frag[j, k] / (col_sum[k] + eps)
|
| 449 |
+
|
| 450 |
+
for _ in T.serial(sinkhorn_iters - 1):
|
| 451 |
+
# comb = comb / (comb.sum(-1) + eps)
|
| 452 |
+
T.reduce_sum(comb_frag, row_sum, dim=1)
|
| 453 |
+
for j, k in T.Parallel(hc, hc):
|
| 454 |
+
comb_frag[j, k] = comb_frag[j, k] / (row_sum[j] + eps)
|
| 455 |
+
# comb = comb / (comb.sum(-2) + eps)
|
| 456 |
+
T.reduce_sum(comb_frag, col_sum, dim=0)
|
| 457 |
+
for j, k in T.Parallel(hc, hc):
|
| 458 |
+
comb_frag[j, k] = comb_frag[j, k] / (col_sum[k] + eps)
|
| 459 |
+
|
| 460 |
+
T.copy(comb_frag, comb[i, :, :])
|
| 461 |
+
|
| 462 |
+
return hc_split_sinkhorn_kernel_
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def hc_split_sinkhorn(
|
| 466 |
+
mixes: torch.Tensor, hc_scale: torch.Tensor, hc_base: torch.Tensor, hc_mult: int = 4, sinkhorn_iters: int = 20, eps: float = 1e-6
|
| 467 |
+
):
|
| 468 |
+
b, s, _ = mixes.size()
|
| 469 |
+
pre = mixes.new_empty(b, s, hc_mult)
|
| 470 |
+
post = mixes.new_empty(b, s, hc_mult)
|
| 471 |
+
comb = mixes.new_empty(b, s, hc_mult, hc_mult)
|
| 472 |
+
kernel = hc_split_sinkhorn_kernel(hc_mult, sinkhorn_iters, eps)
|
| 473 |
+
kernel(mixes.view(-1, (2 + hc_mult) * hc_mult), hc_scale, hc_base, pre.view(-1, hc_mult), post.view(-1, hc_mult), comb.view(-1, hc_mult, hc_mult))
|
| 474 |
+
return pre, post, comb
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
@tilelang.jit(pass_configs=pass_configs)
|
| 478 |
+
def fp4_gemm_kernel(
|
| 479 |
+
N,
|
| 480 |
+
K,
|
| 481 |
+
act_block_size=128,
|
| 482 |
+
out_dtype=BF16,
|
| 483 |
+
accum_dtype=FP32,
|
| 484 |
+
scale_dtype=FP32,
|
| 485 |
+
):
|
| 486 |
+
"""FP8 act x FP4 weight GEMM kernel.
|
| 487 |
+
|
| 488 |
+
C[M, N] = A_fp8[M, K] @ B_fp4[N, K]^T
|
| 489 |
+
|
| 490 |
+
Act: 1x32 or 1x128 quant on K, FP8 with configurable scale dtype
|
| 491 |
+
Weight: 1x32 quant on K (reduce dim), FP4 with E8M0 scale
|
| 492 |
+
|
| 493 |
+
B is stored as [N, K//2] in float4_e2m1fn_x2, logical [N, K] in fp4.
|
| 494 |
+
The FP4 values are packed along the K (last) dimension.
|
| 495 |
+
|
| 496 |
+
Strategy: load FP4 sub-blocks of size [block_N, sub_K] (sub_K=32),
|
| 497 |
+
cast FP4 to FP8 via float, then do FP8xFP8 GEMM.
|
| 498 |
+
Apply activation and weight scales to the accumulator.
|
| 499 |
+
"""
|
| 500 |
+
M = T.symbolic("M")
|
| 501 |
+
assert act_block_size in [32, 128]
|
| 502 |
+
act_group_size = act_block_size
|
| 503 |
+
weight_group_size = 32
|
| 504 |
+
block_M = 32
|
| 505 |
+
block_N = 128
|
| 506 |
+
block_K = 32 # matches weight_group_size for simple scale handling
|
| 507 |
+
n_sub = act_group_size // block_K
|
| 508 |
+
|
| 509 |
+
@T.prim_func
|
| 510 |
+
def fp4_gemm_kernel_(
|
| 511 |
+
A: T.Tensor[(M, K), FP8],
|
| 512 |
+
B: T.Tensor[(N, K), FP4],
|
| 513 |
+
C: T.Tensor[(M, N), out_dtype],
|
| 514 |
+
scales_a: T.Tensor[(M, T.ceildiv(K, act_group_size)), scale_dtype],
|
| 515 |
+
scales_b: T.Tensor[(N, T.ceildiv(K, weight_group_size)), scale_dtype],
|
| 516 |
+
):
|
| 517 |
+
with T.Kernel(T.ceildiv(N, block_N), T.ceildiv(M, block_M), threads=128) as (
|
| 518 |
+
bx,
|
| 519 |
+
by,
|
| 520 |
+
):
|
| 521 |
+
A_shared = T.alloc_shared((block_M, block_K), FP8)
|
| 522 |
+
B_fp4_shared = T.alloc_shared((block_N, block_K), FP4)
|
| 523 |
+
B_shared = T.alloc_shared((block_N, block_K), FP8)
|
| 524 |
+
C_shared = T.alloc_shared((block_M, block_N), out_dtype)
|
| 525 |
+
C_local = T.alloc_fragment((block_M, block_N), accum_dtype)
|
| 526 |
+
C_local_accum = T.alloc_fragment((block_M, block_N), accum_dtype)
|
| 527 |
+
scale_a_frag = T.alloc_fragment((block_M,), FP32)
|
| 528 |
+
scale_b_frag = T.alloc_fragment((block_N,), FP32)
|
| 529 |
+
|
| 530 |
+
T.use_swizzle(panel_size=10)
|
| 531 |
+
T.clear(C_local)
|
| 532 |
+
T.clear(C_local_accum)
|
| 533 |
+
|
| 534 |
+
K_iters = T.ceildiv(K, block_K)
|
| 535 |
+
for k in T.Pipelined(K_iters, num_stages=2):
|
| 536 |
+
T.copy(A[by * block_M, k * block_K], A_shared)
|
| 537 |
+
T.copy(B[bx * block_N, k * block_K], B_fp4_shared)
|
| 538 |
+
# FP4->FP8 cast must go through FP32 to avoid ambiguous C++ overload
|
| 539 |
+
for i, j in T.Parallel(block_N, block_K):
|
| 540 |
+
B_shared[i, j] = T.Cast(FP8, T.Cast(FP32, B_fp4_shared[i, j]))
|
| 541 |
+
|
| 542 |
+
# Weight scale: per 32 on K, indexed by k (each k is one block_K=32)
|
| 543 |
+
for i in T.Parallel(block_N):
|
| 544 |
+
scale_b_frag[i] = T.Cast(FP32, scales_b[bx * block_N + i, k])
|
| 545 |
+
|
| 546 |
+
# Activation scale: one value per act_group_size elements.
|
| 547 |
+
for i in T.Parallel(block_M):
|
| 548 |
+
scale_a_frag[i] = T.Cast(FP32, scales_a[by * block_M + i, k // n_sub])
|
| 549 |
+
|
| 550 |
+
T.gemm(A_shared, B_shared, C_local, transpose_B=True)
|
| 551 |
+
|
| 552 |
+
for i, j in T.Parallel(block_M, block_N):
|
| 553 |
+
C_local_accum[i, j] += C_local[i, j] * scale_a_frag[i] * scale_b_frag[j]
|
| 554 |
+
T.clear(C_local)
|
| 555 |
+
|
| 556 |
+
T.copy(C_local_accum, C_shared)
|
| 557 |
+
T.copy(C_shared, C[by * block_M, bx * block_N])
|
| 558 |
+
|
| 559 |
+
return fp4_gemm_kernel_
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def fp4_gemm(
|
| 563 |
+
a: torch.Tensor,
|
| 564 |
+
a_s: torch.Tensor,
|
| 565 |
+
b: torch.Tensor,
|
| 566 |
+
b_s: torch.Tensor,
|
| 567 |
+
scale_dtype: torch.dtype = torch.float32,
|
| 568 |
+
act_block_size: int = 128,
|
| 569 |
+
) -> torch.Tensor:
|
| 570 |
+
"""C[M,N] = A_fp8[M,K] @ B_fp4[N,K]^T.
|
| 571 |
+
A has per-32 or per-128 activation scale; B has per-32 E8M0 weight scale.
|
| 572 |
+
B is stored as [N, K//2] in float4_e2m1fn_x2 (2 FP4 values per byte, packed along K)."""
|
| 573 |
+
assert a.is_contiguous() and b.is_contiguous(), "Input tensors must be contiguous"
|
| 574 |
+
assert a_s.is_contiguous() and b_s.is_contiguous(), "Scaling factor tensors must be contiguous"
|
| 575 |
+
tl_dtype = FE8M0 if scale_dtype == torch.float8_e8m0fnu else FP32
|
| 576 |
+
K = a.size(-1)
|
| 577 |
+
M = a.numel() // K
|
| 578 |
+
N = b.size(0)
|
| 579 |
+
assert act_block_size in (32, 128)
|
| 580 |
+
assert K % act_block_size == 0
|
| 581 |
+
assert a_s.numel() == M * (K // act_block_size)
|
| 582 |
+
assert b_s.shape == (N, K // 32)
|
| 583 |
+
c = a.new_empty(*a.size()[:-1], N, dtype=torch.get_default_dtype())
|
| 584 |
+
kernel = fp4_gemm_kernel(
|
| 585 |
+
N,
|
| 586 |
+
K,
|
| 587 |
+
act_block_size=act_block_size,
|
| 588 |
+
scale_dtype=tl_dtype,
|
| 589 |
+
)
|
| 590 |
+
kernel(a.view(M, K), b, c.view(M, N), a_s.view(M, -1), b_s)
|
| 591 |
+
return c
|
inference/model.py
ADDED
|
@@ -0,0 +1,1309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
from contextlib import contextmanager
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
from typing import Literal
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import torch.distributed as dist
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
from engram import EngramLayout, NgramHashState
|
| 11 |
+
from image_processor import IMAGE, IMAGE_END, IMAGE_NEW_LINE, IMAGE_START
|
| 12 |
+
from kernel import (
|
| 13 |
+
act_quant,
|
| 14 |
+
fp4_act_quant,
|
| 15 |
+
fp4_gemm,
|
| 16 |
+
fp8_gemm,
|
| 17 |
+
hc_split_sinkhorn,
|
| 18 |
+
sparse_attn,
|
| 19 |
+
)
|
| 20 |
+
from torch import nn
|
| 21 |
+
from vision import Aligner, ViT
|
| 22 |
+
|
| 23 |
+
# Set once by Transformer.__init__; one model per process, so layers just read them.
|
| 24 |
+
world_size = 1
|
| 25 |
+
rank = 0
|
| 26 |
+
default_dtype = torch.float8_e4m3fn # storage dtype for Linear weights, from ModelArgs.dtype
|
| 27 |
+
fp8_block_size = 32 # one fp8 scale per 32x32 weight block / 32 activations
|
| 28 |
+
fp4_block_size = 32 # one fp4 scale per 32 elements along K
|
| 29 |
+
scale_fmt = "ue8m0"
|
| 30 |
+
scale_dtype = torch.float8_e8m0fnu
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@contextmanager
|
| 34 |
+
def set_dtype(dtype):
|
| 35 |
+
"""Temporarily override torch's default dtype, restoring it even if the body raises."""
|
| 36 |
+
prev = torch.get_default_dtype()
|
| 37 |
+
torch.set_default_dtype(dtype)
|
| 38 |
+
try:
|
| 39 |
+
yield
|
| 40 |
+
finally:
|
| 41 |
+
torch.set_default_dtype(prev)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class ModelArgs:
|
| 46 |
+
"""Field names are exactly the config JSON keys. The defaults are a small model that
|
| 47 |
+
`python model.py` can run, not the released shapes -- though the scale-independent
|
| 48 |
+
values (norm_eps, score_func, hc_*, engram_*) do match it."""
|
| 49 |
+
|
| 50 |
+
# runtime limits rather than model shape: they size the KV caches
|
| 51 |
+
max_batch_size: int = 4
|
| 52 |
+
max_seq_len: int = 4096
|
| 53 |
+
temperature: float = 1
|
| 54 |
+
dtype: Literal["bf16", "fp8"] = "fp8"
|
| 55 |
+
expert_dtype: Literal["fp4"] | None = "fp4"
|
| 56 |
+
vocab_size: int = 129280
|
| 57 |
+
dim: int = 1024
|
| 58 |
+
moe_inter_dim: int = 1024
|
| 59 |
+
n_layers: int = 5
|
| 60 |
+
n_mtp_layers: int = 1 # extra draft layers appended after the backbone, indices n_layers..
|
| 61 |
+
n_heads: int = 16
|
| 62 |
+
# moe
|
| 63 |
+
n_routed_experts: int = 8
|
| 64 |
+
n_shared_experts: int = 1
|
| 65 |
+
n_activated_experts: int = 2
|
| 66 |
+
score_func: Literal["softmax", "sigmoid", "sqrtsoftplus"] = "sqrtsoftplus"
|
| 67 |
+
gate_temp: float = 1.0
|
| 68 |
+
norm_topk_prob: bool = True
|
| 69 |
+
route_scale: float = 1.0
|
| 70 |
+
swiglu_limit: float = 0.0
|
| 71 |
+
# attention: latent q/kv projections, plus a LoRA-factorised output projection over o_groups
|
| 72 |
+
q_lora_rank: int = 256
|
| 73 |
+
head_dim: int = 128
|
| 74 |
+
rope_head_dim: int = 32
|
| 75 |
+
norm_eps: float = 1e-20
|
| 76 |
+
o_groups: int = 8
|
| 77 |
+
o_lora_rank: int = 256
|
| 78 |
+
# sparse attention: every layer attends over a sliding window, and may add compressed KV on top
|
| 79 |
+
window_size: int = 128
|
| 80 |
+
# one entry per layer, MTP layers included: 0 = sliding window only, r = KV compressed r-to-1
|
| 81 |
+
compress_ratios: tuple[int, ...] = (0, 2, 2, 1, 1, 0)
|
| 82 |
+
# layers sharing a ratio also share one compressed KV and one indexer, produced by the first
|
| 83 |
+
kv_source_layers: tuple[int, ...] = (1, 3)
|
| 84 |
+
index_source_layers: tuple[int, ...] = (1, 3)
|
| 85 |
+
# rope, with YaRN extrapolation when original_seq_len > 0. Compressed KV rotates at its own
|
| 86 |
+
# theta because one latent stands for compress_ratio tokens, so its positions are further apart.
|
| 87 |
+
compress_rope_theta: float = 40000.0
|
| 88 |
+
original_seq_len: int = 0
|
| 89 |
+
rope_theta: float = 10000.0
|
| 90 |
+
rope_factor: float = 40
|
| 91 |
+
beta_fast: int = 32
|
| 92 |
+
beta_slow: int = 1
|
| 93 |
+
# the indexer: a small extra attention that scores compressed positions, so each query can keep
|
| 94 |
+
# just `index_topk` of them. Names match DeepSeek-V3.2-Exp, where this mechanism first appeared.
|
| 95 |
+
index_n_heads: int = 16
|
| 96 |
+
index_head_dim: int = 64
|
| 97 |
+
index_topk: int = 64
|
| 98 |
+
# candidate pre-filtering: candidate_source_layer < 0 turns it off and the other two are unused
|
| 99 |
+
candidate_source_layer: int = -1
|
| 100 |
+
candidate_topk_blocks: int = 0
|
| 101 |
+
candidate_block_size: int = 0
|
| 102 |
+
# hyper-connections: the residual stream is carried as hc_mult parallel copies
|
| 103 |
+
hc_mult: int = 4
|
| 104 |
+
hc_sinkhorn_iters: int = 20
|
| 105 |
+
hc_eps: float = 1e-6
|
| 106 |
+
# engram: n-gram hash lookups added into the residual stream at a few layers
|
| 107 |
+
engram_layer_ids: tuple[int, ...] = ()
|
| 108 |
+
engram_num_embeddings: tuple[int, ...] = () # unpadded table rows; each rank allocates ceil(rows / world_size)
|
| 109 |
+
engram_max_ngram_size: int = 1
|
| 110 |
+
engram_vocab_size: int = 0 # bucket size each (n-gram size, head) starts searching primes from
|
| 111 |
+
engram_n_heads: int = 0
|
| 112 |
+
engram_head_dim: int = 0
|
| 113 |
+
engram_pad_id: int = 2 # token that fills n-gram slots with no history; matches training
|
| 114 |
+
# size of the compressed tokenizer vocab; every hash multiplier is derived from it
|
| 115 |
+
engram_compressed_vocab_size: int = 0
|
| 116 |
+
# vision (VL); vision_n_layers == 0 disables the vision path
|
| 117 |
+
vision_n_layers: int = 0
|
| 118 |
+
vision_dim: int = 1024
|
| 119 |
+
vision_n_heads: int = 16
|
| 120 |
+
vision_inter_dim: int = 2816
|
| 121 |
+
vision_patch_size: int = 14
|
| 122 |
+
vision_rope_theta: float = 10000.0
|
| 123 |
+
vision_downsample_ratio: int = 3
|
| 124 |
+
vision_max_n_token: int = 1024
|
| 125 |
+
vision_min_pixels: int = 544 * 544
|
| 126 |
+
vision_max_wh_ratio: int | None = None
|
| 127 |
+
# raw id of <|deepseek_image|>; every position of an image span carries this id in input_ids
|
| 128 |
+
image_token_id: int = 129264
|
| 129 |
+
# dspark draft head. Only the forward pass is implemented here -- nothing calls forward_spec,
|
| 130 |
+
# so these are read but the speculative-decoding loop itself is out of scope for this repo.
|
| 131 |
+
dspark_block_size: int = 0
|
| 132 |
+
dspark_noise_token_id: int = 0
|
| 133 |
+
dspark_target_layer_ids: tuple[int, ...] = ()
|
| 134 |
+
dspark_markov_rank: int = 256
|
| 135 |
+
dspark_n_routed_experts: int = 0
|
| 136 |
+
dspark_n_activated_experts: int = 0
|
| 137 |
+
|
| 138 |
+
@property
|
| 139 |
+
def vision_enabled(self) -> bool:
|
| 140 |
+
return self.vision_n_layers > 0
|
| 141 |
+
|
| 142 |
+
def get_moe_config(self, layer_id: int) -> tuple[int, int]:
|
| 143 |
+
"""Return the routed/activated expert counts for a given layer."""
|
| 144 |
+
if layer_id < self.n_layers:
|
| 145 |
+
return self.n_routed_experts, self.n_activated_experts
|
| 146 |
+
return (
|
| 147 |
+
self.dspark_n_routed_experts or self.n_routed_experts,
|
| 148 |
+
self.dspark_n_activated_experts or self.n_activated_experts,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class ParallelEmbedding(nn.Module):
|
| 153 |
+
"""Embedding sharded along the vocab dimension. Each rank holds vocab_size // world_size rows.
|
| 154 |
+
Out-of-range indices are zero-masked before all_reduce to combine partial embeddings."""
|
| 155 |
+
|
| 156 |
+
def __init__(self, vocab_size: int, dim: int):
|
| 157 |
+
super().__init__()
|
| 158 |
+
self.vocab_size = vocab_size
|
| 159 |
+
self.dim = dim
|
| 160 |
+
assert vocab_size % world_size == 0, (
|
| 161 |
+
f"Vocabulary size must be divisible by world size (world_size={world_size})"
|
| 162 |
+
)
|
| 163 |
+
self.part_vocab_size = vocab_size // world_size
|
| 164 |
+
self.vocab_start_idx = rank * self.part_vocab_size
|
| 165 |
+
self.vocab_end_idx = self.vocab_start_idx + self.part_vocab_size
|
| 166 |
+
self.weight = nn.Parameter(torch.empty(self.part_vocab_size, self.dim))
|
| 167 |
+
|
| 168 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 169 |
+
if world_size > 1:
|
| 170 |
+
# ids off this rank read row 0 then get zeroed, so the all_reduce sums one real row
|
| 171 |
+
mask = (x < self.vocab_start_idx) | (x >= self.vocab_end_idx)
|
| 172 |
+
x = x - self.vocab_start_idx
|
| 173 |
+
x[mask] = 0
|
| 174 |
+
y = F.embedding(x, self.weight)
|
| 175 |
+
if world_size > 1:
|
| 176 |
+
y[mask] = 0
|
| 177 |
+
dist.all_reduce(y)
|
| 178 |
+
return y
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def linear(x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor | None = None) -> torch.Tensor:
|
| 182 |
+
"""Pick a GEMM from the weight dtype. Quantized weights need a quantized activation, and both
|
| 183 |
+
fp4 and fp8 weights take an fp8 one -- for fp4 the kernel handles the mixed precision."""
|
| 184 |
+
assert bias is None
|
| 185 |
+
|
| 186 |
+
if weight.dtype == torch.float4_e2m1fn_x2:
|
| 187 |
+
x, s = act_quant(x, fp8_block_size, scale_fmt, scale_dtype)
|
| 188 |
+
return fp4_gemm(
|
| 189 |
+
x,
|
| 190 |
+
s,
|
| 191 |
+
weight,
|
| 192 |
+
weight.scale,
|
| 193 |
+
scale_dtype,
|
| 194 |
+
act_block_size=fp8_block_size,
|
| 195 |
+
)
|
| 196 |
+
elif weight.dtype == torch.float8_e4m3fn:
|
| 197 |
+
x, s = act_quant(x, fp8_block_size, scale_fmt, scale_dtype)
|
| 198 |
+
return fp8_gemm(
|
| 199 |
+
x,
|
| 200 |
+
s,
|
| 201 |
+
weight,
|
| 202 |
+
weight.scale,
|
| 203 |
+
scale_dtype,
|
| 204 |
+
block_size=fp8_block_size,
|
| 205 |
+
)
|
| 206 |
+
else:
|
| 207 |
+
return F.linear(x, weight)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class Linear(nn.Module):
|
| 211 |
+
"""bf16, fp8 or fp4 weights. Quantized ones get a `scale`, also attached to `.weight` so that
|
| 212 |
+
`linear()` can reach it."""
|
| 213 |
+
|
| 214 |
+
def __init__(self, in_features: int, out_features: int, bias: bool = False, dtype=None):
|
| 215 |
+
super().__init__()
|
| 216 |
+
self.in_features = in_features
|
| 217 |
+
self.out_features = out_features
|
| 218 |
+
dtype = dtype or default_dtype
|
| 219 |
+
if dtype == torch.float4_e2m1fn_x2:
|
| 220 |
+
# two values per byte: [out, in] logically, [out, in//2] stored
|
| 221 |
+
self.weight = nn.Parameter(torch.empty(out_features, in_features // 2, dtype=torch.float4_e2m1fn_x2))
|
| 222 |
+
self.weight.scale = self.scale = nn.Parameter(
|
| 223 |
+
torch.empty(out_features, in_features // fp4_block_size, dtype=torch.float8_e8m0fnu)
|
| 224 |
+
)
|
| 225 |
+
elif dtype == torch.float8_e4m3fn:
|
| 226 |
+
self.weight = nn.Parameter(torch.empty(out_features, in_features, dtype=dtype))
|
| 227 |
+
self.weight.scale = self.scale = nn.Parameter(
|
| 228 |
+
torch.empty(
|
| 229 |
+
(out_features + fp8_block_size - 1) // fp8_block_size,
|
| 230 |
+
(in_features + fp8_block_size - 1) // fp8_block_size,
|
| 231 |
+
dtype=torch.float8_e8m0fnu,
|
| 232 |
+
)
|
| 233 |
+
)
|
| 234 |
+
else:
|
| 235 |
+
self.weight = nn.Parameter(torch.empty(out_features, in_features, dtype=dtype))
|
| 236 |
+
self.register_parameter("scale", None)
|
| 237 |
+
if bias:
|
| 238 |
+
self.bias = nn.Parameter(torch.empty(out_features))
|
| 239 |
+
else:
|
| 240 |
+
self.register_parameter("bias", None)
|
| 241 |
+
|
| 242 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 243 |
+
return linear(x, self.weight, self.bias)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class ColumnParallelLinear(Linear):
|
| 247 |
+
"""Splits the output dim across ranks; each rank's slice of the output is already complete."""
|
| 248 |
+
|
| 249 |
+
def __init__(self, in_features: int, out_features: int, bias: bool = False, dtype=None):
|
| 250 |
+
assert out_features % world_size == 0, (
|
| 251 |
+
f"Output features must be divisible by world size (world_size={world_size})"
|
| 252 |
+
)
|
| 253 |
+
self.part_out_features = out_features // world_size
|
| 254 |
+
super().__init__(in_features, self.part_out_features, bias, dtype)
|
| 255 |
+
|
| 256 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 257 |
+
return linear(x, self.weight, self.bias)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class RowParallelLinear(Linear):
|
| 261 |
+
"""Splits the reduction dim, so each rank holds a partial sum: hence the fp32 all_reduce, with
|
| 262 |
+
the bias added only after it."""
|
| 263 |
+
|
| 264 |
+
def __init__(self, in_features: int, out_features: int, bias: bool = False, dtype=None):
|
| 265 |
+
assert in_features % world_size == 0, (
|
| 266 |
+
f"Input features must be divisible by world size (world_size={world_size})"
|
| 267 |
+
)
|
| 268 |
+
self.part_in_features = in_features // world_size
|
| 269 |
+
super().__init__(self.part_in_features, out_features, bias, dtype)
|
| 270 |
+
|
| 271 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 272 |
+
y = linear(x, self.weight, None)
|
| 273 |
+
if world_size > 1:
|
| 274 |
+
y = y.float()
|
| 275 |
+
dist.all_reduce(y)
|
| 276 |
+
if self.bias is not None:
|
| 277 |
+
y += self.bias
|
| 278 |
+
return y.type_as(x)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
class RMSNorm(nn.Module):
|
| 282 |
+
def __init__(self, dim: int, eps: float = 1e-6):
|
| 283 |
+
super().__init__()
|
| 284 |
+
self.dim = dim
|
| 285 |
+
self.eps = eps
|
| 286 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 287 |
+
|
| 288 |
+
def forward(self, x: torch.Tensor):
|
| 289 |
+
dtype = x.dtype
|
| 290 |
+
x = x.float()
|
| 291 |
+
var = x.square().mean(-1, keepdim=True)
|
| 292 |
+
x = x * torch.rsqrt(var + self.eps)
|
| 293 |
+
return (self.weight * x).to(dtype)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
class ParallelEngramEmbedding(nn.Module):
|
| 297 |
+
"""The n-gram hash table, sharded over its rows. Stays fp8: rows are dequantized on lookup."""
|
| 298 |
+
|
| 299 |
+
def __init__(self, num_embeddings: int, dim: int):
|
| 300 |
+
super().__init__()
|
| 301 |
+
self.num_embeddings = num_embeddings
|
| 302 |
+
self.dim = dim
|
| 303 |
+
self.part_num_embeddings = (num_embeddings + world_size - 1) // world_size
|
| 304 |
+
self.vocab_start_idx = rank * self.part_num_embeddings
|
| 305 |
+
self.vocab_end_idx = self.vocab_start_idx + self.part_num_embeddings
|
| 306 |
+
|
| 307 |
+
self.block_size = fp8_block_size
|
| 308 |
+
# the table stays fp8 as stored: rows are dequantized with `scale` on lookup
|
| 309 |
+
self.weight = nn.Parameter(torch.empty(self.part_num_embeddings, dim, dtype=torch.float8_e4m3fn))
|
| 310 |
+
self.scale = nn.Parameter(torch.empty(self.part_num_embeddings, dim // self.block_size, dtype=scale_dtype))
|
| 311 |
+
|
| 312 |
+
def forward(self, indices: torch.Tensor) -> torch.Tensor:
|
| 313 |
+
mask = (indices < self.vocab_start_idx) | (indices >= self.vocab_end_idx)
|
| 314 |
+
local_indices = indices - self.vocab_start_idx
|
| 315 |
+
local_indices = local_indices.masked_fill(mask, 0)
|
| 316 |
+
|
| 317 |
+
values = F.embedding(local_indices, self.weight)
|
| 318 |
+
scales = F.embedding(local_indices, self.scale)
|
| 319 |
+
values = values.float().unflatten(-1, (-1, self.block_size)) * scales.float().unsqueeze(-1)
|
| 320 |
+
values = values.flatten(-2).to(torch.bfloat16)
|
| 321 |
+
values = values.masked_fill(mask.unsqueeze(-1), 0)
|
| 322 |
+
|
| 323 |
+
if world_size > 1:
|
| 324 |
+
dist.all_reduce(values)
|
| 325 |
+
return values
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
class Engram(nn.Module):
|
| 329 |
+
"""Writes an n-gram lookup into the residual stream, gated by how well it matches that stream.
|
| 330 |
+
|
| 331 |
+
The hash ids fetch `n_hash_cols` rows; `wkv` turns them into one key per hc copy plus a shared
|
| 332 |
+
value. The gate is a normalized dot product of stream against key.
|
| 333 |
+
"""
|
| 334 |
+
|
| 335 |
+
def __init__(self, args: ModelArgs, layer_id: int, layout: EngramLayout):
|
| 336 |
+
super().__init__()
|
| 337 |
+
self.layer_id = layer_id
|
| 338 |
+
self.layer_hash_index = layout.layer_ids.index(layer_id)
|
| 339 |
+
self.dim = args.dim
|
| 340 |
+
self.hc_mult = args.hc_mult
|
| 341 |
+
self.clamp_value = 1e-6
|
| 342 |
+
|
| 343 |
+
self.embed = ParallelEngramEmbedding(layout.num_embeddings[self.layer_hash_index], layout.head_dim)
|
| 344 |
+
n_hash_cols = (layout.max_ngram_size - 1) * layout.n_heads
|
| 345 |
+
self.wkv = Linear(n_hash_cols * layout.head_dim, args.dim * (args.hc_mult + 1))
|
| 346 |
+
self.eps = args.norm_eps
|
| 347 |
+
self.q_weight = nn.Parameter(torch.ones(args.hc_mult, args.dim))
|
| 348 |
+
self.k_weight = nn.Parameter(torch.ones(args.hc_mult, args.dim))
|
| 349 |
+
|
| 350 |
+
def forward(self, x: torch.Tensor, hash_ids: torch.Tensor, token_mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 351 |
+
"""x: [B, L, hc_mult, dim]; hash_ids: [B, L, n_hash_cols]; token_mask: [B, L], False shuts
|
| 352 |
+
the gate so those positions pass through untouched."""
|
| 353 |
+
kv = self.wkv(self.embed(hash_ids).flatten(-2))
|
| 354 |
+
key, value = kv.split([self.hc_mult * self.dim, self.dim], dim=-1)
|
| 355 |
+
key = key.float().unflatten(-1, (self.hc_mult, self.dim))
|
| 356 |
+
weight = self.q_weight.float() * self.k_weight.float() # only ever used as a product
|
| 357 |
+
h, eps = x.float(), self.eps
|
| 358 |
+
# normalized per (token, hc copy) over `dim`, NOT jointly over the copies
|
| 359 |
+
rstd = torch.rsqrt(h.square().mean(-1) + eps) * torch.rsqrt(key.square().mean(-1) + eps)
|
| 360 |
+
dot = (h * weight * key).sum(-1) * rstd * self.dim**-0.5
|
| 361 |
+
# signed sqrt before the sigmoid, matching the training kernel
|
| 362 |
+
gate = torch.sigmoid(torch.copysign(dot.abs().clamp_min(self.clamp_value).sqrt(), dot))
|
| 363 |
+
if token_mask is not None:
|
| 364 |
+
gate = gate.masked_fill(~token_mask.unsqueeze(-1), 0)
|
| 365 |
+
return (h + gate.unsqueeze(-1) * value.float().unsqueeze(-2)).to(x.dtype)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
@lru_cache(2)
|
| 369 |
+
def precompute_freqs_cis(dim, seqlen, original_seq_len, base, factor, beta_fast, beta_slow) -> torch.Tensor:
|
| 370 |
+
"""Rotary frequencies as complex exponentials, one row per position.
|
| 371 |
+
|
| 372 |
+
With original_seq_len > 0 this applies YaRN: dimensions whose wavelength already fits inside the
|
| 373 |
+
training context keep their frequency, those far beyond it are divided by `factor`, and the
|
| 374 |
+
`beta_fast`..`beta_slow` band in between is faded across with a linear ramp.
|
| 375 |
+
"""
|
| 376 |
+
freqs = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
|
| 377 |
+
if original_seq_len > 0:
|
| 378 |
+
# the dim whose wavelength completes `rotations` turns over the training context
|
| 379 |
+
def corrected_dim(rotations):
|
| 380 |
+
return dim * math.log(original_seq_len / (rotations * 2 * math.pi)) / (2 * math.log(base))
|
| 381 |
+
|
| 382 |
+
low = max(math.floor(corrected_dim(beta_fast)), 0)
|
| 383 |
+
high = min(math.ceil(corrected_dim(beta_slow)), dim - 1)
|
| 384 |
+
ramp = ((torch.arange(dim // 2, dtype=torch.float32) - low) / max(high - low, 1e-3)).clamp(0, 1)
|
| 385 |
+
smooth = 1 - ramp
|
| 386 |
+
freqs = freqs / factor * (1 - smooth) + freqs * smooth
|
| 387 |
+
|
| 388 |
+
freqs = torch.outer(torch.arange(seqlen), freqs)
|
| 389 |
+
return torch.polar(torch.ones_like(freqs), freqs)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def apply_rotary_emb(x: torch.Tensor, freqs_cis: torch.Tensor, inverse: bool = False) -> torch.Tensor:
|
| 393 |
+
"""Rotate `x` in place, taking adjacent element pairs as complex numbers. Accepts [b, s, d] and
|
| 394 |
+
[b, s, h, d]; `inverse` conjugates the rotation, which is how the attention output gets the
|
| 395 |
+
query's rotation removed again so the cache can stay in one shared rotated form."""
|
| 396 |
+
y = x
|
| 397 |
+
x = torch.view_as_complex(x.float().unflatten(-1, (-1, 2)))
|
| 398 |
+
if inverse:
|
| 399 |
+
freqs_cis = freqs_cis.conj()
|
| 400 |
+
if x.ndim == 3:
|
| 401 |
+
freqs_cis = freqs_cis.view(1, x.size(1), x.size(-1))
|
| 402 |
+
else:
|
| 403 |
+
freqs_cis = freqs_cis.view(1, x.size(1), 1, x.size(-1))
|
| 404 |
+
x = torch.view_as_real(x * freqs_cis).flatten(-2)
|
| 405 |
+
y.copy_(x)
|
| 406 |
+
return y
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
@lru_cache(1)
|
| 410 |
+
def get_window_topk_idxs(window_size: int, bsz: int, seqlen: int, start_pos: int):
|
| 411 |
+
"""Which sliding-window cache slots each query attends to; -1 marks a slot holding nothing.
|
| 412 |
+
|
| 413 |
+
The cache is a ring of `window_size` slots. Prefill needs one row per query, each seeing its own
|
| 414 |
+
causal window. A decode step has a single query that sees the whole ring, listed oldest first.
|
| 415 |
+
Order within a row does not matter to `sparse_attn`, which handles every slot independently.
|
| 416 |
+
"""
|
| 417 |
+
if start_pos == 0:
|
| 418 |
+
end = torch.arange(seqlen).unsqueeze(1)
|
| 419 |
+
idxs = (end - window_size + 1).clamp(0) + torch.arange(min(seqlen, window_size))
|
| 420 |
+
idxs = torch.where(idxs > end, -1, idxs) # before the sequence started
|
| 421 |
+
else:
|
| 422 |
+
oldest = start_pos % window_size + 1
|
| 423 |
+
idxs = torch.cat([torch.arange(oldest, window_size), torch.arange(oldest)])
|
| 424 |
+
idxs = torch.where(idxs > start_pos, -1, idxs) # ring still filling
|
| 425 |
+
# sparse_attn needs real [b, m, topk] int32 memory, hence the materializing expand
|
| 426 |
+
return idxs.int().unsqueeze(0).expand(bsz, -1, -1).contiguous()
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
class Compressor(nn.Module):
|
| 430 |
+
"""Pools `compress_ratio` consecutive tokens into one KV latent with a learned softmax gate.
|
| 431 |
+
|
| 432 |
+
Returns the latent before RoPE, or None while a group is still filling up -- so during decode it
|
| 433 |
+
only yields every `compress_ratio` steps, holding the partial group in `kv_state`/`score_state`.
|
| 434 |
+
Pre-RoPE is deliberate: the indexer needs the unrotated form, so Attention rotates afterwards.
|
| 435 |
+
"""
|
| 436 |
+
|
| 437 |
+
def __init__(self, args: ModelArgs, layer_id: int):
|
| 438 |
+
super().__init__()
|
| 439 |
+
compress_ratio = args.compress_ratios[layer_id]
|
| 440 |
+
head_dim = args.head_dim
|
| 441 |
+
self.compress_ratio = compress_ratio
|
| 442 |
+
self.head_dim = head_dim
|
| 443 |
+
self.norm = RMSNorm(head_dim, args.norm_eps)
|
| 444 |
+
# ratio 1 is a plain projection, so it stays in the checkpoint's bf16; the softmax pooling
|
| 445 |
+
# above ratio 1 runs in fp32, so those weights are promoted to fp32 to match
|
| 446 |
+
self.wkv = Linear(args.dim, head_dim, dtype=torch.float32 if compress_ratio > 1 else torch.bfloat16)
|
| 447 |
+
if compress_ratio > 1:
|
| 448 |
+
self.wgate = Linear(args.dim, head_dim, dtype=torch.float32)
|
| 449 |
+
# tail of an incomplete group, carried across decode steps
|
| 450 |
+
self.kv_state: torch.Tensor
|
| 451 |
+
self.score_state: torch.Tensor
|
| 452 |
+
state_shape = (args.max_batch_size, compress_ratio, head_dim)
|
| 453 |
+
self.register_buffer("kv_state", torch.zeros(state_shape, dtype=torch.float32), persistent=False)
|
| 454 |
+
self.register_buffer(
|
| 455 |
+
"score_state", torch.full(state_shape, -torch.inf, dtype=torch.float32), persistent=False
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
def forward(self, x: torch.Tensor, start_pos: int) -> torch.Tensor | None:
|
| 459 |
+
bsz, seqlen, _ = x.size()
|
| 460 |
+
ratio, dtype = self.compress_ratio, x.dtype
|
| 461 |
+
if ratio == 1: # one token per group: nothing to pool, so no gate and no fp32
|
| 462 |
+
return self.norm(self.wkv(x))
|
| 463 |
+
|
| 464 |
+
x = x.float()
|
| 465 |
+
kv, score = self.wkv(x), self.wgate(x)
|
| 466 |
+
if start_pos == 0:
|
| 467 |
+
should_compress = seqlen >= ratio
|
| 468 |
+
remainder = seqlen % ratio
|
| 469 |
+
cutoff = seqlen - remainder
|
| 470 |
+
if remainder: # trailing partial group waits in the state
|
| 471 |
+
kv, self.kv_state[:bsz, :remainder] = kv.split([cutoff, remainder], dim=1)
|
| 472 |
+
score, self.score_state[:bsz, :remainder] = score.split([cutoff, remainder], dim=1)
|
| 473 |
+
kv = kv.unflatten(1, (-1, ratio))
|
| 474 |
+
score = score.unflatten(1, (-1, ratio))
|
| 475 |
+
kv = (kv * score.softmax(dim=2)).sum(dim=2)
|
| 476 |
+
else: # one token per step: fill a slot, and pool only when the group just completed
|
| 477 |
+
should_compress = (start_pos + 1) % ratio == 0
|
| 478 |
+
slot = start_pos % ratio
|
| 479 |
+
self.kv_state[:bsz, slot] = kv.squeeze(1)
|
| 480 |
+
self.score_state[:bsz, slot] = score.squeeze(1)
|
| 481 |
+
if should_compress:
|
| 482 |
+
kv = (self.kv_state[:bsz] * self.score_state[:bsz].softmax(dim=1)).sum(dim=1, keepdim=True)
|
| 483 |
+
if not should_compress:
|
| 484 |
+
return None
|
| 485 |
+
return self.norm(kv.to(dtype))
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
class Indexer(torch.nn.Module):
|
| 489 |
+
"""Keeps the `index_topk` best compressed positions per query.
|
| 490 |
+
|
| 491 |
+
A small side attention: fp4 query heads against one shared key per compressed position, scores
|
| 492 |
+
rectified then combined by `weights_proj`. With a candidate source this is the second of two
|
| 493 |
+
levels; `select_candidate_blocks` is the first.
|
| 494 |
+
"""
|
| 495 |
+
|
| 496 |
+
def __init__(self, args: ModelArgs, layer_id: int):
|
| 497 |
+
super().__init__()
|
| 498 |
+
# the index keys are derived from the compressor's latent, so only a layer that compresses
|
| 499 |
+
# its own KV can produce them; every other indexer reads them from that layer's cache
|
| 500 |
+
self.owns_k = layer_id in args.kv_source_layers
|
| 501 |
+
self.compress_ratio = args.compress_ratios[layer_id]
|
| 502 |
+
self.is_candidate_source = layer_id == args.candidate_source_layer
|
| 503 |
+
self.uses_candidates = 0 <= args.candidate_source_layer < layer_id
|
| 504 |
+
self.candidate_topk_blocks = args.candidate_topk_blocks
|
| 505 |
+
self.candidate_block_size = args.candidate_block_size
|
| 506 |
+
self.dim = args.dim
|
| 507 |
+
self.n_heads = args.index_n_heads
|
| 508 |
+
self.n_local_heads = args.index_n_heads // world_size
|
| 509 |
+
self.index_head_dim = args.index_head_dim
|
| 510 |
+
self.rope_head_dim = args.rope_head_dim
|
| 511 |
+
self.index_topk = args.index_topk
|
| 512 |
+
self.q_lora_rank = args.q_lora_rank
|
| 513 |
+
self.softmax_scale = self.index_head_dim**-0.5
|
| 514 |
+
self.wq_b = ColumnParallelLinear(self.q_lora_rank, self.n_heads * self.index_head_dim)
|
| 515 |
+
self.weights_proj = ColumnParallelLinear(self.dim, self.n_heads, dtype=torch.bfloat16)
|
| 516 |
+
self.freqs_cis: torch.Tensor | None = None
|
| 517 |
+
if self.owns_k:
|
| 518 |
+
self.wk = Linear(args.head_dim, self.index_head_dim, dtype=torch.bfloat16)
|
| 519 |
+
self.k_norm = RMSNorm(self.index_head_dim, args.norm_eps)
|
| 520 |
+
self.k_cache: torch.Tensor
|
| 521 |
+
self.register_buffer(
|
| 522 |
+
"k_cache",
|
| 523 |
+
torch.zeros(args.max_batch_size, args.max_seq_len // self.compress_ratio, args.index_head_dim),
|
| 524 |
+
persistent=False,
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
def forward(self, x: torch.Tensor, qr: torch.Tensor, latent: torch.Tensor, start_pos: int, offset: int):
|
| 528 |
+
"""`latent` is this layer's RoPE-free compressed latent, None when this layer does not
|
| 529 |
+
compress or when its current group is still incomplete. An index-key owner turns it into
|
| 530 |
+
index keys here, which has to happen before Attention overwrites that same storage with
|
| 531 |
+
the RoPE'd, quantized values."""
|
| 532 |
+
assert self.freqs_cis is not None
|
| 533 |
+
bsz, seqlen, _ = x.size()
|
| 534 |
+
ratio, rd, end_pos = self.compress_ratio, self.rope_head_dim, start_pos + seqlen
|
| 535 |
+
|
| 536 |
+
# latent is None while a group is still filling up, so there is nothing to publish yet
|
| 537 |
+
if self.owns_k and latent is not None:
|
| 538 |
+
# a latent stands for the first token of its group, so group j takes position j * ratio
|
| 539 |
+
freqs = (
|
| 540 |
+
self.freqs_cis[: seqlen - seqlen % ratio : ratio]
|
| 541 |
+
if start_pos == 0
|
| 542 |
+
else self.freqs_cis[start_pos + 1 - ratio].unsqueeze(0)
|
| 543 |
+
)
|
| 544 |
+
k = self.k_norm(self.wk(latent))
|
| 545 |
+
apply_rotary_emb(k[..., -rd:], freqs)
|
| 546 |
+
fp4_act_quant(k, fp4_block_size, True)
|
| 547 |
+
self.k_cache[:bsz, start_pos // ratio : start_pos // ratio + k.size(1)] = k
|
| 548 |
+
shared_attn.index_k = self.k_cache
|
| 549 |
+
|
| 550 |
+
q = self.wq_b(qr).unflatten(-1, (self.n_local_heads, self.index_head_dim))
|
| 551 |
+
apply_rotary_emb(q[..., -rd:], self.freqs_cis[start_pos:end_pos])
|
| 552 |
+
fp4_act_quant(q, fp4_block_size, True)
|
| 553 |
+
|
| 554 |
+
index_k = shared_attn.index_k[:bsz, : end_pos // ratio]
|
| 555 |
+
weights = self.weights_proj(x) * (self.softmax_scale * self.n_heads**-0.5)
|
| 556 |
+
index_score = torch.einsum("bshd,btd->bsht", q, index_k)
|
| 557 |
+
index_score = (index_score.relu_() * weights.unsqueeze(-1)).sum(dim=2)
|
| 558 |
+
if world_size > 1:
|
| 559 |
+
dist.all_reduce(index_score)
|
| 560 |
+
|
| 561 |
+
# how many compressed positions each query can see: a block becomes visible once the query
|
| 562 |
+
# has passed its last token. One query per decode step, so there it is just a number.
|
| 563 |
+
if start_pos == 0:
|
| 564 |
+
compress_lens = (torch.arange(1, seqlen + 1, device=x.device) // ratio).unsqueeze(-1)
|
| 565 |
+
index_score.masked_fill_(torch.arange(seqlen // ratio, device=x.device) >= compress_lens, -torch.inf)
|
| 566 |
+
else:
|
| 567 |
+
compress_lens = end_pos // ratio
|
| 568 |
+
|
| 569 |
+
if self.is_candidate_source:
|
| 570 |
+
shared_attn.candidates = select_candidate_blocks(
|
| 571 |
+
index_score, compress_lens, self.candidate_topk_blocks, self.candidate_block_size
|
| 572 |
+
)
|
| 573 |
+
elif self.uses_candidates:
|
| 574 |
+
# level two: score with our own weights, but only inside the source's candidate blocks
|
| 575 |
+
index_score = index_score.masked_fill(~shared_attn.candidates, -torch.inf)
|
| 576 |
+
|
| 577 |
+
# top-k by score, re-sorted into position order; unreachable -> -1, rest shifted by offset
|
| 578 |
+
topk = min(self.index_topk, end_pos // ratio)
|
| 579 |
+
idxs = index_score.topk(topk, dim=-1, sorted=False).indices.sort(dim=-1).values
|
| 580 |
+
return torch.where(idxs < compress_lens, idxs + offset, -1).int()
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def select_candidate_blocks(
|
| 584 |
+
logits: torch.Tensor,
|
| 585 |
+
compress_lens: torch.Tensor | int,
|
| 586 |
+
topk_blocks: int,
|
| 587 |
+
block_size: int,
|
| 588 |
+
) -> torch.Tensor:
|
| 589 |
+
"""Level one of the two-level top-k: keep the `topk_blocks` highest-scoring blocks per query.
|
| 590 |
+
|
| 591 |
+
`logits` is [..., n_positions] with positions the query cannot reach already at -inf, which is
|
| 592 |
+
what makes a block score of -inf mean "not reachable yet". `compress_lens` is a plain int during
|
| 593 |
+
decode, or broadcasts against logits' leading dims during prefill. Returns a bool mask shaped
|
| 594 |
+
like `logits`, so the layers consuming it just mask and never think about blocks again.
|
| 595 |
+
"""
|
| 596 |
+
width = logits.size(-1)
|
| 597 |
+
# score each block by its best position; -inf pads the last one out to block_size
|
| 598 |
+
scores = F.pad(logits, (0, -width % block_size), value=-torch.inf)
|
| 599 |
+
scores = scores.unflatten(-1, (-1, block_size)).amax(dim=-1)
|
| 600 |
+
num_blocks = scores.size(-1)
|
| 601 |
+
|
| 602 |
+
# the block with this query's newest position is only partly filled, so pin it in: it holds the
|
| 603 |
+
# most recent tokens but could otherwise be outscored by an older, full block
|
| 604 |
+
last = (compress_lens - 1) // block_size
|
| 605 |
+
scores = scores.masked_fill(torch.arange(num_blocks, device=logits.device) == last, torch.inf)
|
| 606 |
+
|
| 607 |
+
top = scores.topk(min(topk_blocks, num_blocks), dim=-1)
|
| 608 |
+
# fewer reachable blocks than topk_blocks means leftover picks came back -inf: drop them
|
| 609 |
+
keep = torch.zeros_like(scores, dtype=torch.bool).scatter_(-1, top.indices, top.values > -torch.inf)
|
| 610 |
+
return keep.repeat_interleave(block_size, dim=-1)[..., :width]
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
class Attention(nn.Module):
|
| 614 |
+
"""Latent attention over two KV sources at once, concatenated into one `sparse_attn` call: a
|
| 615 |
+
sliding window of raw KV, plus -- when compress_ratio > 0 -- `index_topk` compressed positions
|
| 616 |
+
reaching further back. Q and the output projection are both low-rank, the latter grouped.
|
| 617 |
+
|
| 618 |
+
compress_ratio > 0 does not mean the layer compresses its own KV: only kv_source_layers do,
|
| 619 |
+
the rest read that same cache.
|
| 620 |
+
"""
|
| 621 |
+
|
| 622 |
+
def __init__(self, layer_id: int, args: ModelArgs):
|
| 623 |
+
super().__init__()
|
| 624 |
+
self.layer_id = layer_id
|
| 625 |
+
self.dim = args.dim
|
| 626 |
+
self.n_heads = args.n_heads
|
| 627 |
+
self.n_local_heads = args.n_heads // world_size
|
| 628 |
+
self.q_lora_rank = args.q_lora_rank
|
| 629 |
+
self.o_lora_rank = args.o_lora_rank
|
| 630 |
+
self.head_dim = args.head_dim
|
| 631 |
+
self.rope_head_dim = args.rope_head_dim
|
| 632 |
+
self.nope_head_dim = args.head_dim - args.rope_head_dim
|
| 633 |
+
self.n_groups = args.o_groups
|
| 634 |
+
self.n_local_groups = self.n_groups // world_size
|
| 635 |
+
self.window_size = args.window_size
|
| 636 |
+
self.compress_ratio = args.compress_ratios[layer_id]
|
| 637 |
+
self.eps = args.norm_eps
|
| 638 |
+
|
| 639 |
+
self.attn_sink = nn.Parameter(torch.empty(self.n_local_heads, dtype=torch.float32))
|
| 640 |
+
self.wq_a = Linear(self.dim, self.q_lora_rank)
|
| 641 |
+
self.q_norm = RMSNorm(self.q_lora_rank, self.eps)
|
| 642 |
+
self.wq_b = ColumnParallelLinear(self.q_lora_rank, self.n_heads * self.head_dim)
|
| 643 |
+
self.wkv = Linear(self.dim, self.head_dim)
|
| 644 |
+
self.kv_norm = RMSNorm(self.head_dim, self.eps)
|
| 645 |
+
self.wo_a = ColumnParallelLinear(
|
| 646 |
+
self.n_heads * self.head_dim // self.n_groups,
|
| 647 |
+
self.n_groups * args.o_lora_rank,
|
| 648 |
+
dtype=torch.bfloat16,
|
| 649 |
+
)
|
| 650 |
+
self.wo_b = RowParallelLinear(self.n_groups * args.o_lora_rank, self.dim)
|
| 651 |
+
self.softmax_scale = self.head_dim**-0.5
|
| 652 |
+
|
| 653 |
+
is_backbone = layer_id < args.n_layers
|
| 654 |
+
self.is_kv_source = is_backbone and layer_id in args.kv_source_layers
|
| 655 |
+
self.is_index_source = is_backbone and layer_id in args.index_source_layers
|
| 656 |
+
self.compressor: Compressor | None = None
|
| 657 |
+
self.indexer: Indexer | None = None
|
| 658 |
+
if self.is_kv_source:
|
| 659 |
+
self.compressor = Compressor(args, layer_id)
|
| 660 |
+
if self.is_index_source:
|
| 661 |
+
self.indexer = Indexer(args, layer_id)
|
| 662 |
+
|
| 663 |
+
self.window_kv_cache: torch.Tensor
|
| 664 |
+
self.register_buffer(
|
| 665 |
+
"window_kv_cache",
|
| 666 |
+
torch.zeros(args.max_batch_size, args.window_size, self.head_dim),
|
| 667 |
+
persistent=False,
|
| 668 |
+
)
|
| 669 |
+
if self.is_kv_source:
|
| 670 |
+
self.compress_kv_cache: torch.Tensor
|
| 671 |
+
self.register_buffer(
|
| 672 |
+
"compress_kv_cache",
|
| 673 |
+
torch.zeros(
|
| 674 |
+
args.max_batch_size,
|
| 675 |
+
args.max_seq_len // self.compress_ratio,
|
| 676 |
+
self.head_dim,
|
| 677 |
+
),
|
| 678 |
+
persistent=False,
|
| 679 |
+
)
|
| 680 |
+
if self.compress_ratio:
|
| 681 |
+
original_seq_len, rope_theta = (
|
| 682 |
+
args.original_seq_len,
|
| 683 |
+
args.compress_rope_theta,
|
| 684 |
+
)
|
| 685 |
+
else:
|
| 686 |
+
# disable YaRN and use base rope_theta in pure sliding-window attention
|
| 687 |
+
original_seq_len, rope_theta = 0, args.rope_theta
|
| 688 |
+
freqs_cis = precompute_freqs_cis(
|
| 689 |
+
self.rope_head_dim,
|
| 690 |
+
args.max_seq_len,
|
| 691 |
+
original_seq_len,
|
| 692 |
+
rope_theta,
|
| 693 |
+
args.rope_factor,
|
| 694 |
+
args.beta_fast,
|
| 695 |
+
args.beta_slow,
|
| 696 |
+
)
|
| 697 |
+
self.freqs_cis: torch.Tensor
|
| 698 |
+
self.register_buffer("freqs_cis", freqs_cis, persistent=False)
|
| 699 |
+
|
| 700 |
+
def _window_kv(self, x, freqs_cis, start_pos):
|
| 701 |
+
"""This layer's sliding-window K and the window positions every query may attend to. The K
|
| 702 |
+
stays fp8, quantized over the whole post-RoPE vector, RoPE tail included."""
|
| 703 |
+
bsz, seqlen, _ = x.size()
|
| 704 |
+
win = self.window_size
|
| 705 |
+
kv = self.kv_norm(self.wkv(x))
|
| 706 |
+
apply_rotary_emb(kv[..., -self.rope_head_dim :], freqs_cis)
|
| 707 |
+
act_quant(kv, fp8_block_size, scale_fmt, scale_dtype, True)
|
| 708 |
+
if start_pos == 0: # prefill: attend over this chunk, seeding the ring buffer for decode
|
| 709 |
+
if seqlen <= win:
|
| 710 |
+
self.window_kv_cache[:bsz, :seqlen] = kv
|
| 711 |
+
else:
|
| 712 |
+
cutoff = seqlen % win
|
| 713 |
+
self.window_kv_cache[:bsz, cutoff:win], self.window_kv_cache[:bsz, :cutoff] = kv[:, -win:].split(
|
| 714 |
+
[win - cutoff, cutoff], dim=1
|
| 715 |
+
)
|
| 716 |
+
window_kv = kv
|
| 717 |
+
else: # decode: one token into the ring buffer, attend over the whole window
|
| 718 |
+
self.window_kv_cache[:bsz, start_pos % win] = kv.squeeze(1)
|
| 719 |
+
window_kv = self.window_kv_cache[:bsz]
|
| 720 |
+
return window_kv, get_window_topk_idxs(win, bsz, seqlen, start_pos)
|
| 721 |
+
|
| 722 |
+
def _compress_topk_idxs(self, x, qr, latent, start_pos, offset, compress_len):
|
| 723 |
+
"""Which compressed positions each query attends to. Index sources run their own indexer;
|
| 724 |
+
the layers in between reuse the result their source published."""
|
| 725 |
+
if not self.is_index_source:
|
| 726 |
+
return shared_attn.topk_idxs
|
| 727 |
+
|
| 728 |
+
bsz, seqlen, _ = x.size()
|
| 729 |
+
if compress_len == 0:
|
| 730 |
+
idxs = torch.empty(bsz, seqlen, 0, dtype=torch.int32, device=x.device)
|
| 731 |
+
else:
|
| 732 |
+
assert self.indexer is not None
|
| 733 |
+
if self.indexer.freqs_cis is None:
|
| 734 |
+
self.indexer.freqs_cis = self.freqs_cis
|
| 735 |
+
idxs = self.indexer(x, qr, latent, start_pos, offset)
|
| 736 |
+
shared_attn.topk_idxs = idxs
|
| 737 |
+
return idxs
|
| 738 |
+
|
| 739 |
+
def _compress_kv(self, x, qr, start_pos, offset):
|
| 740 |
+
"""The shared compressed KV and the compressed positions every query may attend to. This
|
| 741 |
+
layer compresses its own KV only when it is a source; otherwise it just reads the cache."""
|
| 742 |
+
bsz, seqlen, _ = x.size()
|
| 743 |
+
ratio = self.compress_ratio
|
| 744 |
+
compress_len = (start_pos + seqlen) // ratio
|
| 745 |
+
latent = None
|
| 746 |
+
if self.is_kv_source:
|
| 747 |
+
latent = self.compressor(x, start_pos)
|
| 748 |
+
shared_attn.compress_kv = self.compress_kv_cache
|
| 749 |
+
# the indexer needs the latent before RoPE, so it runs before the cache is written
|
| 750 |
+
idxs = self._compress_topk_idxs(x, qr, latent, start_pos, offset, compress_len)
|
| 751 |
+
if latent is not None:
|
| 752 |
+
# a latent stands for the first token of its group, so group j takes position j * ratio
|
| 753 |
+
freqs = (
|
| 754 |
+
self.freqs_cis[: seqlen - seqlen % ratio : ratio]
|
| 755 |
+
if start_pos == 0
|
| 756 |
+
else self.freqs_cis[start_pos + 1 - ratio].unsqueeze(0)
|
| 757 |
+
)
|
| 758 |
+
apply_rotary_emb(latent[..., -self.rope_head_dim :], freqs)
|
| 759 |
+
# Compressed KV uses groups of 16 with E4M3 scales; the indexer uses 32 with E8M0.
|
| 760 |
+
fp4_act_quant(latent, 16, True, scale_dtype=torch.float8_e4m3fn)
|
| 761 |
+
self.compress_kv_cache[:bsz, start_pos // ratio : start_pos // ratio + latent.size(1)] = latent
|
| 762 |
+
# read after the write, so this does not depend on the slice aliasing the cache
|
| 763 |
+
return shared_attn.compress_kv[:bsz, :compress_len], idxs
|
| 764 |
+
|
| 765 |
+
def forward(self, x: torch.Tensor, start_pos: int):
|
| 766 |
+
bsz, seqlen, _ = x.size()
|
| 767 |
+
freqs_cis = self.freqs_cis[start_pos : start_pos + seqlen]
|
| 768 |
+
rd = self.rope_head_dim
|
| 769 |
+
|
| 770 |
+
qr = self.q_norm(self.wq_a(x))
|
| 771 |
+
q = self.wq_b(qr).unflatten(-1, (self.n_local_heads, self.head_dim))
|
| 772 |
+
apply_rotary_emb(q[..., -rd:], freqs_cis)
|
| 773 |
+
|
| 774 |
+
kv, topk_idxs = self._window_kv(x, freqs_cis, start_pos)
|
| 775 |
+
if self.compress_ratio:
|
| 776 |
+
compress_kv, compress_idxs = self._compress_kv(x, qr, start_pos, kv.size(1))
|
| 777 |
+
kv = torch.cat([kv, compress_kv], dim=1)
|
| 778 |
+
topk_idxs = torch.cat([topk_idxs, compress_idxs], dim=-1)
|
| 779 |
+
|
| 780 |
+
o = sparse_attn(q, kv, self.attn_sink, topk_idxs, self.softmax_scale)
|
| 781 |
+
apply_rotary_emb(o[..., -rd:], freqs_cis, True)
|
| 782 |
+
|
| 783 |
+
# wo_a is block-diagonal over groups (each projects only its own heads), hence einsum not
|
| 784 |
+
# Linear. convert.py dequantizes it to bf16; an fp8 grouped GEMM would halve the memory.
|
| 785 |
+
o = o.view(bsz, seqlen, self.n_local_groups, -1)
|
| 786 |
+
wo_a = self.wo_a.weight.view(self.n_local_groups, self.o_lora_rank, -1)
|
| 787 |
+
o = torch.einsum("bsgd,grd->bsgr", o, wo_a)
|
| 788 |
+
x = self.wo_b(o.flatten(2))
|
| 789 |
+
return x
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
class Gate(nn.Module):
|
| 793 |
+
"""MoE gating. The correction bias steers expert selection only; the routing weights come from the
|
| 794 |
+
unbiased scores. Image-span tokens use a separate bias (training `noaux_tc_for_vl`)."""
|
| 795 |
+
|
| 796 |
+
def __init__(self, layer_id: int, args: ModelArgs):
|
| 797 |
+
super().__init__()
|
| 798 |
+
n_routed_experts, n_activated_experts = args.get_moe_config(layer_id)
|
| 799 |
+
self.dim = args.dim
|
| 800 |
+
self.topk = n_activated_experts
|
| 801 |
+
self.score_func = args.score_func
|
| 802 |
+
self.gate_temp = args.gate_temp
|
| 803 |
+
self.norm_topk_prob = args.norm_topk_prob
|
| 804 |
+
self.route_scale = args.route_scale
|
| 805 |
+
self.weight = nn.Parameter(torch.empty(n_routed_experts, args.dim))
|
| 806 |
+
self.bias = nn.Parameter(torch.empty(n_routed_experts, dtype=torch.float32))
|
| 807 |
+
self.bias_vl = nn.Parameter(torch.empty(n_routed_experts, dtype=torch.float32)) if args.vision_enabled else None
|
| 808 |
+
|
| 809 |
+
def forward(self, x: torch.Tensor, image_mask: torch.Tensor | None = None) -> tuple[torch.Tensor, torch.Tensor]:
|
| 810 |
+
"""x: [n, dim]; image_mask: [n] bool, True for tokens inside an image span."""
|
| 811 |
+
scores = linear(x.float(), self.weight.float()) / self.gate_temp
|
| 812 |
+
if self.score_func == "softmax":
|
| 813 |
+
scores = scores.softmax(dim=-1)
|
| 814 |
+
elif self.score_func == "sigmoid":
|
| 815 |
+
scores = scores.sigmoid()
|
| 816 |
+
else:
|
| 817 |
+
scores = F.softplus(scores).sqrt()
|
| 818 |
+
bias = self.bias
|
| 819 |
+
if image_mask is not None and self.bias_vl is not None:
|
| 820 |
+
bias = torch.where(image_mask.unsqueeze(-1), self.bias_vl, bias)
|
| 821 |
+
# the bias picks experts but does not scale them: weights come from the raw scores
|
| 822 |
+
indices = (scores + bias).topk(self.topk, dim=-1)[1]
|
| 823 |
+
weights = scores.gather(1, indices)
|
| 824 |
+
if self.norm_topk_prob and self.topk > 1:
|
| 825 |
+
weights /= weights.sum(dim=-1, keepdim=True) + 1e-20 # not norm_eps, matches training
|
| 826 |
+
weights *= self.route_scale
|
| 827 |
+
return weights, indices
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
class Expert(nn.Module):
|
| 831 |
+
"""One SwiGLU FFN. The clamps come straight from training, where they keep fp8/fp4 activations in
|
| 832 |
+
range: the up branch is clamped on both sides, the gate branch only from above."""
|
| 833 |
+
|
| 834 |
+
def __init__(self, dim: int, inter_dim: int, dtype=None, swiglu_limit=0.0):
|
| 835 |
+
super().__init__()
|
| 836 |
+
self.w1 = Linear(dim, inter_dim, dtype=dtype)
|
| 837 |
+
self.w2 = Linear(inter_dim, dim, dtype=dtype)
|
| 838 |
+
self.w3 = Linear(dim, inter_dim, dtype=dtype)
|
| 839 |
+
self.swiglu_limit = swiglu_limit
|
| 840 |
+
|
| 841 |
+
def forward(self, x: torch.Tensor, weights: torch.Tensor | None = None) -> torch.Tensor:
|
| 842 |
+
dtype = x.dtype
|
| 843 |
+
gate = self.w1(x).float()
|
| 844 |
+
up = self.w3(x).float()
|
| 845 |
+
if self.swiglu_limit > 0:
|
| 846 |
+
up = torch.clamp(up, min=-self.swiglu_limit, max=self.swiglu_limit)
|
| 847 |
+
gate = torch.clamp(gate, max=self.swiglu_limit)
|
| 848 |
+
x = F.silu(gate) * up
|
| 849 |
+
if weights is not None:
|
| 850 |
+
x = weights * x
|
| 851 |
+
return self.w2(x.to(dtype))
|
| 852 |
+
|
| 853 |
+
|
| 854 |
+
class MoE(nn.Module):
|
| 855 |
+
"""Top-k routed experts plus one shared expert every token goes through. Experts are split
|
| 856 |
+
across ranks, so `self.experts` is None for those another rank owns."""
|
| 857 |
+
|
| 858 |
+
def __init__(self, layer_id: int, args: ModelArgs):
|
| 859 |
+
super().__init__()
|
| 860 |
+
n_routed_experts, n_activated_experts = args.get_moe_config(layer_id)
|
| 861 |
+
self.layer_id = layer_id
|
| 862 |
+
self.dim = args.dim
|
| 863 |
+
assert n_routed_experts % world_size == 0, (
|
| 864 |
+
f"Number of experts must be divisible by world size (world_size={world_size})"
|
| 865 |
+
)
|
| 866 |
+
self.n_routed_experts = n_routed_experts
|
| 867 |
+
self.n_local_experts = n_routed_experts // world_size
|
| 868 |
+
self.n_activated_experts = n_activated_experts
|
| 869 |
+
self.experts_start_idx = rank * self.n_local_experts
|
| 870 |
+
self.experts_end_idx = self.experts_start_idx + self.n_local_experts
|
| 871 |
+
self.gate = Gate(layer_id, args)
|
| 872 |
+
expert_dtype = torch.float4_e2m1fn_x2 if args.expert_dtype == "fp4" else None
|
| 873 |
+
self.experts = nn.ModuleList(
|
| 874 |
+
[
|
| 875 |
+
Expert(
|
| 876 |
+
args.dim,
|
| 877 |
+
args.moe_inter_dim,
|
| 878 |
+
dtype=expert_dtype,
|
| 879 |
+
swiglu_limit=args.swiglu_limit,
|
| 880 |
+
)
|
| 881 |
+
if self.experts_start_idx <= i < self.experts_end_idx
|
| 882 |
+
else None
|
| 883 |
+
for i in range(self.n_routed_experts)
|
| 884 |
+
]
|
| 885 |
+
)
|
| 886 |
+
assert args.n_shared_experts == 1
|
| 887 |
+
self.shared_experts = Expert(args.dim, args.moe_inter_dim, swiglu_limit=args.swiglu_limit)
|
| 888 |
+
|
| 889 |
+
def forward(self, x: torch.Tensor, image_mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 890 |
+
shape = x.size()
|
| 891 |
+
x = x.view(-1, self.dim)
|
| 892 |
+
weights, indices = self.gate(x, None if image_mask is None else image_mask.flatten())
|
| 893 |
+
y = torch.zeros_like(x, dtype=torch.float32)
|
| 894 |
+
counts = torch.bincount(indices.flatten(), minlength=self.n_routed_experts).tolist()
|
| 895 |
+
for i in range(self.experts_start_idx, self.experts_end_idx):
|
| 896 |
+
if counts[i] == 0:
|
| 897 |
+
continue
|
| 898 |
+
expert = self.experts[i]
|
| 899 |
+
idx, top = torch.where(indices == i)
|
| 900 |
+
y[idx] += expert(x[idx], weights[idx, top, None])
|
| 901 |
+
if world_size > 1:
|
| 902 |
+
dist.all_reduce(y)
|
| 903 |
+
y += self.shared_experts(x)
|
| 904 |
+
return y.type_as(x).view(shape)
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
class Block(nn.Module):
|
| 908 |
+
"""A block whose residual stream is `hc_mult` parallel copies (Hyper-Connections).
|
| 909 |
+
|
| 910 |
+
Attention and FFN each sit between `hc_pre` (collapse the copies into one sublayer input) and
|
| 911 |
+
`hc_post` (expand back out, mixing the residual in through `comb`). `hc_mixes` derives all three
|
| 912 |
+
coefficient sets from the stream itself, `comb` made doubly stochastic by Sinkhorn.
|
| 913 |
+
|
| 914 |
+
The coefficients a sublayer computes are used by the *next* one -- see `forward`.
|
| 915 |
+
"""
|
| 916 |
+
|
| 917 |
+
attention_cls = Attention
|
| 918 |
+
|
| 919 |
+
def __init__(
|
| 920 |
+
self,
|
| 921 |
+
layer_id: int,
|
| 922 |
+
args: ModelArgs,
|
| 923 |
+
engram_layout: EngramLayout | None = None,
|
| 924 |
+
):
|
| 925 |
+
super().__init__()
|
| 926 |
+
self.layer_id = layer_id
|
| 927 |
+
self.norm_eps = args.norm_eps
|
| 928 |
+
self.attn = self.attention_cls(layer_id, args)
|
| 929 |
+
self.ffn = MoE(layer_id, args)
|
| 930 |
+
self.engram = None
|
| 931 |
+
if engram_layout is not None and layer_id in engram_layout.layer_ids:
|
| 932 |
+
self.engram = Engram(args, layer_id, engram_layout)
|
| 933 |
+
self.attn_norm = RMSNorm(args.dim, self.norm_eps)
|
| 934 |
+
self.ffn_norm = RMSNorm(args.dim, self.norm_eps)
|
| 935 |
+
self.hc_mult = hc_mult = args.hc_mult
|
| 936 |
+
self.hc_sinkhorn_iters = args.hc_sinkhorn_iters
|
| 937 |
+
self.hc_eps = args.hc_eps
|
| 938 |
+
mix_hc = (2 + hc_mult) * hc_mult
|
| 939 |
+
hc_dim = hc_mult * args.dim
|
| 940 |
+
with set_dtype(torch.float32):
|
| 941 |
+
self.hc_attn_fn = nn.Parameter(torch.empty(mix_hc, hc_dim))
|
| 942 |
+
self.hc_ffn_fn = nn.Parameter(torch.empty(mix_hc, hc_dim))
|
| 943 |
+
self.hc_attn_base = nn.Parameter(torch.empty(mix_hc))
|
| 944 |
+
self.hc_ffn_base = nn.Parameter(torch.empty(mix_hc))
|
| 945 |
+
self.hc_attn_scale = nn.Parameter(torch.empty(3))
|
| 946 |
+
self.hc_ffn_scale = nn.Parameter(torch.empty(3))
|
| 947 |
+
|
| 948 |
+
def hc_mixes(self, x: torch.Tensor, hc_fn: torch.Tensor, hc_scale: torch.Tensor, hc_base: torch.Tensor):
|
| 949 |
+
"""x: [b,s,hc,d], hc_fn: [mix_hc, hc*d], hc_scale: [3], hc_base: [mix_hc]. Returns the
|
| 950 |
+
pre / post / comb coefficients, split out of one projection of the flattened stream."""
|
| 951 |
+
# normalized over the whole flattened hc*d stream, one statistic per token
|
| 952 |
+
x = x.flatten(2).float()
|
| 953 |
+
rsqrt = torch.rsqrt(x.square().mean(-1, keepdim=True) + self.norm_eps)
|
| 954 |
+
mixes = F.linear(x, hc_fn) * rsqrt
|
| 955 |
+
return hc_split_sinkhorn(mixes, hc_scale, hc_base, self.hc_mult, self.hc_sinkhorn_iters, self.hc_eps)
|
| 956 |
+
|
| 957 |
+
def hc_pre(self, x: torch.Tensor, pre_mix: torch.Tensor):
|
| 958 |
+
"""Collapse the hc copies into one, weighted by pre_mix. [b,s,hc,d] x [b,s,hc] -> [b,s,d]"""
|
| 959 |
+
y = torch.sum(pre_mix.unsqueeze(-1) * x.float(), dim=2)
|
| 960 |
+
return y.to(x.dtype)
|
| 961 |
+
|
| 962 |
+
def hc_post(self, x: torch.Tensor, residual: torch.Tensor, post: torch.Tensor, comb: torch.Tensor):
|
| 963 |
+
"""Expand the sublayer output back to hc copies and mix the residual in through `comb`.
|
| 964 |
+
x: [b,s,d], residual: [b,s,hc,d], post: [b,s,hc], comb: [b,s,hc,hc] -> [b,s,hc,d]"""
|
| 965 |
+
y = post.unsqueeze(-1) * x.unsqueeze(-2) + torch.sum(comb.unsqueeze(-1) * residual.unsqueeze(-2), dim=2)
|
| 966 |
+
return y.type_as(x)
|
| 967 |
+
|
| 968 |
+
def forward(
|
| 969 |
+
self,
|
| 970 |
+
x: torch.Tensor,
|
| 971 |
+
start_pos: int,
|
| 972 |
+
pre_mix: torch.Tensor,
|
| 973 |
+
image_mask: torch.Tensor | None,
|
| 974 |
+
*attn_args,
|
| 975 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 976 |
+
"""`pre_mix` collapses the hc_mult copies down to one input for this block's attention. Each
|
| 977 |
+
sub-block's own `hc_mixes` produces the mix for the *next* one, so attention uses what the
|
| 978 |
+
previous layer's FFN produced and the FFN uses what this attention produced.
|
| 979 |
+
|
| 980 |
+
image_mask: [b, s] bool, True inside image spans (selects the VL routing bias)."""
|
| 981 |
+
residual = x
|
| 982 |
+
attn_pre, attn_post, attn_comb = self.hc_mixes(x, self.hc_attn_fn, self.hc_attn_scale, self.hc_attn_base)
|
| 983 |
+
x = self.hc_pre(x, pre_mix)
|
| 984 |
+
x = self.attn_norm(x)
|
| 985 |
+
x = self.attn(x, start_pos, *attn_args)
|
| 986 |
+
x = self.hc_post(x, residual, attn_post, attn_comb)
|
| 987 |
+
|
| 988 |
+
residual = x
|
| 989 |
+
ffn_pre, ffn_post, ffn_comb = self.hc_mixes(x, self.hc_ffn_fn, self.hc_ffn_scale, self.hc_ffn_base)
|
| 990 |
+
x = self.hc_pre(x, attn_pre)
|
| 991 |
+
x = self.ffn_norm(x)
|
| 992 |
+
x = self.ffn(x, image_mask)
|
| 993 |
+
x = self.hc_post(x, residual, ffn_post, ffn_comb)
|
| 994 |
+
return x, ffn_pre
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
class ParallelHead(nn.Module):
|
| 998 |
+
def __init__(self, vocab_size: int, dim: int, norm_eps: float = 1e-6, hc_eps: float = 1e-6):
|
| 999 |
+
super().__init__()
|
| 1000 |
+
self.vocab_size = vocab_size
|
| 1001 |
+
self.dim = dim
|
| 1002 |
+
self.norm_eps = norm_eps
|
| 1003 |
+
self.hc_eps = hc_eps
|
| 1004 |
+
self.part_vocab_size = vocab_size // world_size
|
| 1005 |
+
# bf16 in the checkpoint, kept as fp32 here so the logits come out in fp32 directly
|
| 1006 |
+
self.weight = nn.Parameter(torch.empty(self.part_vocab_size, self.dim, dtype=torch.float32))
|
| 1007 |
+
|
| 1008 |
+
def forward(self, x: torch.Tensor, full_logits=False):
|
| 1009 |
+
"""x: [b, s, d]. Generation only needs the last position, so that is the default."""
|
| 1010 |
+
if not full_logits:
|
| 1011 |
+
x = x[:, -1]
|
| 1012 |
+
logits = F.linear(x.float(), self.weight)
|
| 1013 |
+
if world_size > 1:
|
| 1014 |
+
all_logits = [torch.empty_like(logits) for _ in range(world_size)]
|
| 1015 |
+
dist.all_gather(all_logits, logits)
|
| 1016 |
+
logits = torch.cat(all_logits, dim=-1)
|
| 1017 |
+
return logits
|
| 1018 |
+
|
| 1019 |
+
|
| 1020 |
+
@lru_cache(1)
|
| 1021 |
+
def get_dspark_topk_idxs(window_size: int, bsz: int, block_size: int, start_pos: int):
|
| 1022 |
+
assert start_pos > 0
|
| 1023 |
+
matrix = torch.cat(
|
| 1024 |
+
[
|
| 1025 |
+
torch.arange(min(window_size, start_pos + 1)),
|
| 1026 |
+
window_size + torch.arange(block_size),
|
| 1027 |
+
]
|
| 1028 |
+
)
|
| 1029 |
+
return matrix.int().view(1, 1, -1).expand(bsz, block_size, -1).contiguous()
|
| 1030 |
+
|
| 1031 |
+
|
| 1032 |
+
class DSparkAttention(Attention):
|
| 1033 |
+
def forward(self, x: torch.Tensor, start_pos: int, main_x: torch.Tensor):
|
| 1034 |
+
assert self.compress_ratio == 0
|
| 1035 |
+
bsz, seqlen, _ = main_x.size()
|
| 1036 |
+
win = self.window_size
|
| 1037 |
+
rd = self.rope_head_dim
|
| 1038 |
+
|
| 1039 |
+
main_freqs_cis = self.freqs_cis[start_pos : start_pos + seqlen]
|
| 1040 |
+
main_kv = self.kv_norm(self.wkv(main_x))
|
| 1041 |
+
apply_rotary_emb(main_kv[..., -rd:], main_freqs_cis)
|
| 1042 |
+
act_quant(main_kv, fp8_block_size, scale_fmt, scale_dtype, True)
|
| 1043 |
+
|
| 1044 |
+
if start_pos == 0:
|
| 1045 |
+
if seqlen <= win:
|
| 1046 |
+
self.window_kv_cache[:bsz, :seqlen] = main_kv
|
| 1047 |
+
else:
|
| 1048 |
+
cutoff = seqlen % win
|
| 1049 |
+
self.window_kv_cache[:bsz, cutoff:win], self.window_kv_cache[:bsz, :cutoff] = main_kv[:, -win:].split(
|
| 1050 |
+
[win - cutoff, cutoff], dim=1
|
| 1051 |
+
)
|
| 1052 |
+
return x
|
| 1053 |
+
|
| 1054 |
+
bsz, block_size, _ = x.size()
|
| 1055 |
+
freqs_cis = self.freqs_cis[start_pos + seqlen : start_pos + seqlen + block_size]
|
| 1056 |
+
|
| 1057 |
+
qr = self.q_norm(self.wq_a(x))
|
| 1058 |
+
q = self.wq_b(qr).unflatten(-1, (self.n_local_heads, self.head_dim))
|
| 1059 |
+
apply_rotary_emb(q[..., -rd:], freqs_cis)
|
| 1060 |
+
kv = self.kv_norm(self.wkv(x))
|
| 1061 |
+
apply_rotary_emb(kv[..., -rd:], freqs_cis)
|
| 1062 |
+
act_quant(kv, fp8_block_size, scale_fmt, scale_dtype, True)
|
| 1063 |
+
|
| 1064 |
+
topk_idxs = get_dspark_topk_idxs(win, bsz, block_size, start_pos)
|
| 1065 |
+
self.window_kv_cache[:bsz, start_pos % win] = main_kv.squeeze(1)
|
| 1066 |
+
kv = torch.cat([self.window_kv_cache[:bsz], kv], dim=1)
|
| 1067 |
+
o = sparse_attn(q, kv, self.attn_sink, topk_idxs, self.softmax_scale)
|
| 1068 |
+
apply_rotary_emb(o[..., -rd:], freqs_cis, True)
|
| 1069 |
+
|
| 1070 |
+
o = o.view(bsz, block_size, self.n_local_groups, -1)
|
| 1071 |
+
wo_a = self.wo_a.weight.view(self.n_local_groups, self.o_lora_rank, -1)
|
| 1072 |
+
o = torch.einsum("bsgd,grd->bsgr", o, wo_a)
|
| 1073 |
+
x = self.wo_b(o.flatten(2))
|
| 1074 |
+
return x
|
| 1075 |
+
|
| 1076 |
+
|
| 1077 |
+
class DSparkMarkovHead(nn.Module):
|
| 1078 |
+
def __init__(self, vocab_size: int, dspark_markov_rank: int):
|
| 1079 |
+
super().__init__()
|
| 1080 |
+
self.embed = ParallelEmbedding(vocab_size, dspark_markov_rank)
|
| 1081 |
+
self.head = ParallelHead(vocab_size, dspark_markov_rank)
|
| 1082 |
+
|
| 1083 |
+
def forward(self, token_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 1084 |
+
embed = self.embed(token_ids)
|
| 1085 |
+
logits = self.head(embed, full_logits=True)
|
| 1086 |
+
return logits, embed
|
| 1087 |
+
|
| 1088 |
+
|
| 1089 |
+
class DSparkConfidenceHead(nn.Module):
|
| 1090 |
+
def __init__(self, input_dim: int):
|
| 1091 |
+
super().__init__()
|
| 1092 |
+
# proj in the checkpoint is stored in bf16, while the parameter here is stored in fp32 for fp32 confidence score.
|
| 1093 |
+
self.proj = Linear(input_dim, 1, dtype=torch.float32)
|
| 1094 |
+
|
| 1095 |
+
def forward(self, hidden: torch.Tensor, markov_embed: torch.Tensor):
|
| 1096 |
+
hidden = torch.cat([hidden, markov_embed], dim=-1)
|
| 1097 |
+
return self.proj(hidden.float()).squeeze(-1)
|
| 1098 |
+
|
| 1099 |
+
|
| 1100 |
+
class DSparkBlock(Block):
|
| 1101 |
+
"""DSpark stage stored under the mtp.* checkpoint namespace."""
|
| 1102 |
+
|
| 1103 |
+
attention_cls = DSparkAttention
|
| 1104 |
+
|
| 1105 |
+
def __init__(self, layer_id: int, args: ModelArgs):
|
| 1106 |
+
super().__init__(layer_id, args)
|
| 1107 |
+
stage_id = layer_id - args.n_layers
|
| 1108 |
+
self.block_size = args.dspark_block_size
|
| 1109 |
+
self.noise_token_id = args.dspark_noise_token_id
|
| 1110 |
+
self.temperature = args.temperature
|
| 1111 |
+
if stage_id == 0:
|
| 1112 |
+
assert len(args.dspark_target_layer_ids) > 0, "DSpark needs target layers"
|
| 1113 |
+
self.main_proj = Linear(args.dim * len(args.dspark_target_layer_ids), args.dim)
|
| 1114 |
+
self.main_norm = RMSNorm(args.dim, args.norm_eps)
|
| 1115 |
+
if stage_id == args.n_mtp_layers - 1:
|
| 1116 |
+
self.norm = RMSNorm(args.dim, args.norm_eps)
|
| 1117 |
+
self.markov_head = DSparkMarkovHead(args.vocab_size, args.dspark_markov_rank)
|
| 1118 |
+
self.confidence_head = DSparkConfidenceHead(args.dim + args.dspark_markov_rank)
|
| 1119 |
+
self.embed: ParallelEmbedding | None = None
|
| 1120 |
+
self.head: ParallelHead | None = None
|
| 1121 |
+
|
| 1122 |
+
def forward(self, x: torch.Tensor, start_pos: int, pre_mix: torch.Tensor, main_x: torch.Tensor):
|
| 1123 |
+
if start_pos == 0:
|
| 1124 |
+
self.attn(x, start_pos, main_x) # prefill only seeds the window KV cache
|
| 1125 |
+
return x, pre_mix
|
| 1126 |
+
return super().forward(x, start_pos, pre_mix, None, main_x) # drafts are text: no VL bias
|
| 1127 |
+
|
| 1128 |
+
def forward_embed(self, main_hidden: torch.Tensor, input_ids: torch.Tensor):
|
| 1129 |
+
assert self.embed is not None
|
| 1130 |
+
main_x = self.main_norm(self.main_proj(main_hidden))
|
| 1131 |
+
draft_input_ids = input_ids.new_full([input_ids.size(0), self.block_size], self.noise_token_id)
|
| 1132 |
+
draft_input_ids[:, 0] = input_ids
|
| 1133 |
+
x = self.embed(draft_input_ids)
|
| 1134 |
+
x = x.unsqueeze(2).repeat(1, 1, self.hc_mult, 1)
|
| 1135 |
+
return x, main_x
|
| 1136 |
+
|
| 1137 |
+
def forward_head(
|
| 1138 |
+
self,
|
| 1139 |
+
x: torch.Tensor,
|
| 1140 |
+
pre_mix: torch.Tensor,
|
| 1141 |
+
input_ids: torch.Tensor,
|
| 1142 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 1143 |
+
assert self.head is not None
|
| 1144 |
+
x = self.hc_pre(x, pre_mix)
|
| 1145 |
+
logits = self.head(self.norm(x), full_logits=True)
|
| 1146 |
+
output_ids = input_ids.new_empty(input_ids.size(0), self.block_size + 1)
|
| 1147 |
+
output_ids[:, 0] = input_ids
|
| 1148 |
+
markov_embeds = []
|
| 1149 |
+
for i in range(self.block_size):
|
| 1150 |
+
logits_bias, markov_embed = self.markov_head(output_ids[:, i])
|
| 1151 |
+
logits[:, i].add_(logits_bias)
|
| 1152 |
+
markov_embeds.append(markov_embed)
|
| 1153 |
+
output_ids[:, i + 1] = sample(logits[:, i], self.temperature)
|
| 1154 |
+
markov_embed = torch.stack(markov_embeds, dim=1)
|
| 1155 |
+
confidence = self.confidence_head(x, markov_embed)
|
| 1156 |
+
return output_ids, logits, confidence
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
def make_identity_pre_mix(x: torch.Tensor, hc_mult: int) -> torch.Tensor:
|
| 1160 |
+
"""initial one-hot mix"""
|
| 1161 |
+
pre_mix = x.new_zeros(x.size(0), x.size(1), hc_mult, dtype=torch.float32)
|
| 1162 |
+
pre_mix[:, :, 0] = 1.0
|
| 1163 |
+
return pre_mix
|
| 1164 |
+
|
| 1165 |
+
|
| 1166 |
+
class SharedAttentionRuntime:
|
| 1167 |
+
"""What attention layers hand down the stack instead of recomputing. Layers run in order and
|
| 1168 |
+
every source writes before its consumers read, so one slot each is enough and nothing needs
|
| 1169 |
+
resetting between forwards. Sources: compress_kv and index_k from kv_source_layers, topk_idxs
|
| 1170 |
+
from index_source_layers, candidates from candidate_source_layer."""
|
| 1171 |
+
|
| 1172 |
+
def __init__(self):
|
| 1173 |
+
self.compress_kv: torch.Tensor | None = None
|
| 1174 |
+
self.index_k: torch.Tensor | None = None
|
| 1175 |
+
self.topk_idxs: torch.Tensor | None = None
|
| 1176 |
+
self.candidates: torch.Tensor | None = None
|
| 1177 |
+
|
| 1178 |
+
|
| 1179 |
+
# Only ever one model per process, same as world_size / rank / default_dtype above.
|
| 1180 |
+
shared_attn = SharedAttentionRuntime()
|
| 1181 |
+
|
| 1182 |
+
|
| 1183 |
+
class Transformer(nn.Module):
|
| 1184 |
+
"""DeepSeek-V4.1: embed -> expand to hc_mult copies -> blocks -> collapse -> logits. Building
|
| 1185 |
+
this sets the globals at the top of the file. The tokenizer only feeds the engram token map."""
|
| 1186 |
+
|
| 1187 |
+
def __init__(self, args: ModelArgs, tokenizer=None):
|
| 1188 |
+
global world_size, rank, default_dtype
|
| 1189 |
+
world_size = dist.get_world_size() if dist.is_initialized() else 1
|
| 1190 |
+
rank = dist.get_rank() if dist.is_initialized() else 0
|
| 1191 |
+
default_dtype = torch.float8_e4m3fn if args.dtype == "fp8" else torch.bfloat16
|
| 1192 |
+
super().__init__()
|
| 1193 |
+
self.max_seq_len = args.max_seq_len
|
| 1194 |
+
self.temperature = args.temperature
|
| 1195 |
+
self.norm_eps = args.norm_eps
|
| 1196 |
+
self.hc_eps = args.hc_eps
|
| 1197 |
+
self.engram_layout = EngramLayout.from_args(args)
|
| 1198 |
+
self.engram_hash = (
|
| 1199 |
+
NgramHashState(args, self.engram_layout, tokenizer) if self.engram_layout is not None else None
|
| 1200 |
+
)
|
| 1201 |
+
self.embed = ParallelEmbedding(args.vocab_size, args.dim)
|
| 1202 |
+
self.layers = torch.nn.ModuleList()
|
| 1203 |
+
for layer_id in range(args.n_layers):
|
| 1204 |
+
self.layers.append(Block(layer_id, args, self.engram_layout))
|
| 1205 |
+
self.norm = RMSNorm(args.dim, self.norm_eps)
|
| 1206 |
+
self.head = ParallelHead(args.vocab_size, args.dim, self.norm_eps, self.hc_eps)
|
| 1207 |
+
self.mtp = torch.nn.ModuleList()
|
| 1208 |
+
self.target_layer_ids = args.dspark_target_layer_ids
|
| 1209 |
+
if args.dspark_block_size:
|
| 1210 |
+
for layer_id in range(args.n_mtp_layers):
|
| 1211 |
+
self.mtp.append(DSparkBlock(args.n_layers + layer_id, args))
|
| 1212 |
+
self.mtp[-1].embed = self.embed
|
| 1213 |
+
self.mtp[-1].head = self.head
|
| 1214 |
+
self.hc_mult = args.hc_mult
|
| 1215 |
+
self.vision = None
|
| 1216 |
+
if args.vision_enabled:
|
| 1217 |
+
self.vision = ViT(args)
|
| 1218 |
+
self.aligner = Aligner(args)
|
| 1219 |
+
# learned embeddings for the image span delimiters
|
| 1220 |
+
self.image_start = nn.Parameter(torch.empty(args.dim))
|
| 1221 |
+
self.image_end = nn.Parameter(torch.empty(args.dim))
|
| 1222 |
+
self.image_newline = nn.Parameter(torch.empty(args.dim))
|
| 1223 |
+
|
| 1224 |
+
@torch.inference_mode()
|
| 1225 |
+
def encode_image(self, patches: torch.Tensor, n_vit_h: int, n_vit_w: int) -> torch.Tensor:
|
| 1226 |
+
return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
|
| 1227 |
+
|
| 1228 |
+
def merge_image_embeddings(self, images, h: torch.Tensor):
|
| 1229 |
+
"""Overwrite each image's token span in h with its ViT/aligner features. The IMAGE slots take
|
| 1230 |
+
the aligner rows in row-major order; the span delimiters take learned embeddings."""
|
| 1231 |
+
for i, sample in enumerate(images):
|
| 1232 |
+
for img in sample or ():
|
| 1233 |
+
types = img.types.to(h.device)
|
| 1234 |
+
span = h[i, img.start : img.start + types.numel()]
|
| 1235 |
+
span[types == IMAGE_START] = self.image_start.to(h.dtype)
|
| 1236 |
+
span[types == IMAGE_END] = self.image_end.to(h.dtype)
|
| 1237 |
+
span[types == IMAGE_NEW_LINE] = self.image_newline.to(h.dtype)
|
| 1238 |
+
embeds = self.encode_image(img.patches.to(h.device), img.n_vit_h, img.n_vit_w)
|
| 1239 |
+
span[types == IMAGE] = embeds.to(h.dtype)
|
| 1240 |
+
|
| 1241 |
+
@torch.inference_mode()
|
| 1242 |
+
def forward(
|
| 1243 |
+
self, input_ids: torch.Tensor, start_pos: int = 0, images=None, token_types: torch.Tensor | None = None
|
| 1244 |
+
):
|
| 1245 |
+
"""input_ids: [b, s], every entry a real token id -- generate.py only ever passes positions it
|
| 1246 |
+
has already filled, so the padding it uses internally never reaches here. token_types /
|
| 1247 |
+
images carry the VL inputs built by image_processor.prepare_vl_inputs; image spans must lie
|
| 1248 |
+
inside the first (start_pos 0) chunk."""
|
| 1249 |
+
image_mask = None if token_types is None else token_types >= 0 # TEXT is -1
|
| 1250 |
+
# image tokens take no part in an n-gram and get no engram contribution; text-only needs no mask
|
| 1251 |
+
engram_mask = None if image_mask is None else ~image_mask
|
| 1252 |
+
engram_hashes = self.engram_hash(input_ids, start_pos, engram_mask) if self.engram_hash is not None else None
|
| 1253 |
+
h = self.embed(input_ids)
|
| 1254 |
+
if images is not None:
|
| 1255 |
+
assert start_pos == 0, "image spans must be prefilled in a single chunk"
|
| 1256 |
+
self.merge_image_embeddings(images, h)
|
| 1257 |
+
# Expand to hc_mult copies for Hyper-Connections
|
| 1258 |
+
h = h.unsqueeze(2).repeat(1, 1, self.hc_mult, 1)
|
| 1259 |
+
main_hiddens = []
|
| 1260 |
+
pre_mix = make_identity_pre_mix(h, self.hc_mult)
|
| 1261 |
+
for i, layer in enumerate(self.layers):
|
| 1262 |
+
if layer.engram is not None:
|
| 1263 |
+
h = layer.engram(h, engram_hashes[:, :, layer.engram.layer_hash_index, :], engram_mask)
|
| 1264 |
+
# the MTP head reads the attention input of its target layers, not their output
|
| 1265 |
+
if i in self.target_layer_ids:
|
| 1266 |
+
main_hiddens.append(h.mean(dim=2))
|
| 1267 |
+
h, pre_mix = layer(h, start_pos, pre_mix, image_mask)
|
| 1268 |
+
h = layer.hc_pre(h, pre_mix)
|
| 1269 |
+
logits = self.head(self.norm(h))
|
| 1270 |
+
output_ids = sample(logits, self.temperature)
|
| 1271 |
+
main_hidden = torch.cat(main_hiddens, dim=-1) if main_hiddens else None
|
| 1272 |
+
return output_ids, logits, main_hidden
|
| 1273 |
+
|
| 1274 |
+
@torch.inference_mode()
|
| 1275 |
+
def forward_spec(self, input_ids: torch.Tensor, main_hidden: torch.Tensor, start_pos: int = 0):
|
| 1276 |
+
h, main_x = self.mtp[0].forward_embed(main_hidden, input_ids)
|
| 1277 |
+
pre_mix = make_identity_pre_mix(h, self.hc_mult)
|
| 1278 |
+
for layer in self.mtp:
|
| 1279 |
+
h, pre_mix = layer(h, start_pos, pre_mix, main_x)
|
| 1280 |
+
if start_pos == 0:
|
| 1281 |
+
return None
|
| 1282 |
+
return self.mtp[-1].forward_head(h, pre_mix, input_ids)
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
def sample(logits, temperature: float = 1.0):
|
| 1286 |
+
"""Gumbel-max trick: equivalent to multinomial sampling but faster on GPU,
|
| 1287 |
+
since it avoids the GPU-to-CPU sync in torch.multinomial."""
|
| 1288 |
+
if temperature == 0:
|
| 1289 |
+
return logits.argmax(dim=-1)
|
| 1290 |
+
logits = logits / max(temperature, 1e-5)
|
| 1291 |
+
probs = torch.softmax(logits, dim=-1, dtype=torch.float32)
|
| 1292 |
+
return probs.div_(torch.empty_like(probs).exponential_(1)).argmax(dim=-1)
|
| 1293 |
+
|
| 1294 |
+
|
| 1295 |
+
if __name__ == "__main__":
|
| 1296 |
+
torch.set_default_dtype(torch.bfloat16)
|
| 1297 |
+
torch.set_default_device("cuda")
|
| 1298 |
+
torch.manual_seed(0)
|
| 1299 |
+
args = ModelArgs(dspark_block_size=6, dspark_target_layer_ids=(3, 4))
|
| 1300 |
+
x = torch.randint(0, args.vocab_size, (2, 150))
|
| 1301 |
+
model = Transformer(args)
|
| 1302 |
+
|
| 1303 |
+
output_ids, logits, main_hidden = model(x[:, :128])
|
| 1304 |
+
model.forward_spec(output_ids, main_hidden)
|
| 1305 |
+
for i in range(128, 150):
|
| 1306 |
+
output_ids, logits, main_hidden = model(x[:, i : i + 1], i)
|
| 1307 |
+
result = model.forward_spec(output_ids, main_hidden, i)
|
| 1308 |
+
assert result is not None
|
| 1309 |
+
output_ids, logits, confidence = result
|
inference/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.10.0
|
| 2 |
+
transformers
|
| 3 |
+
tokenizers
|
| 4 |
+
safetensors>=0.7.0
|
| 5 |
+
numpy
|
| 6 |
+
sympy
|
| 7 |
+
Pillow
|
| 8 |
+
tilelang==0.1.8
|
| 9 |
+
tqdm
|
inference/run.sh
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# Run the reference inference on a converted checkpoint.
|
| 4 |
+
#
|
| 5 |
+
# ./run.sh /path/to/DeepSeek-V4.1-Exp-TP8
|
| 6 |
+
# ./run.sh /path/to/DeepSeek-V4.1-Exp-TP8 examples/example_harmony.json
|
| 7 |
+
# MP=4 ./run.sh /path/to/DeepSeek-V4.1-Exp-TP4
|
| 8 |
+
#
|
| 9 |
+
# Paths inside an example are resolved from this directory, so run it from anywhere.
|
| 10 |
+
|
| 11 |
+
set -euo pipefail
|
| 12 |
+
cd "$(dirname "$0")"
|
| 13 |
+
|
| 14 |
+
CKPT_PATH="${1:-${CKPT_PATH:-}}"
|
| 15 |
+
INPUT_FILE="${2:-${INPUT_FILE:-examples/example_harmony.json}}"
|
| 16 |
+
MP="${MP:-8}"
|
| 17 |
+
CONFIG="${CONFIG:-config.json}"
|
| 18 |
+
|
| 19 |
+
usage() {
|
| 20 |
+
echo "usage: $0 <checkpoint-dir> [input-file]" >&2
|
| 21 |
+
echo >&2
|
| 22 |
+
echo " checkpoint-dir holds model0-mp${MP}.safetensors .. model$((MP - 1))-mp${MP}.safetensors," >&2
|
| 23 |
+
echo " as produced by convert.py --model-parallel ${MP}" >&2
|
| 24 |
+
echo " input-file TXT or JSON prompts (default: examples/example.txt)" >&2
|
| 25 |
+
echo >&2
|
| 26 |
+
echo " MP=${MP} CONFIG=${CONFIG} override with environment variables" >&2
|
| 27 |
+
exit 1
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
[ -n "${CKPT_PATH}" ] || usage
|
| 31 |
+
|
| 32 |
+
if [ ! -d "${CKPT_PATH}" ]; then
|
| 33 |
+
echo "error: checkpoint directory not found: ${CKPT_PATH}" >&2
|
| 34 |
+
usage
|
| 35 |
+
fi
|
| 36 |
+
|
| 37 |
+
missing=0
|
| 38 |
+
for rank in $(seq 0 $((MP - 1))); do
|
| 39 |
+
if [ ! -f "${CKPT_PATH}/model${rank}-mp${MP}.safetensors" ]; then
|
| 40 |
+
missing=$((missing + 1))
|
| 41 |
+
fi
|
| 42 |
+
done
|
| 43 |
+
if [ "${missing}" -ne 0 ]; then
|
| 44 |
+
echo "error: ${CKPT_PATH} is missing ${missing} of the ${MP} shards MP=${MP} needs" >&2
|
| 45 |
+
echo " expected model0-mp${MP}.safetensors .. model$((MP - 1))-mp${MP}.safetensors" >&2
|
| 46 |
+
usage
|
| 47 |
+
fi
|
| 48 |
+
|
| 49 |
+
[ -f "${INPUT_FILE}" ] || { echo "error: input file not found: ${INPUT_FILE}" >&2; usage; }
|
| 50 |
+
|
| 51 |
+
torchrun --nproc-per-node "${MP}" generate.py \
|
| 52 |
+
--ckpt-path "${CKPT_PATH}" \
|
| 53 |
+
--config "${CONFIG}" \
|
| 54 |
+
--input-file "${INPUT_FILE}"
|
inference/vision.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
from torch import nn
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@lru_cache(8)
|
| 9 |
+
def get_vision_cos_sin(n_h: int, n_w: int, dim: int, theta: float):
|
| 10 |
+
inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
|
| 11 |
+
hpos = torch.arange(n_h).unsqueeze(1).expand(n_h, n_w)
|
| 12 |
+
wpos = torch.arange(n_w).unsqueeze(0).expand(n_h, n_w)
|
| 13 |
+
freqs = torch.stack([hpos, wpos], dim=-1).reshape(-1, 2, 1).float() * inv_freq
|
| 14 |
+
freqs = freqs.flatten(1)
|
| 15 |
+
return freqs.cos().unsqueeze(1), freqs.sin().unsqueeze(1)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def apply_rotary(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
|
| 19 |
+
dtype = x.dtype
|
| 20 |
+
x1, x2 = x.float().chunk(2, dim=-1)
|
| 21 |
+
return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1).to(dtype)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RMSNorm(nn.Module):
|
| 25 |
+
def __init__(self, dim: int, eps: float = 1e-6):
|
| 26 |
+
super().__init__()
|
| 27 |
+
self.eps = eps
|
| 28 |
+
self.weight = nn.Parameter(torch.ones(dim, dtype=torch.float32))
|
| 29 |
+
|
| 30 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 31 |
+
dtype = x.dtype
|
| 32 |
+
x = x.float()
|
| 33 |
+
x = x * torch.rsqrt(x.square().mean(-1, keepdim=True) + self.eps)
|
| 34 |
+
return (self.weight * x).to(dtype)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class PatchEmbed(nn.Module):
|
| 38 |
+
def __init__(self, args):
|
| 39 |
+
super().__init__()
|
| 40 |
+
self.proj = nn.Linear(3 * args.vision_patch_size**2, args.vision_dim)
|
| 41 |
+
|
| 42 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 43 |
+
return self.proj(x.flatten(1))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class Attention(nn.Module):
|
| 47 |
+
def __init__(self, args):
|
| 48 |
+
super().__init__()
|
| 49 |
+
self.n_heads = args.vision_n_heads
|
| 50 |
+
self.head_dim = args.vision_dim // args.vision_n_heads
|
| 51 |
+
self.wqkv = nn.Linear(args.vision_dim, 3 * args.vision_dim)
|
| 52 |
+
self.wo = nn.Linear(args.vision_dim, args.vision_dim)
|
| 53 |
+
|
| 54 |
+
def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
|
| 55 |
+
n = x.size(0)
|
| 56 |
+
q, k, v = (t.view(n, self.n_heads, self.head_dim) for t in self.wqkv(x).chunk(3, dim=-1))
|
| 57 |
+
q = apply_rotary(q, cos, sin)
|
| 58 |
+
k = apply_rotary(k, cos, sin)
|
| 59 |
+
o = F.scaled_dot_product_attention(q.transpose(0, 1), k.transpose(0, 1), v.transpose(0, 1))
|
| 60 |
+
return self.wo(o.transpose(0, 1).reshape(n, -1))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class MLP(nn.Module):
|
| 64 |
+
def __init__(self, args):
|
| 65 |
+
super().__init__()
|
| 66 |
+
self.w1 = nn.Linear(args.vision_dim, 2 * args.vision_inter_dim, bias=False)
|
| 67 |
+
self.w2 = nn.Linear(args.vision_inter_dim, args.vision_dim, bias=False)
|
| 68 |
+
|
| 69 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 70 |
+
gate, up = self.w1(x).chunk(2, dim=-1)
|
| 71 |
+
return self.w2(F.silu(gate) * up)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class Block(nn.Module):
|
| 75 |
+
def __init__(self, args):
|
| 76 |
+
super().__init__()
|
| 77 |
+
self.norm1 = RMSNorm(args.vision_dim)
|
| 78 |
+
self.attn = Attention(args)
|
| 79 |
+
self.norm2 = RMSNorm(args.vision_dim)
|
| 80 |
+
self.mlp = MLP(args)
|
| 81 |
+
|
| 82 |
+
def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
|
| 83 |
+
x = x + self.attn(self.norm1(x), cos, sin)
|
| 84 |
+
return x + self.mlp(self.norm2(x))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class ViT(nn.Module):
|
| 88 |
+
"""DeepSeek ViT: full bidirectional attention over one image with 2D RoPE."""
|
| 89 |
+
|
| 90 |
+
def __init__(self, args):
|
| 91 |
+
super().__init__()
|
| 92 |
+
self.rope_dim = args.vision_dim // args.vision_n_heads // 2
|
| 93 |
+
self.rope_theta = args.vision_rope_theta
|
| 94 |
+
self.patch_embed = PatchEmbed(args)
|
| 95 |
+
self.blocks = nn.ModuleList([Block(args) for _ in range(args.vision_n_layers)])
|
| 96 |
+
self.norm = RMSNorm(args.vision_dim)
|
| 97 |
+
|
| 98 |
+
def forward(self, patches: torch.Tensor, n_h: int, n_w: int) -> torch.Tensor:
|
| 99 |
+
x = self.patch_embed(patches)
|
| 100 |
+
cos, sin = get_vision_cos_sin(n_h, n_w, self.rope_dim, self.rope_theta)
|
| 101 |
+
for block in self.blocks:
|
| 102 |
+
x = block(x, cos, sin)
|
| 103 |
+
return self.norm(x)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class Aligner(nn.Module):
|
| 107 |
+
def __init__(self, args):
|
| 108 |
+
super().__init__()
|
| 109 |
+
self.downsample_ratio = args.vision_downsample_ratio
|
| 110 |
+
in_dim = args.vision_dim * self.downsample_ratio**2
|
| 111 |
+
self.w1 = nn.Linear(in_dim, args.dim)
|
| 112 |
+
self.w2 = nn.Linear(args.dim, args.dim)
|
| 113 |
+
|
| 114 |
+
def forward(self, x: torch.Tensor, n_h: int, n_w: int) -> torch.Tensor:
|
| 115 |
+
r = self.downsample_ratio
|
| 116 |
+
x = x.view(n_h, n_w, -1).permute(2, 0, 1)
|
| 117 |
+
x = F.pad(x, (0, -n_w % r, 0, -n_h % r))
|
| 118 |
+
x = F.unfold(x.unsqueeze(0), r, stride=r).squeeze(0).transpose(0, 1)
|
| 119 |
+
return self.w2(F.gelu(self.w1(x)))
|
model-00002-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4320066fc6958e5bc01d8c3feba79b7454b59f0f4b7299ab7145ed44bbf4ecec
|
| 3 |
+
size 1323858272
|
model-00003-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b396bb48e9d85e2bd1dc1aaa0aec46b155b27fab8cade79279617fa69f0daa52
|
| 3 |
+
size 3585021280
|
model-00019-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:87490a474582667cc2d8fd90ebe210ce5a766e2f99b8d4a811a6da4f99038d5d
|
| 3 |
+
size 3585025920
|
model-00020-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a373d4844bae095bd612f1477fb5f872d0d1eb67b235b2f1d77397da7e35f674
|
| 3 |
+
size 3585025920
|
model-00021-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:df3c6658f536b52fddd2fe088b988538507e7eabc9eddffc1075969b1e85d4c2
|
| 3 |
+
size 3585025920
|
model-00022-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c6498ee573b2f407118c3260e829f1041bb4cc6fca29549ffd7e7b6a2948bb54
|
| 3 |
+
size 3585025920
|
model-00039-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:948f048c3c30adf6fcda7edc9fdb687eae3386dbcc1c1db4bb19240eba14245e
|
| 3 |
+
size 3590602032
|
model-00040-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d21e3b4be29389d79697bf9cbb2f68362af6b954a4f3c9976654e80ef8af9517
|
| 3 |
+
size 3585025920
|
model-00042-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:44457675c78432f38002dca2c8266eddcc35031143f5392c609407bce4fdf7b6
|
| 3 |
+
size 3585025920
|
model-00043-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d762b688f138e00a24eac96f27b842715bb4b534ecc9ca39076bed2b8c33201e
|
| 3 |
+
size 1323837624
|
model-00044-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9a6b39fb88a2510487a8efaef77aa7864e8061f6b62c95a0f010e9dd538f3b05
|
| 3 |
+
size 2652728736
|
model-00045-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0cc9d5f6ca3a2158ccc63ce2c70c76aeda8177d54913340481af566680329eb5
|
| 3 |
+
size 2573998176
|
model-00046-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e625902027b9d23d416f8818c665fab4704e0b96dc1bc778321601b700475a9d
|
| 3 |
+
size 2706402896
|
model-00047-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:824db4881320407ac340736d14dcee5ecd748c27d0f5836b8127ecc2e3781b0f
|
| 3 |
+
size 101535150936
|
model-00048-of-00048.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:976330f4954338e1ad8b508c32aa912032c7ad908959fd53c8307650fe4520ed
|
| 3 |
+
size 101537926640
|
recipe/.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
kernels/build/
|
| 2 |
+
__pycache__/
|
| 3 |
+
third_party/
|
| 4 |
+
.cache/
|
| 5 |
+
bench/evaldata/
|
| 6 |
+
results/*.serve.log
|
| 7 |
+
results/quality-*.jsonl
|
recipe/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Diffbot
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|