hotkernel 0.1.0__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hotkernel-0.1.0 → hotkernel-0.1.1}/PKG-INFO +24 -6
- {hotkernel-0.1.0 → hotkernel-0.1.1}/README.md +23 -5
- {hotkernel-0.1.0 → hotkernel-0.1.1}/README_zh.md +23 -5
- {hotkernel-0.1.0 → hotkernel-0.1.1}/pyproject.toml +1 -1
- {hotkernel-0.1.0 → hotkernel-0.1.1}/skills/kernel-speedrun/SKILL.md +5 -2
- {hotkernel-0.1.0 → hotkernel-0.1.1}/src/hotkernel/__init__.py +1 -1
- hotkernel-0.1.1/src/hotkernel/newtarget.py +263 -0
- {hotkernel-0.1.0 → hotkernel-0.1.1}/src/hotkernel/server.py +84 -4
- hotkernel-0.1.1/src/hotkernel/templates/contract.py +172 -0
- {hotkernel-0.1.0 → hotkernel-0.1.1}/.gitignore +0 -0
- {hotkernel-0.1.0 → hotkernel-0.1.1}/LICENSE +0 -0
- {hotkernel-0.1.0 → hotkernel-0.1.1}/src/hotkernel/gpus.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hotkernel
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Find the hot kernel, cool it down: an Nsight/SASS MCP server with a verify-and-record contract and a kernel-speedrun loop for coding agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/xinhuagu/hotkernel
|
|
6
6
|
Project-URL: Issues, https://github.com/xinhuagu/hotkernel/issues
|
|
@@ -36,7 +36,7 @@ English | [中文](https://github.com/xinhuagu/hotkernel/blob/main/README_zh.md)
|
|
|
36
36
|
|
|
37
37
|
</div>
|
|
38
38
|
|
|
39
|
-
Ask Claude to "make this kernel faster" today and it guesses: it cannot see the Nsight report, does not know which kernel eats 60% of the step, has never read the SASS the compiler produced, and cannot benchmark its own rewrite on your card. hotkernel is the missing pair of hands.
|
|
39
|
+
Ask Claude to "make this kernel faster" today and it guesses: it cannot see the Nsight report, does not know which kernel eats 60% of the step, has never read the SASS the compiler produced, and cannot benchmark its own rewrite on your card. hotkernel is the missing pair of hands. Ten tools wrap `nsys`, `ncu`, `cuobjdump`, `torch.profiler` and a strict verify-and-record contract; one skill turns them into a loop: profile, pick the hot kernel, read its SASS, check it against the roofline, rewrite it in Triton, verify against the baseline, record the verdict, keep or revert, repeat.
|
|
40
40
|
|
|
41
41
|
**Three targets, three autonomous agent runs, one RTX 5090: rmsnorm 3.2× faster than eager PyTorch, softmax 1.2× faster than `torch.softmax`, gemv level with cuBLAS. Every kernel the agent kept runs at 95–105% of the card's measured streaming bandwidth.**¹
|
|
42
42
|
|
|
@@ -44,14 +44,31 @@ Ask Claude to "make this kernel faster" today and it guesses: it cannot see the
|
|
|
44
44
|
|
|
45
45
|
## Install
|
|
46
46
|
|
|
47
|
+
Claude Code, one command (Claude Code 2.1.275 or later): it adds this repository as a plugin marketplace and installs the plugin, which is the MCP server plus the kernel-speedrun skill.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude plugin install hotkernel --marketplace xinhuagu/hotkernel
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Inside a session the same is `/plugin marketplace add xinhuagu/hotkernel` followed by `/plugin install hotkernel@hotkernel`. The plugin has one option, the Python that has your PyTorch and Triton: set it with `/plugin configure hotkernel@hotkernel`, or at install time with `claude plugin install hotkernel@hotkernel --config hotkernel_python=$HOME/venvs/mytorch/bin/python`. Left empty, hotkernel uses the first `python` on PATH that can import torch, and says so if there is none. The server itself stays torch-free and runs as `uvx hotkernel`.
|
|
54
|
+
|
|
55
|
+
Codex and other MCP clients (Cursor, DSH) keep the plain MCP setup:
|
|
56
|
+
|
|
47
57
|
```bash
|
|
48
|
-
|
|
49
|
-
codex mcp add --env HOTKERNEL_PYTHON=$HOME/venvs/mytorch/bin/python hotkernel -- uvx hotkernel # Codex
|
|
58
|
+
codex mcp add --env HOTKERNEL_PYTHON=$HOME/venvs/mytorch/bin/python hotkernel -- uvx hotkernel
|
|
50
59
|
```
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
or `{"command": "uvx", "args": ["hotkernel"], "env": {"HOTKERNEL_PYTHON": "..."}}` in the client's MCP config, plus a copy of [`skills/kernel-speedrun/`](https://github.com/xinhuagu/hotkernel/tree/main/skills/kernel-speedrun) in the agent's skills directory. Claude Code without the plugin works the same way: `claude mcp add -e HOTKERNEL_PYTHON=... hotkernel -- uvx hotkernel`.
|
|
62
|
+
|
|
63
|
+
## Point it at your own kernel
|
|
64
|
+
|
|
65
|
+
`hotkernel new` turns any PyTorch function into a target with the same contract as the examples: the function becomes the baseline, its fp32 result the reference, tolerances default by dtype, and the protocol-3 timer is vendored next to the scripts, so they run in your torch interpreter whether or not hotkernel is installed there.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
hotkernel new gelu --from torch.nn.functional:gelu --shape 4096x8192 --dtype bf16 --out targets
|
|
69
|
+
```
|
|
53
70
|
|
|
54
|
-
|
|
71
|
+
writes `targets/gelu_baseline.py`, `targets/gelu_candidate.py` (starts as the baseline; this is the file the loop edits) and `targets/_hotkernel_contract.py`. A function of your own works the same way, with extra inputs in call order: `--from ./mykernels.py:fused --extra w:8192`. Then `bench` the baseline for the number to beat, and `verify` the untouched candidate against it, which must come back `inconclusive` before anything changed. Agents get the same through the `new_target` tool. On the reference card the untouched gelu candidate benched at 0.0952 ms and verified `inconclusive`; a deliberately wrong one came back `incorrect`.
|
|
55
72
|
|
|
56
73
|
## Before / after
|
|
57
74
|
|
|
@@ -80,6 +97,7 @@ Every row on [LEADERBOARD.md](https://github.com/xinhuagu/hotkernel/blob/main/LE
|
|
|
80
97
|
| `bench` | run a script n times, collect its `{"ms": …, "ok": …}` line, return mean/std/min | your torch |
|
|
81
98
|
| `verify` | run baseline and candidate interleaved, n times each; speedup and a verdict: `faster`, `slower`, `inconclusive`, `incorrect` (any `ok=false`, no speedup reported), `invalid` (shape, dtype, seed or timing protocol changed) | your torch |
|
|
82
99
|
| `record` | write the verify result to `results/<target>/`, regenerate the leaderboard from the logs, and say whether the change beats the run's best so far | nothing |
|
|
100
|
+
| `new_target` | scaffold a target from your own function: baseline, blank candidate and the vendored contract, the same as `hotkernel new` | nothing |
|
|
83
101
|
|
|
84
102
|
`torch_trace`, `bench`, `verify` and `record` work on any machine with PyTorch. Nsight is optional and only makes the picture sharper.
|
|
85
103
|
|
|
@@ -14,7 +14,7 @@ English | [中文](https://github.com/xinhuagu/hotkernel/blob/main/README_zh.md)
|
|
|
14
14
|
|
|
15
15
|
</div>
|
|
16
16
|
|
|
17
|
-
Ask Claude to "make this kernel faster" today and it guesses: it cannot see the Nsight report, does not know which kernel eats 60% of the step, has never read the SASS the compiler produced, and cannot benchmark its own rewrite on your card. hotkernel is the missing pair of hands.
|
|
17
|
+
Ask Claude to "make this kernel faster" today and it guesses: it cannot see the Nsight report, does not know which kernel eats 60% of the step, has never read the SASS the compiler produced, and cannot benchmark its own rewrite on your card. hotkernel is the missing pair of hands. Ten tools wrap `nsys`, `ncu`, `cuobjdump`, `torch.profiler` and a strict verify-and-record contract; one skill turns them into a loop: profile, pick the hot kernel, read its SASS, check it against the roofline, rewrite it in Triton, verify against the baseline, record the verdict, keep or revert, repeat.
|
|
18
18
|
|
|
19
19
|
**Three targets, three autonomous agent runs, one RTX 5090: rmsnorm 3.2× faster than eager PyTorch, softmax 1.2× faster than `torch.softmax`, gemv level with cuBLAS. Every kernel the agent kept runs at 95–105% of the card's measured streaming bandwidth.**¹
|
|
20
20
|
|
|
@@ -22,14 +22,31 @@ Ask Claude to "make this kernel faster" today and it guesses: it cannot see the
|
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
|
+
Claude Code, one command (Claude Code 2.1.275 or later): it adds this repository as a plugin marketplace and installs the plugin, which is the MCP server plus the kernel-speedrun skill.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
claude plugin install hotkernel --marketplace xinhuagu/hotkernel
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Inside a session the same is `/plugin marketplace add xinhuagu/hotkernel` followed by `/plugin install hotkernel@hotkernel`. The plugin has one option, the Python that has your PyTorch and Triton: set it with `/plugin configure hotkernel@hotkernel`, or at install time with `claude plugin install hotkernel@hotkernel --config hotkernel_python=$HOME/venvs/mytorch/bin/python`. Left empty, hotkernel uses the first `python` on PATH that can import torch, and says so if there is none. The server itself stays torch-free and runs as `uvx hotkernel`.
|
|
32
|
+
|
|
33
|
+
Codex and other MCP clients (Cursor, DSH) keep the plain MCP setup:
|
|
34
|
+
|
|
25
35
|
```bash
|
|
26
|
-
|
|
27
|
-
codex mcp add --env HOTKERNEL_PYTHON=$HOME/venvs/mytorch/bin/python hotkernel -- uvx hotkernel # Codex
|
|
36
|
+
codex mcp add --env HOTKERNEL_PYTHON=$HOME/venvs/mytorch/bin/python hotkernel -- uvx hotkernel
|
|
28
37
|
```
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
or `{"command": "uvx", "args": ["hotkernel"], "env": {"HOTKERNEL_PYTHON": "..."}}` in the client's MCP config, plus a copy of [`skills/kernel-speedrun/`](https://github.com/xinhuagu/hotkernel/tree/main/skills/kernel-speedrun) in the agent's skills directory. Claude Code without the plugin works the same way: `claude mcp add -e HOTKERNEL_PYTHON=... hotkernel -- uvx hotkernel`.
|
|
40
|
+
|
|
41
|
+
## Point it at your own kernel
|
|
42
|
+
|
|
43
|
+
`hotkernel new` turns any PyTorch function into a target with the same contract as the examples: the function becomes the baseline, its fp32 result the reference, tolerances default by dtype, and the protocol-3 timer is vendored next to the scripts, so they run in your torch interpreter whether or not hotkernel is installed there.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
hotkernel new gelu --from torch.nn.functional:gelu --shape 4096x8192 --dtype bf16 --out targets
|
|
47
|
+
```
|
|
31
48
|
|
|
32
|
-
|
|
49
|
+
writes `targets/gelu_baseline.py`, `targets/gelu_candidate.py` (starts as the baseline; this is the file the loop edits) and `targets/_hotkernel_contract.py`. A function of your own works the same way, with extra inputs in call order: `--from ./mykernels.py:fused --extra w:8192`. Then `bench` the baseline for the number to beat, and `verify` the untouched candidate against it, which must come back `inconclusive` before anything changed. Agents get the same through the `new_target` tool. On the reference card the untouched gelu candidate benched at 0.0952 ms and verified `inconclusive`; a deliberately wrong one came back `incorrect`.
|
|
33
50
|
|
|
34
51
|
## Before / after
|
|
35
52
|
|
|
@@ -58,6 +75,7 @@ Every row on [LEADERBOARD.md](https://github.com/xinhuagu/hotkernel/blob/main/LE
|
|
|
58
75
|
| `bench` | run a script n times, collect its `{"ms": …, "ok": …}` line, return mean/std/min | your torch |
|
|
59
76
|
| `verify` | run baseline and candidate interleaved, n times each; speedup and a verdict: `faster`, `slower`, `inconclusive`, `incorrect` (any `ok=false`, no speedup reported), `invalid` (shape, dtype, seed or timing protocol changed) | your torch |
|
|
60
77
|
| `record` | write the verify result to `results/<target>/`, regenerate the leaderboard from the logs, and say whether the change beats the run's best so far | nothing |
|
|
78
|
+
| `new_target` | scaffold a target from your own function: baseline, blank candidate and the vendored contract, the same as `hotkernel new` | nothing |
|
|
61
79
|
|
|
62
80
|
`torch_trace`, `bench`, `verify` and `record` work on any machine with PyTorch. Nsight is optional and only makes the picture sharper.
|
|
63
81
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
</div>
|
|
16
16
|
|
|
17
|
-
今天你让 Claude"把这个 kernel 跑快点",它只能靠猜:它看不到 Nsight 的报告,不知道哪个 kernel 吃掉了一步的 60%,没读过编译器生成的 SASS,也没法在你的卡上测自己改的版本。hotkernel
|
|
17
|
+
今天你让 Claude"把这个 kernel 跑快点",它只能靠猜:它看不到 Nsight 的报告,不知道哪个 kernel 吃掉了一步的 60%,没读过编译器生成的 SASS,也没法在你的卡上测自己改的版本。hotkernel 就是它缺的那双手。十个工具包住 `nsys`、`ncu`、`cuobjdump`、`torch.profiler` 和一套严格的验证与记录协议;一个 skill 把它们串成循环:剖析、选最耗时的 kernel、读它的 SASS、对照 roofline、用 Triton 重写、对着基线验证、记录裁决、保留或回滚、再来。
|
|
18
18
|
|
|
19
19
|
**三个目标,三次 agent 自主运行,一张 RTX 5090:rmsnorm 比 PyTorch 朴素实现快 3.2 倍,softmax 比 `torch.softmax` 快 1.2 倍,gemv 与 cuBLAS 持平。agent 保留下来的每个 kernel 都跑到了这张卡实测流式带宽的 95% 到 105%。**¹
|
|
20
20
|
|
|
@@ -22,14 +22,31 @@
|
|
|
22
22
|
|
|
23
23
|
## 安装
|
|
24
24
|
|
|
25
|
+
Claude Code 一条命令(Claude Code 2.1.275 及以上):把本仓库加为插件市场并装上插件,插件 = MCP server + kernel-speedrun skill。
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
claude plugin install hotkernel --marketplace xinhuagu/hotkernel
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
在会话里等价于先 `/plugin marketplace add xinhuagu/hotkernel` 再 `/plugin install hotkernel@hotkernel`。插件只有一个选项:装了你 PyTorch 和 Triton 的那个 Python。用 `/plugin configure hotkernel@hotkernel` 设置,或者安装时直接给:`claude plugin install hotkernel@hotkernel --config hotkernel_python=$HOME/venvs/mytorch/bin/python`。留空时 hotkernel 用 PATH 上第一个能 `import torch` 的 `python`,一个都没有就明确报错。server 本身不依赖 torch,以 `uvx hotkernel` 运行。
|
|
32
|
+
|
|
33
|
+
Codex 和其他 MCP 客户端(Cursor、DSH)仍按普通 MCP 方式配置:
|
|
34
|
+
|
|
25
35
|
```bash
|
|
26
|
-
|
|
27
|
-
codex mcp add --env HOTKERNEL_PYTHON=$HOME/venvs/mytorch/bin/python hotkernel -- uvx hotkernel # Codex
|
|
36
|
+
codex mcp add --env HOTKERNEL_PYTHON=$HOME/venvs/mytorch/bin/python hotkernel -- uvx hotkernel
|
|
28
37
|
```
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
或者在客户端的 MCP 配置里写 `{"command": "uvx", "args": ["hotkernel"], "env": {"HOTKERNEL_PYTHON": "..."}}`,再把 [`skills/kernel-speedrun/`](https://github.com/xinhuagu/hotkernel/tree/main/skills/kernel-speedrun) 复制到 agent 的 skills 目录。不装插件的 Claude Code 也一样:`claude mcp add -e HOTKERNEL_PYTHON=... hotkernel -- uvx hotkernel`。
|
|
40
|
+
|
|
41
|
+
## 对准你自己的 kernel
|
|
42
|
+
|
|
43
|
+
`hotkernel new` 把任意一个 PyTorch 函数变成和示例同一契约的目标:这个函数就是基线,它的 fp32 结果是参考实现,容差按 dtype 给默认值,协议 3 的计时器以文件形式放在脚本旁边,所以脚本在你的 torch 解释器里就能跑,那里装没装 hotkernel 都行。
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
hotkernel new gelu --from torch.nn.functional:gelu --shape 4096x8192 --dtype bf16 --out targets
|
|
47
|
+
```
|
|
31
48
|
|
|
32
|
-
|
|
49
|
+
会生成 `targets/gelu_baseline.py`、`targets/gelu_candidate.py`(一开始就是基线本身,循环改的就是这个文件)和 `targets/_hotkernel_contract.py`。自己写的函数同样用法,额外输入按调用顺序给:`--from ./mykernels.py:fused --extra w:8192`。然后对基线 `bench` 得到要打败的数字,再把没改过的候选 `verify` 一次,改动之前它必须是 `inconclusive`。agent 用 `new_target` 工具做同样的事。在参考卡上,没改过的 gelu 候选 bench 为 0.0952 ms、verify 判 `inconclusive`,故意写错的候选判 `incorrect`。
|
|
33
50
|
|
|
34
51
|
## 改前 / 改后
|
|
35
52
|
|
|
@@ -58,6 +75,7 @@ codex mcp add --env HOTKERNEL_PYTHON=$HOME/venvs/mytorch/bin/python hotkernel --
|
|
|
58
75
|
| `bench` | 把脚本跑 n 次,收集它打印的 `{"ms": …, "ok": …}`,返回均值、标准差、最小值 | 你的 torch |
|
|
59
76
|
| `verify` | 基线和候选交替各跑 n 次;给出加速比和裁决:`faster`、`slower`、`inconclusive`、`incorrect`(任一次 `ok=false`,不报加速比)、`invalid`(shape、dtype、种子或计时协议被改) | 你的 torch |
|
|
60
77
|
| `record` | 把 verify 结果写到 `results/<目标>/`,从日志重新生成排行榜,并告诉 agent 这次改动是否超过本轮最好成绩 | 无 |
|
|
78
|
+
| `new_target` | 用你自己的函数生成目标:基线、空白候选和随附的契约文件,与 `hotkernel new` 相同 | 无 |
|
|
61
79
|
|
|
62
80
|
`torch_trace`、`bench`、`verify`、`record` 在任何装了 PyTorch 的机器上都能用。Nsight 是可选的,只是让图更清楚。
|
|
63
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hotkernel"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.1"
|
|
4
4
|
description = "Find the hot kernel, cool it down: an Nsight/SASS MCP server with a verify-and-record contract and a kernel-speedrun loop for coding agents."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -6,8 +6,11 @@ description: Overnight loop that makes one GPU kernel faster on the GPU in this
|
|
|
6
6
|
# kernel-speedrun
|
|
7
7
|
|
|
8
8
|
You have the `hotkernel` MCP tools: `gpu_info`, `roofline`, `bench`, `torch_trace`, `profile`,
|
|
9
|
-
`top_kernels`, `disasm`, `verify`, `record
|
|
10
|
-
speedup that someone else
|
|
9
|
+
`top_kernels`, `disasm`, `verify`, `record`, and `new_target` (scaffold a target from the
|
|
10
|
+
user's own function). This skill is the loop that turns them into a speedup that someone else
|
|
11
|
+
can reproduce. If the user's kernel has no target yet, start with
|
|
12
|
+
`new_target(target, from_="module:function", shape="4096x8192", dtype="bf16")` and bench the
|
|
13
|
+
baseline it writes; the generated candidate is the file you edit.
|
|
11
14
|
|
|
12
15
|
## The contract
|
|
13
16
|
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Scaffold a hotkernel target from the user's own PyTorch function.
|
|
2
|
+
|
|
3
|
+
Used by the `hotkernel new` command and the `new_target` MCP tool. It writes three files into
|
|
4
|
+
the output directory, none of which import hotkernel, so they run in whatever interpreter has
|
|
5
|
+
the user's PyTorch:
|
|
6
|
+
|
|
7
|
+
_hotkernel_contract.py the vendored contract (protocol 3 timer, Target, tolerances)
|
|
8
|
+
<target>_baseline.py the user's function as the baseline, plus the Target definition
|
|
9
|
+
<target>_candidate.py the file the loop edits; starts as the baseline
|
|
10
|
+
|
|
11
|
+
This module imports no torch: the generated scripts do.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import importlib.resources
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
CONTRACT_FILE = "_hotkernel_contract.py"
|
|
25
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
26
|
+
_INPUT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def contract_source() -> str:
|
|
30
|
+
"""The contract file as shipped in this hotkernel version."""
|
|
31
|
+
return (importlib.resources.files("hotkernel").joinpath("templates/contract.py")
|
|
32
|
+
.read_text(encoding="utf-8"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def contract_protocol() -> int:
|
|
36
|
+
m = re.search(r"^PROTOCOL\s*=\s*(\d+)", contract_source(), re.MULTILINE)
|
|
37
|
+
return int(m.group(1)) if m else 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_shape(spec: str) -> tuple[int, ...]:
|
|
41
|
+
"""'4096x8192', '4096,8192' or '8192' -> tuple of ints."""
|
|
42
|
+
parts = [p for p in re.split(r"[x,*]", str(spec).strip().lower()) if p]
|
|
43
|
+
try:
|
|
44
|
+
dims = tuple(int(p) for p in parts)
|
|
45
|
+
except ValueError:
|
|
46
|
+
raise ValueError(f"bad shape {spec!r}; write it like 4096x8192") from None
|
|
47
|
+
if not dims or any(d <= 0 for d in dims):
|
|
48
|
+
raise ValueError(f"bad shape {spec!r}; every dimension must be a positive integer")
|
|
49
|
+
return dims
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_inputs(shape: str, extra: list[str] | None) -> dict[str, tuple[int, ...]]:
|
|
53
|
+
"""The primary input is `x` with `shape`; extras are 'name:shape' items in call order."""
|
|
54
|
+
shapes = {"x": parse_shape(shape)}
|
|
55
|
+
for item in extra or []:
|
|
56
|
+
if ":" not in item:
|
|
57
|
+
raise ValueError(f"extra input {item!r} must look like name:shape, e.g. w:8192")
|
|
58
|
+
name, s = item.split(":", 1)
|
|
59
|
+
if not _INPUT_RE.match(name) or name in shapes:
|
|
60
|
+
raise ValueError(f"bad or duplicate input name {name!r}")
|
|
61
|
+
shapes[name] = parse_shape(s)
|
|
62
|
+
return shapes
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_from(spec: str) -> tuple[str, str, bool]:
|
|
66
|
+
"""'torch.nn.functional:gelu' -> (module, function, False);
|
|
67
|
+
'mykernels.py:fused' or './dir/k.py:f' -> (file path, function, True)."""
|
|
68
|
+
if ":" not in spec:
|
|
69
|
+
raise ValueError(f"--from must be module:function or file.py:function, got {spec!r}")
|
|
70
|
+
module, function = spec.rsplit(":", 1)
|
|
71
|
+
if not _INPUT_RE.match(function):
|
|
72
|
+
raise ValueError(f"bad function name {function!r}")
|
|
73
|
+
is_file = module.endswith(".py")
|
|
74
|
+
if not is_file and not re.match(r"^[A-Za-z_][\w.]*$", module):
|
|
75
|
+
raise ValueError(f"bad module name {module!r}")
|
|
76
|
+
return module, function, is_file
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_BASELINE = '''"""hotkernel target "{target}": {spec} is the baseline.
|
|
80
|
+
|
|
81
|
+
Generated by `hotkernel new` on {date} (contract protocol {protocol}). Everything that makes
|
|
82
|
+
the number honest is here and in {contract}: the inputs (shapes, dtype, seed), the reference
|
|
83
|
+
and its tolerances, the input rotation and the timer. A candidate defines a function with the
|
|
84
|
+
same signature and calls run(candidate, impl="..."); it cannot change any of this without
|
|
85
|
+
changing the contract fields that verify() checks. The loop never edits this file.
|
|
86
|
+
|
|
87
|
+
The reference is the same function run in fp32 and cast back to DTYPE. If your function
|
|
88
|
+
needs a different reference (a different formula, a library call), edit `reference` below
|
|
89
|
+
before the first verify(), not after.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
{binding}
|
|
93
|
+
from _hotkernel_contract import PROTOCOL, Target # noqa: E402
|
|
94
|
+
|
|
95
|
+
DTYPE = {dtype!r}
|
|
96
|
+
SHAPES = {shapes} # inputs in call order
|
|
97
|
+
ROTATE = {rotate} # streamed inputs that get K distinct copies (> 2x L2 in total)
|
|
98
|
+
ITERS = {iters}
|
|
99
|
+
SEED = {seed}
|
|
100
|
+
RTOL, ATOL = {rtol}, {atol} # None = default for DTYPE (see DEFAULT_TOLERANCES in the contract)
|
|
101
|
+
FLOPS = {flops} # nominal flops per call for roofline(); None = not stated
|
|
102
|
+
|
|
103
|
+
assert PROTOCOL == {protocol}, "contract file does not match the protocol this target was generated for"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def reference({params}):
|
|
107
|
+
return baseline_fn({float_args}).to({primary}.dtype)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
TARGET = Target(name={target!r}, dtype=DTYPE, shapes=SHAPES, rotate=ROTATE, reference=reference,
|
|
111
|
+
rtol=RTOL, atol=ATOL, iters=ITERS, seed=SEED, flops=FLOPS,
|
|
112
|
+
reference_name={refname!r})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run(fn, impl: str, extra: dict | None = None) -> None:
|
|
116
|
+
TARGET.run(fn, impl, extra)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
run(baseline_fn, impl={spec!r})
|
|
121
|
+
'''
|
|
122
|
+
|
|
123
|
+
_CANDIDATE = '''"""Candidate for the "{target}" target: the file the kernel-speedrun loop edits.
|
|
124
|
+
|
|
125
|
+
It starts as the baseline itself, so the first verify() is a fair "inconclusive" and every
|
|
126
|
+
later verdict measures only what changed. Define {target}_candidate({params}) and leave the
|
|
127
|
+
run() call alone: inputs, seed, reference, tolerances, rotation and timer are the baseline's,
|
|
128
|
+
and verify() reports `invalid` if the contract changes. Triton and torch.compile are fair game.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
from {target}_baseline import baseline_fn, run
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def {target}_candidate({params}):
|
|
135
|
+
# Starting point: the baseline. Replace with something faster; keep the signature.
|
|
136
|
+
return baseline_fn({params})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
run({target}_candidate, impl="candidate (starts as the baseline)")
|
|
141
|
+
'''
|
|
142
|
+
|
|
143
|
+
_BIND_MODULE = '''import importlib
|
|
144
|
+
|
|
145
|
+
import torch # noqa: F401 (the generated scripts run in the interpreter that has your torch)
|
|
146
|
+
|
|
147
|
+
baseline_fn = getattr(importlib.import_module({module!r}), {function!r})'''
|
|
148
|
+
|
|
149
|
+
_BIND_FILE = '''import importlib.util
|
|
150
|
+
import sys
|
|
151
|
+
|
|
152
|
+
import torch # noqa: F401 (the generated scripts run in the interpreter that has your torch)
|
|
153
|
+
|
|
154
|
+
_spec = importlib.util.spec_from_file_location({modname!r}, {path!r})
|
|
155
|
+
_mod = importlib.util.module_from_spec(_spec)
|
|
156
|
+
sys.modules[{modname!r}] = _mod
|
|
157
|
+
_spec.loader.exec_module(_mod)
|
|
158
|
+
baseline_fn = getattr(_mod, {function!r})'''
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def new_target(target: str, from_: str, shape: str, dtype: str = "bf16",
|
|
162
|
+
extra: list[str] | None = None, rotate: list[str] | None = None,
|
|
163
|
+
out_dir: str = ".", flops: int | None = None, iters: int = 50, seed: int = 0,
|
|
164
|
+
rtol: float | None = None, atol: float | None = None, force: bool = False) -> dict:
|
|
165
|
+
"""Write the three files and return what was written plus the next commands. Errors come
|
|
166
|
+
back as {"error": ...} so an agent can read them."""
|
|
167
|
+
try:
|
|
168
|
+
if not _NAME_RE.match(target):
|
|
169
|
+
raise ValueError(f"target name {target!r} must be lowercase letters, digits and _")
|
|
170
|
+
shapes = parse_inputs(shape, extra)
|
|
171
|
+
module, function, is_file = parse_from(from_)
|
|
172
|
+
rotate_names = tuple(rotate) if rotate else ("x",)
|
|
173
|
+
if any(r not in shapes for r in rotate_names):
|
|
174
|
+
raise ValueError(f"--rotate names must be inputs: {list(shapes)}")
|
|
175
|
+
except ValueError as e:
|
|
176
|
+
return {"error": str(e)}
|
|
177
|
+
|
|
178
|
+
out = Path(out_dir).expanduser().resolve()
|
|
179
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
contract = out / CONTRACT_FILE
|
|
181
|
+
baseline = out / f"{target}_baseline.py"
|
|
182
|
+
candidate = out / f"{target}_candidate.py"
|
|
183
|
+
src = contract_source()
|
|
184
|
+
if contract.exists() and contract.read_text(encoding="utf-8") != src and not force:
|
|
185
|
+
return {"error": f"{contract} exists and differs from this hotkernel's contract "
|
|
186
|
+
f"(protocol {contract_protocol()}); pass force=True / --force to replace "
|
|
187
|
+
"it, which re-times every target in that directory under the new contract"}
|
|
188
|
+
clashes = [str(p) for p in (baseline, candidate) if p.exists()]
|
|
189
|
+
if clashes and not force:
|
|
190
|
+
return {"error": f"already exists: {clashes}; pass force=True / --force to overwrite"}
|
|
191
|
+
|
|
192
|
+
params = ", ".join(shapes)
|
|
193
|
+
primary = rotate_names[0]
|
|
194
|
+
if is_file:
|
|
195
|
+
path = str(Path(module).expanduser().resolve())
|
|
196
|
+
modname = "_hk_user_" + re.sub(r"\W", "_", Path(module).stem)
|
|
197
|
+
binding = _BIND_FILE.format(modname=modname, path=path, function=function)
|
|
198
|
+
spec = f"{Path(module).name}:{function}"
|
|
199
|
+
else:
|
|
200
|
+
binding = _BIND_MODULE.format(module=module, function=function)
|
|
201
|
+
spec = f"{module}:{function}"
|
|
202
|
+
protocol = contract_protocol()
|
|
203
|
+
baseline_text = _BASELINE.format(
|
|
204
|
+
target=target, spec=spec, date=time.strftime("%Y-%m-%d"), protocol=protocol,
|
|
205
|
+
contract=CONTRACT_FILE, binding=binding, dtype=dtype,
|
|
206
|
+
shapes="{" + ", ".join(f"{n!r}: {s!r}" for n, s in shapes.items()) + "}",
|
|
207
|
+
rotate=repr(tuple(rotate_names)), iters=int(iters), seed=int(seed),
|
|
208
|
+
rtol=repr(rtol), atol=repr(atol), flops=repr(None if flops is None else int(flops)),
|
|
209
|
+
params=params, float_args=", ".join(f"{n}.float()" for n in shapes), primary=primary,
|
|
210
|
+
refname=f"{spec} in fp32")
|
|
211
|
+
candidate_text = _CANDIDATE.format(target=target, params=params)
|
|
212
|
+
|
|
213
|
+
contract.write_text(src, encoding="utf-8", newline="\n")
|
|
214
|
+
baseline.write_text(baseline_text, encoding="utf-8", newline="\n")
|
|
215
|
+
candidate.write_text(candidate_text, encoding="utf-8", newline="\n")
|
|
216
|
+
rel = out.as_posix()
|
|
217
|
+
return {
|
|
218
|
+
"target": target, "protocol": protocol,
|
|
219
|
+
"files": {"contract": str(contract), "baseline": str(baseline), "candidate": str(candidate)},
|
|
220
|
+
"inputs": {n: list(s) for n, s in shapes.items()}, "dtype": dtype, "rotate": list(rotate_names),
|
|
221
|
+
"next": [
|
|
222
|
+
(f"python {baseline.name} (run from {rel}, in the interpreter that has your torch) "
|
|
223
|
+
"to see the baseline's JSON line"),
|
|
224
|
+
f"bench(script='{rel}/{baseline.name}', n=5): the number to beat",
|
|
225
|
+
(f"verify(candidate='{rel}/{candidate.name}', baseline='{rel}/{baseline.name}', n=7): "
|
|
226
|
+
"must be 'inconclusive' before you change anything"),
|
|
227
|
+
f"edit {candidate.name}, then verify() and record(target='{target}', ...) after every change",
|
|
228
|
+
],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def build_parser(prog: str = "hotkernel new") -> argparse.ArgumentParser:
|
|
233
|
+
p = argparse.ArgumentParser(prog=prog, description="Scaffold a hotkernel target from your "
|
|
234
|
+
"own PyTorch function: baseline, blank candidate and the vendored contract.")
|
|
235
|
+
p.add_argument("target", help="target name, e.g. gelu (lowercase letters, digits, _)")
|
|
236
|
+
p.add_argument("--from", dest="from_", required=True, metavar="MODULE:FUNCTION",
|
|
237
|
+
help="the function to speed up: torch.nn.functional:gelu or ./mykernels.py:fused")
|
|
238
|
+
p.add_argument("--shape", required=True, help="shape of the primary input x, e.g. 4096x8192")
|
|
239
|
+
p.add_argument("--dtype", default="bf16", help="bf16 (default), fp16, fp32, fp64")
|
|
240
|
+
p.add_argument("--extra", nargs="*", default=[], metavar="NAME:SHAPE",
|
|
241
|
+
help="more inputs in call order, e.g. w:8192 b:8192 (random, same dtype)")
|
|
242
|
+
p.add_argument("--rotate", nargs="*", default=None, metavar="NAME",
|
|
243
|
+
help="inputs that stream through memory and get K copies (default: x)")
|
|
244
|
+
p.add_argument("--out", default=".", help="directory for the generated files (default: .)")
|
|
245
|
+
p.add_argument("--flops", type=int, default=None, help="nominal flops per call for roofline()")
|
|
246
|
+
p.add_argument("--iters", type=int, default=50)
|
|
247
|
+
p.add_argument("--seed", type=int, default=0)
|
|
248
|
+
p.add_argument("--rtol", type=float, default=None, help="override the dtype default")
|
|
249
|
+
p.add_argument("--atol", type=float, default=None, help="override the dtype default")
|
|
250
|
+
p.add_argument("--force", action="store_true", help="overwrite existing files")
|
|
251
|
+
return p
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main(argv: list[str]) -> int:
|
|
255
|
+
args = build_parser().parse_args(argv)
|
|
256
|
+
result = new_target(args.target, args.from_, args.shape, args.dtype, args.extra, args.rotate,
|
|
257
|
+
args.out, args.flops, args.iters, args.seed, args.rtol, args.atol, args.force)
|
|
258
|
+
print(json.dumps(result, indent=1))
|
|
259
|
+
return 1 if "error" in result else 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -13,6 +13,7 @@ can read them instead of crashing):
|
|
|
13
13
|
verify baseline vs candidate, interleaved, n runs each: speedup + verdict
|
|
14
14
|
(faster / slower / inconclusive / incorrect / invalid)
|
|
15
15
|
record write a verify result to results/<target>/ and append the LEADERBOARD row
|
|
16
|
+
new_target scaffold baseline + blank candidate + vendored contract from the user's function
|
|
16
17
|
|
|
17
18
|
Design rules:
|
|
18
19
|
* hotkernel's own environment stays light (no torch). Anything that needs the user's
|
|
@@ -64,8 +65,46 @@ _DEFAULT_TIMEOUT = 600
|
|
|
64
65
|
|
|
65
66
|
# --------------------------------------------------------------------------- helpers
|
|
66
67
|
|
|
68
|
+
_PYTHON_HELP = (
|
|
69
|
+
"Set HOTKERNEL_PYTHON to the interpreter that has your PyTorch (and Triton), for example "
|
|
70
|
+
"~/venvs/mytorch/bin/python: `claude mcp add -e HOTKERNEL_PYTHON=... hotkernel -- uvx hotkernel`, "
|
|
71
|
+
"or for the Claude Code plugin the 'Python with your PyTorch' option "
|
|
72
|
+
"(`claude plugin configure hotkernel@hotkernel`), or an env entry in any other MCP client's "
|
|
73
|
+
"config. hotkernel itself stays torch-free."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@functools.lru_cache(maxsize=1)
|
|
78
|
+
def _probe_torch_python() -> str | None:
|
|
79
|
+
"""The first python/python3 on PATH that can import torch, or None. Probed once per server."""
|
|
80
|
+
for name in ("python3", "python"):
|
|
81
|
+
exe = shutil.which(name)
|
|
82
|
+
if not exe:
|
|
83
|
+
continue
|
|
84
|
+
r = _run([exe, "-c", "import torch, sys; print(sys.executable)"], timeout=120)
|
|
85
|
+
if "error" not in r and r["returncode"] == 0:
|
|
86
|
+
return r["stdout"].strip().splitlines()[-1] if r["stdout"].strip() else exe
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _python_or_error() -> tuple[str | None, dict | None]:
|
|
91
|
+
"""HOTKERNEL_PYTHON if set (an empty value or an unexpanded ${...} placeholder counts as
|
|
92
|
+
unset), else the first python on PATH that imports torch, else a clear error."""
|
|
93
|
+
env = (os.environ.get("HOTKERNEL_PYTHON") or "").strip()
|
|
94
|
+
if env and not env.startswith("${"):
|
|
95
|
+
return env, None
|
|
96
|
+
found = _probe_torch_python()
|
|
97
|
+
if found:
|
|
98
|
+
return found, None
|
|
99
|
+
return None, {"error": "HOTKERNEL_PYTHON is not set and no python/python3 on PATH can import "
|
|
100
|
+
"torch, so this tool cannot run the target. " + _PYTHON_HELP}
|
|
101
|
+
|
|
102
|
+
|
|
67
103
|
def _python() -> str:
|
|
68
|
-
|
|
104
|
+
"""Best guess for places that only need an interpreter path (Triton's bundled cuobjdump,
|
|
105
|
+
profile commands); tools that run a target use _python_or_error() and fail loudly."""
|
|
106
|
+
py, _ = _python_or_error()
|
|
107
|
+
return py or sys.executable
|
|
69
108
|
|
|
70
109
|
|
|
71
110
|
def _local_path(p: str | None) -> str | None:
|
|
@@ -236,7 +275,9 @@ def bench(script: str, n: int = 5, args: list[str] | None = None, cwd: str | Non
|
|
|
236
275
|
Contract: the script prints one JSON line to stdout containing at least {"ms": <float>}
|
|
237
276
|
and optionally {"ok": <bool>} for a correctness check. Returns mean/std/min over the n
|
|
238
277
|
timed runs plus every raw sample, so a LEADERBOARD row can cite n and seed."""
|
|
239
|
-
py =
|
|
278
|
+
py, err = _python_or_error()
|
|
279
|
+
if err:
|
|
280
|
+
return err
|
|
240
281
|
cmd = [py, script, *(args or [])]
|
|
241
282
|
samples: list[float] = []
|
|
242
283
|
oks: list[bool] = []
|
|
@@ -328,7 +369,9 @@ def torch_trace(script: str, top: int = 15, args: list[str] | None = None,
|
|
|
328
369
|
"""Run a Python script under torch.profiler in the user's interpreter. Returns `kernels`
|
|
329
370
|
(real GPU kernels by device time, pct over total kernel time) and `ops` (aten ops with
|
|
330
371
|
input shapes, inclusive time). Works without Nsight installed; use this first."""
|
|
331
|
-
py =
|
|
372
|
+
py, err = _python_or_error()
|
|
373
|
+
if err:
|
|
374
|
+
return err
|
|
332
375
|
with tempfile.NamedTemporaryFile("w", suffix="_hotkernel_torchprof.py", delete=False) as f:
|
|
333
376
|
f.write(_TORCH_PROF_RUNNER)
|
|
334
377
|
runner = f.name
|
|
@@ -741,7 +784,9 @@ def verify(candidate: str, baseline: str, n: int = 7, warmup: int = 2,
|
|
|
741
784
|
speedup is reported), 'invalid' if the two scripts disagree on shape/dtype/iters/seed or the
|
|
742
785
|
baseline fails its own check. Also records gpu, driver, power limit, timestamp and the exact
|
|
743
786
|
commands. Pass the result to record()."""
|
|
744
|
-
py =
|
|
787
|
+
py, err = _python_or_error()
|
|
788
|
+
if err:
|
|
789
|
+
return err
|
|
745
790
|
b_cmd = [py, baseline, *(args or [])]
|
|
746
791
|
c_cmd = [py, candidate, *(args or [])]
|
|
747
792
|
t0 = time.perf_counter()
|
|
@@ -967,7 +1012,42 @@ def record(target: str, verify_result: dict | str, note: str = "", who: str = ""
|
|
|
967
1012
|
"advice": advice, **counts}
|
|
968
1013
|
|
|
969
1014
|
|
|
1015
|
+
@mcp.tool()
|
|
1016
|
+
def new_target(target: str, from_: str, shape: str, dtype: str = "bf16",
|
|
1017
|
+
extra: list[str] | None = None, rotate: list[str] | None = None,
|
|
1018
|
+
out_dir: str = ".", flops: int | None = None, iters: int = 50, seed: int = 0,
|
|
1019
|
+
rtol: float | None = None, atol: float | None = None, force: bool = False) -> dict:
|
|
1020
|
+
"""Scaffold a target from the user's own PyTorch function so the loop can run on it: writes
|
|
1021
|
+
<out_dir>/<target>_baseline.py (the function as the baseline plus the Target definition:
|
|
1022
|
+
inputs, seed, fp32 reference, dtype-default tolerances, rotation, timer),
|
|
1023
|
+
<out_dir>/<target>_candidate.py (the file to edit; starts as the baseline) and the vendored
|
|
1024
|
+
_hotkernel_contract.py (protocol 3) they import, so they run in HOTKERNEL_PYTHON without
|
|
1025
|
+
hotkernel installed there. from_: 'torch.nn.functional:gelu' or './mykernels.py:fused';
|
|
1026
|
+
shape: the primary input x, e.g. '4096x8192'; extra: more inputs in call order like
|
|
1027
|
+
['w:8192', 'b:8192'] (random tensors of the same dtype); rotate: inputs that stream through
|
|
1028
|
+
memory (default ['x']); flops: nominal flops per call for roofline(). Same as the CLI
|
|
1029
|
+
`hotkernel new`. Then bench() the baseline and verify() the blank candidate (expect
|
|
1030
|
+
'inconclusive') before changing anything."""
|
|
1031
|
+
from .newtarget import new_target as _new
|
|
1032
|
+
out = _local_path(out_dir) or out_dir
|
|
1033
|
+
return _new(target, from_, shape, dtype, extra, rotate, out, flops, iters, seed, rtol, atol, force)
|
|
1034
|
+
|
|
1035
|
+
|
|
970
1036
|
def main() -> None:
|
|
1037
|
+
"""`hotkernel` starts the MCP server over stdio (what `uvx hotkernel` in an MCP config
|
|
1038
|
+
runs); `hotkernel new ...` scaffolds a target; `hotkernel --version` prints the version."""
|
|
1039
|
+
argv = sys.argv[1:]
|
|
1040
|
+
if argv and argv[0] == "new":
|
|
1041
|
+
from .newtarget import main as new_main
|
|
1042
|
+
sys.exit(new_main(argv[1:]))
|
|
1043
|
+
if argv and argv[0] in ("--version", "-V"):
|
|
1044
|
+
print(f"hotkernel {__version__}")
|
|
1045
|
+
return
|
|
1046
|
+
if argv and argv[0] in ("-h", "--help"):
|
|
1047
|
+
print("usage: hotkernel start the MCP server (stdio)\n"
|
|
1048
|
+
" hotkernel new ... scaffold a target from your function (see `hotkernel new -h`)\n"
|
|
1049
|
+
" hotkernel --version")
|
|
1050
|
+
return
|
|
971
1051
|
mcp.run()
|
|
972
1052
|
|
|
973
1053
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""hotkernel target contract, protocol 3. This file is vendored next to every target by
|
|
2
|
+
`hotkernel new` (as `_hotkernel_contract.py`), so a target runs in the interpreter that has
|
|
3
|
+
your PyTorch whether or not hotkernel is installed there. Do not edit the copy; regenerate it.
|
|
4
|
+
|
|
5
|
+
What it owns, so that a candidate cannot change it: the inputs (shapes, dtype, seed), the
|
|
6
|
+
reference and its tolerances, the input rotation, and the timer. A candidate is one function
|
|
7
|
+
with the baseline's signature handed to the baseline's run().
|
|
8
|
+
|
|
9
|
+
Timing (protocol 3, steady-state rotation, no flush): K distinct sets of the rotated inputs,
|
|
10
|
+
K > 2 * L2 / bytes per call, cycled call after call; the last K outputs are held so the
|
|
11
|
+
allocator gives every call a different output block (K + 1 blocks in a ring). Each call reads
|
|
12
|
+
a cold input and evicts the previous call's dirty output: exactly its own bytes of DRAM
|
|
13
|
+
traffic, the steady-state cost a real pipeline pays. Earlier protocols flushed L2 with a 256 MB
|
|
14
|
+
zero fill (protocol 2) or did nothing (protocol 1); both reported wrong bandwidths on a card
|
|
15
|
+
with a 96 MB L2. See docs/platform-matrix.md in the hotkernel repository for the calibration.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import collections
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
import torch
|
|
26
|
+
|
|
27
|
+
PROTOCOL = 3
|
|
28
|
+
TIMING = "per-call CUDA events, steady-state rotation over K buffer pairs > 2x L2, no flush"
|
|
29
|
+
|
|
30
|
+
# Defaults for allclose(out.float(), ref.float(), rtol, atol) by output dtype: the bf16 and
|
|
31
|
+
# fp16 values allow one rounding of the result, fp32 allows a different accumulation order.
|
|
32
|
+
DEFAULT_TOLERANCES = {
|
|
33
|
+
torch.bfloat16: (2e-2, 2e-2),
|
|
34
|
+
torch.float16: (1e-2, 1e-3),
|
|
35
|
+
torch.float32: (1e-4, 1e-5),
|
|
36
|
+
torch.float64: (1e-8, 1e-10),
|
|
37
|
+
}
|
|
38
|
+
_DTYPES = {"bf16": torch.bfloat16, "bfloat16": torch.bfloat16, "fp16": torch.float16,
|
|
39
|
+
"half": torch.float16, "float16": torch.float16, "fp32": torch.float32,
|
|
40
|
+
"float32": torch.float32, "float": torch.float32, "fp64": torch.float64,
|
|
41
|
+
"float64": torch.float64}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_dtype(name: str | torch.dtype) -> torch.dtype:
|
|
45
|
+
if isinstance(name, torch.dtype):
|
|
46
|
+
return name
|
|
47
|
+
try:
|
|
48
|
+
return _DTYPES[str(name).lower().removeprefix("torch.")]
|
|
49
|
+
except KeyError:
|
|
50
|
+
raise ValueError(f"unknown dtype {name!r}; use one of {sorted(_DTYPES)}") from None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_tolerances(dtype: torch.dtype) -> tuple[float, float]:
|
|
54
|
+
return DEFAULT_TOLERANCES.get(dtype, (1e-4, 1e-5))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def l2_bytes() -> int:
|
|
58
|
+
return int(torch.cuda.get_device_properties(0).L2_cache_size)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def rotation_k(pair_bytes: int) -> int:
|
|
62
|
+
"""Smallest K with K * pair_bytes > 2 * L2, and at least 2."""
|
|
63
|
+
return max(2, math.floor(2 * l2_bytes() / max(pair_bytes, 1)) + 1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def time_kernel(fn, inputs: list[tuple], iters: int = 50, warmup: int | None = None) -> float:
|
|
67
|
+
"""Mean milliseconds per call of fn(*inputs[i % K]) over `iters` timed calls, cycling the
|
|
68
|
+
K input tuples. fn must return a freshly allocated tensor (its output); the last K outputs
|
|
69
|
+
are held so outputs rotate too. Warmup covers every input at least twice."""
|
|
70
|
+
k = len(inputs)
|
|
71
|
+
held: collections.deque = collections.deque(maxlen=k)
|
|
72
|
+
for i in range(2 * k + 3 if warmup is None else warmup):
|
|
73
|
+
held.append(fn(*inputs[i % k]))
|
|
74
|
+
torch.cuda.synchronize()
|
|
75
|
+
events = []
|
|
76
|
+
for i in range(iters):
|
|
77
|
+
start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
|
|
78
|
+
start.record()
|
|
79
|
+
out = fn(*inputs[i % k])
|
|
80
|
+
end.record()
|
|
81
|
+
held.append(out) # frees the oldest output after the event; the ring keeps rotating
|
|
82
|
+
events.append((start, end))
|
|
83
|
+
torch.cuda.synchronize()
|
|
84
|
+
return sum(s.elapsed_time(e) for s, e in events) / iters
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _nbytes(t) -> int:
|
|
88
|
+
return int(t.numel() * t.element_size()) if isinstance(t, torch.Tensor) else 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Target:
|
|
92
|
+
"""One kernel target: its inputs, reference, tolerances and timing, all in one place.
|
|
93
|
+
|
|
94
|
+
shapes: name -> shape of every input, in call order. rotate: the names that get K distinct
|
|
95
|
+
copies (the big streamed tensors); the others are shared across calls. reference(*inputs)
|
|
96
|
+
returns the correct output (usually the same math in fp32, cast back). flops is the nominal
|
|
97
|
+
flop count per call for roofline() and may be None.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, name: str, dtype, shapes: dict[str, tuple[int, ...]], rotate: tuple[str, ...],
|
|
101
|
+
reference, rtol: float | None = None, atol: float | None = None, iters: int = 50,
|
|
102
|
+
seed: int = 0, flops: int | None = None, min_bytes: int | None = None,
|
|
103
|
+
reference_name: str = "reference"):
|
|
104
|
+
self.name = name
|
|
105
|
+
self.dtype = parse_dtype(dtype)
|
|
106
|
+
self.shapes = {k: tuple(int(d) for d in v) for k, v in shapes.items()}
|
|
107
|
+
self.rotate = tuple(rotate)
|
|
108
|
+
unknown = set(self.rotate) - set(self.shapes)
|
|
109
|
+
if unknown or not self.rotate:
|
|
110
|
+
raise ValueError(f"rotate must name inputs from {list(self.shapes)}; got {self.rotate}")
|
|
111
|
+
self.reference = reference
|
|
112
|
+
d_rtol, d_atol = default_tolerances(self.dtype)
|
|
113
|
+
self.rtol = d_rtol if rtol is None else float(rtol)
|
|
114
|
+
self.atol = d_atol if atol is None else float(atol)
|
|
115
|
+
self.iters = int(iters)
|
|
116
|
+
self.seed = int(seed)
|
|
117
|
+
self.flops = None if flops is None else int(flops)
|
|
118
|
+
self.min_bytes = None if min_bytes is None else int(min_bytes)
|
|
119
|
+
self.reference_name = reference_name
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def primary(self) -> str:
|
|
123
|
+
return self.rotate[0]
|
|
124
|
+
|
|
125
|
+
def make_inputs(self, k: int) -> list[tuple]:
|
|
126
|
+
"""K tuples in call order: rotated inputs are fresh per set, the others shared."""
|
|
127
|
+
torch.manual_seed(self.seed)
|
|
128
|
+
rotated = {n: [torch.randn(*self.shapes[n], device="cuda", dtype=self.dtype) for _ in range(k)]
|
|
129
|
+
for n in self.rotate}
|
|
130
|
+
shared = {n: torch.randn(*self.shapes[n], device="cuda", dtype=self.dtype)
|
|
131
|
+
for n in self.shapes if n not in self.rotate}
|
|
132
|
+
return [tuple(rotated[n][i] if n in rotated else shared[n] for n in self.shapes)
|
|
133
|
+
for i in range(k)]
|
|
134
|
+
|
|
135
|
+
def run(self, fn, impl: str, extra: dict | None = None) -> None:
|
|
136
|
+
"""Check fn against the reference, time it under protocol 3, print the JSON line, exit
|
|
137
|
+
0 if correct and 1 if not. The candidate's only job is to pass fn."""
|
|
138
|
+
rotated_bytes = sum(math.prod(self.shapes[n]) * torch.finfo(self.dtype).bits // 8
|
|
139
|
+
for n in self.rotate)
|
|
140
|
+
k = rotation_k(2 * rotated_bytes) # assume output ~ input until the first call tells
|
|
141
|
+
inputs = self.make_inputs(k)
|
|
142
|
+
|
|
143
|
+
out = fn(*inputs[0])
|
|
144
|
+
ref = self.reference(*inputs[0])
|
|
145
|
+
ok = bool(torch.allclose(out.float(), ref.float(), rtol=self.rtol, atol=self.atol))
|
|
146
|
+
pair_bytes = rotated_bytes + _nbytes(out)
|
|
147
|
+
k_needed = rotation_k(pair_bytes)
|
|
148
|
+
if k_needed > k:
|
|
149
|
+
inputs = self.make_inputs(k_needed)
|
|
150
|
+
k = k_needed
|
|
151
|
+
del out, ref
|
|
152
|
+
|
|
153
|
+
ms = time_kernel(fn, inputs, iters=self.iters)
|
|
154
|
+
min_bytes = pair_bytes if self.min_bytes is None else self.min_bytes
|
|
155
|
+
line = {
|
|
156
|
+
"ms": round(ms, 5), "ok": ok,
|
|
157
|
+
"shape": list(self.shapes[self.primary]), "dtype": str(self.dtype),
|
|
158
|
+
"iters": self.iters, "seed": self.seed, "protocol": PROTOCOL, "timing": TIMING,
|
|
159
|
+
"target": self.name, "shapes": {n: list(s) for n, s in self.shapes.items()},
|
|
160
|
+
"rotate": list(self.rotate), "rtol": self.rtol, "atol": self.atol,
|
|
161
|
+
"reference": self.reference_name,
|
|
162
|
+
"rotation_k": k, "rotation_bytes": k * pair_bytes, "l2_bytes": l2_bytes(),
|
|
163
|
+
"min_bytes": min_bytes,
|
|
164
|
+
"achieved_gbs_lower_bound": round(min_bytes / (ms / 1e3) / 1e9, 1),
|
|
165
|
+
"gpu": torch.cuda.get_device_name(0), "torch": torch.__version__,
|
|
166
|
+
"impl": impl,
|
|
167
|
+
}
|
|
168
|
+
if self.flops is not None:
|
|
169
|
+
line["flops"] = self.flops
|
|
170
|
+
line.update(extra or {})
|
|
171
|
+
print(json.dumps(line))
|
|
172
|
+
sys.exit(0 if ok else 1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|