# MORI JIT v2 `python/mori/ops/dispatch_combine_v2/` 的 HIP kernel 后端,以及它底下那套通用的 JIT 框架: kernel 在 C++ 侧生成、编译、发射,基于 cco-LSA,不依赖 mori-shmem。与既有的 FlyDSL 后端并列, 同一个 op 类下可切换。 ## 1. 核心不变量 整套设计围绕一条链: ``` Cfg 值 = 特化身份 = 渲染出的源码文本 = sha256 缓存 key ``` 一个配置想影响生成的二进制,只有一条路——变成 `EpCfg` 的字段、被渲染进源码文本;而那段文本 整个进了缓存 key。没有第二条通道(没有带外的 `-D`,没有人工拼接的 key 片段),所以「配置变了 但复用了旧二进制」在结构上不成立,而不是靠纪律维持。 三条推论,后面各节都是它们的展开: - **配置是一个具名的 struct 值**,不是一串位置模板参数,也不是预处理器宏(§3.1)。 - **字段列表只有一份**,渲染、schema、apply、describe 都走同一次遍历,漏字段是编译期错误(§3.2)。 - **host 和 device 共用同一份算术**,而 host 侧不需要 hipcc(§3.5)。 ## 2. 分层 ``` Python │ C++ ────────────────────────────────────┼────────────────────────────────── EpDispatchCombineOp(父类) │ ├─ …OpFlyDSL backend="flydsl" │ └─ …OpHip backend="hip" ────┼─► Spec: ep_dispatch / ep_combine arena · scratch · 变体表 · _pick │ │ │ ▼ ctypes:名值对 / 裸指针 │ Cfg 值 = 特化身份 = 缓存 key ──────────────────────────► │ │ Render(cfg) → 源码文本 │ ▼ │ hipcc --genco → 内容寻址缓存 │ │ .hsaco │ ▼ │ GetFunction(Spec::EntryName(cfg)) │ hipModuleLaunchKernel ``` Python 拥有 op 的**状态**(arena、scratch、变体表、routing handle)和调度。 C++ 拥有**一个 kernel 长什么样**(Cfg → 源码 → 编译 → 几何)。 Python 不算发射几何、不拼 kernel 名、不决定一个 Cfg 编成什么;`plan.info` 只能读回 C++ 的决定。 参考过的实现:**CK ck_tile** 的声明式实例模型、**DeepEP** 的内容寻址缓存与目录级原子发布、 **aiter opus_gemm** 的「绑好的 callable + 行为标志」选择器(§5)。 mori 特有的简化:走 `.hsaco` + `hipModuleGetFunction` 而非链接,所以不需要 DeepEP 的符号枚举, 也不需要 CK 的链接期解析。 ## 3. 一个 kernel 的三件套 | | 文件 | 内容 | 谁编译 | |---|---|---|---| | ① Cfg | `include/mori/ops/dispatch_combine_v2/ep_cfg.hpp`(intranode)
`include/mori/ops/dispatch_combine_v2/ep_internode_args.hpp` + `ep_internode_cfg.hpp`(internode) | Cfg + Args + `VisitFields` + 共享几何 constexpr | **两侧**(HIP-free) | | ② body | `src/ops/dispatch_combine_v2/ep_intranode_kernel.hpp`(portable)
`src/ops/dispatch_combine_v2/ep_intranode_1250x.hpp`(gfx125x TDM)
`src/ops/dispatch_combine_v2/ep_internode_kernel.hpp`(internode,八个 pass 各一个 body) | `template __device__ void Body(EpArgs)` | 只有生成的 TU | | ③ Spec | `ep_spec.hpp` / `ep_spec.cpp`(intranode)
`ep_internode_spec.hpp` / `ep_internode_spec.cpp`(internode) | Request + `EntryName` + `RenderSource` + `Geometry` + 注册宏 | host | 新增一个 kernel 就是写这三样。**Python 侧零行**——Plan 类由 C++ 发布的 schema 生成。 框架本身在 `include/mori/jit/v2/` + `src/jit/v2/`(`compiler` / `toolchain` / `spec` / `render` / `util` / `plan_api`),不认识任何具体 kernel。 ### 3.1 Cfg 作为单一模板参数(C++20 structural NTTP) ```cpp template __device__ void EpCombineBody(EpArgs args); ``` `EpCfg` 是一个 structural type,整个 struct 值作为非类型模板参数传进去。kernel 内部所有开关都是 `if constexpr (kCfg.xxx)`,不是 `#if`。 差别不是语法糖。`#if` 的条件来自命令行 `-D`——带外、不进缓存 key、散落在源码各处;`if constexpr` 的条件来自 Cfg 字段——进 key、有类型、有守护、IDE 能跳转。相对于一长串位置绑定的模板参数 (DeepEP 和 CK 都是全 `int` 的位置展开),具名字段还消掉了「调换两个同类型参数照样编过」这一类隐患。 ### 3.2 字段列表的完整性是承重的 渲染器漏一个字段,生成的源码里就没有它,kernel 拿到默认值 → **静默跑错的 kernel**,比缓存失效严重。 ```cpp struct EpCfg { int worldSize = 8; int hiddenDim = 7168; int maxTokPerRank = 128; int numExpertPerRank = 8; int numExpertPerToken = 8; int maxRecv = 0; // 0 = worldSize*maxTokPerRank;同时是 flat index 的 stride EpDType dtype = EpDType::Bf16; int blockNum = 64; int warpPerBlock = 16; int waveSize = 64; // 几何,host 算(§3.5) bool useWeights = true; }; template inline void VisitFields(Self& c, const EpCfg& d, Visit&& v) { #define MORI_FIELD(x) v(#x, c.x, d.x) MORI_FIELD(worldSize); ... MORI_FIELD(useWeights); #undef MORI_FIELD } MORI_JIT_ASSERT_FIELD_COUNT(EpCfg, 12, "加了字段就要同步 VisitFields"); ``` 加字段 = 三处编辑(struct、`MORI_FIELD`、计数),漏掉任何一处都是**编译期红字**。 `FieldCount()` 用 `requires` 递归探测 aggregate 成员数,嵌套 struct 计为 1。 **一次遍历,四个消费者**:`Render`(缓存 key)、`Describe`(`info`)、request schema(发给 Python)、 `EpApplyFields`(请求 → struct)。所以加字段 Python 自动看得见。 **只发非默认字段**:加一个默认不改行为的字段,已有实例的文本不变 → 缓存不失效。若新字段在默认值下 也改了 kernel 代码,文本虽没变但 include 哈希会变,照样重编。两级哈希各管一头。 **`EpArgs` 同理**。它的 wire schema 由一份 `MORI_EP_ARGS_FIELDS(X)` 生成,并按同一顺序取 `offsetof` 断言递增。只对 `sizeof` 设防不够:24 个字段里 12 个是裸指针,调换两个同类型字段所有尺寸校验都过, kernel 安静地读错 buffer。按 schema 顺序取的 offset 序列一旦不再递增,就是编译期错误。 ### 3.3 生成的源码 `RenderSource` 产出的就是完整的一个 TU: ```cpp // mori jit v2 — generated, do not edit. #include "src/ops/dispatch_combine_v2/ep_intranode_kernel.hpp" using namespace mori::ops::v2; constexpr EpCfg kCfg = EpCfg{.hiddenDim=2048, .maxTokPerRank=512, .maxRecv=4096}; using TokT = hip_bfloat16; extern "C" __global__ void __launch_bounds__(EpBlockThreads(kCfg)) mori_ep_dispatch_bf16_ws8_h2048_k8_64x16(EpArgs args) { EpDispatchBody(args); } ``` - **入口名由 `Spec::EntryName(cfg)` 生成**,描述哪个 kernel、哪个 body、什么 dtype、形状和几何—— 否则 profile 里 dispatch、combine 和调优表为每个 token 档选出的每组几何全是同一行。 要守的不变量不是「名字是常量」而是「**Python 不拼 kernel 名**」:名字在 C++ 里只写一处, 渲染器把它写进源码、`Prepare` 把**同一个字符串**交给 `hipModuleGetFunction`,改名不可能漏改查找。 名字里的字段都已在 Cfg 里,不给缓存 key 增加新维度。不覆盖 `EntryName` 的 Spec 退回匿名的 `mori_jit_entry`。 - **文本即 key** → 配置不可能不进 key。 - **`__launch_bounds__` 是表达式不是数字** → 由 host/device 共用的同一个 constexpr 算出。 - **arch 路由在 host 侧**:include 哪个 body、调哪个函数,由 `RenderSource` 按 `GetToolchain().arch` 决定——gfx125x 渲染 `ep_intranode_1250x.hpp` 的 TDM 版本,其余渲染 portable 版。不是 device `#if`:渲染时看到的 arch 就是 `--offload-arch` 的 arch,两侧不可能不一致;而这个 选择写在源码文本里,所以自动进 key,两种 body 各有自己的缓存条目。 **为什么生成不能放 Python**:Cfg 同时是 NTTP 类型,Python 渲染就要自己维护一份字段列表和默认值 → 两张清单的问题原样回来。几何算术同理——device 侧也要调它。 ### 3.4 缓存 ``` ~/.mori/jit/_/kernel../{kernel.hip, kernel.hsaco} hash = sha256(name $$ hipcc签名 $$ nic $$ flags $$ include哈希 $$ 源码文本) ``` > **⚠️ 开发期陷阱:JIT 默认不编译你的 `src/`。** `DetectSourceRoot()` > (`src/jit/v2/toolchain.cpp`)按 `MORI_SOURCE_ROOT` → **`.so` 旁边的 `_jit-sources`** → > `MORI_JIT_SOURCE_DIR` 的顺序解析。`python/mori/_jit-sources/` 是 `setup.py` 在 > pip-install 时做的一份**实拷贝**,editable 安装下它优先命中——于是改 > `src/ops/**/*.hpp` 或 `include/**` 对 JIT **无效,且静默**:include 哈希也是对那份陈旧 > 拷贝算的,连缓存目录名都不变。诊断办法是往头文件末尾加 `#error`、清掉 > `~/.mori/jit/_`、重编,若仍然成功就说明编的不是你的树。 > 开发时导出 `MORI_SOURCE_ROOT=`;`pip install -e .` 也能刷新那份拷贝, > 但下次编辑又会过期,环境变量不会。 `include 哈希`是对 `SourceDeps()` 列出的目录做一次排序递归遍历,把每个头文件的**相对路径和内容** 都摘进去。粗粒度是刻意的:它可能过度失效,但不会漏失效。EP 的依赖集是 `include/mori`、`src/ops/dispatch_combine_v2`、`src/cco`。 发布无锁(抄 DeepEP):编到 `tmp//` → 递归 fsync → 目录级 `rename`。抢输的删自己的、用赢家的 ——赢家的内容一定字节相同,因为目录名就是内容哈希。 `_` 只为人读,正确性不依赖它,两者都已在摘要里。**NIC 必须进 key**:intranode LSA 不链 NIC 相关的东西,但设备侧 GDA 会——`libmori_cco_device.bc` 本身就按 arch+NIC 现编。NIC 是进程级事实, 读 `MORI_DEVICE_NIC`(与 CMake、`python/mori/jit/config.py` 同一个权威),不在 JIT 层另做探测。 > **纪律:凡是以二进制形式进入编译的东西,哈希它的字节,不是它的路径。** > `-mlink-builtin-bitcode=.../libmori_cco_device.bc` 的路径会随 flags 进 key,但那份 `.bc` 的 > 内容不会——而它自己就是 JIT 产物。GDA 落地时必须按 include 哈希同样的办法处理。 进程内还有一层 module 缓存,按 `(device, 缓存目录)` 索引:`hipModuleLoad` 绑定调用线程的当前设备, 一个进程可能驱动多张卡。 ### 3.5 host/device 常量只有一份,且 host 不需要 hipcc | 层 | 内容 | 谁编译 | |---|---|---| | Cfg + 共享算术 | `ep_cfg.hpp` | **两侧**:host `g++`,device `hipcc -std=c++20` | | JIT 运行时 | `compiler`/`toolchain`/`spec`/`plan_api` | 普通 C++ 编译器(只用 `hip_runtime_api.h`) | | kernel body | HIP intrinsic、`__global__` | 只有**生成的 TU** | `mori_jit` 和 `mori_ops_v2` 只链 `hip::host`,构建期不需要 hipcc——需要它的时刻是运行期第一次 `Prepare`。三条约束保证这点,而违反了照样编过、只是悄悄把 host 拖进 hipcc,所以有 CI 守护 (`tools/jit_v2/check_host_device_split.sh`,已接入 ctest 的 `jit_host_device_split`): 1. Cfg 头**不得 include 任何 HIP 头**。dtype 用 `enum class EpDType` 标签,渲染时才展开成真实类型名。 2. 共享算术用**无属性 `constexpr`**,不写 `__host__ __device__`——后者会强制 host TU 走 hipcc。 DeepEP 的 `TokenLayout` 是 `__device__ __host__` 的(它 host 侧本来就过 nvcc),这点不能照抄。 3. host 侧不用 C++20 指派初始化。 **C++20 的范围**:`FieldCount` 的探测必须用 requires-expression——C++17 下 gcc 和 clang 都把 "初始化器过多"当硬错误而非替换失败。所以持有 Cfg 的三个目标(`mori_jit`/`mori_ops_v2`/`test_jit_core`) 按 C++20 编,生成的设备 TU 无条件 C++20,mori 其余部分保持 C++17。**"host 不需要 hipcc"不受影响**。 ```cpp // ep_cfg.hpp —— 两侧共用 constexpr int EpBlockThreads(const EpCfg& c) { return c.warpPerBlock * c.waveSize; } constexpr int EpMaxRecv(const EpCfg& c); // 也是 flat index 的 stride constexpr bool EpCfgIsValid(const EpCfg& c); // 编不出来的 Cfg 是 host 侧错误 // ep_spec.cpp —— host 侧,arch 默认集中在这里 EpCfg MakeEpCfg(const std::string& arch, const EpRequest& req, EpKernelKind kind) { c.waveSize = mori::jit::v2::WaveSizeForArch(arch); // gfx12* = 32,其余 64 c.blockNum = 64; // dispatch 搬运,combine 归约 c.warpPerBlock = isDispatch ? 16 : 8; ... // env 覆盖,唯一读环境变量的地方 } ``` `MakeEpCfg` 也是拒绝非法组合的地方:`EpCfgIsValid` 不过就抛(token 字节数必须 16B 对齐、 topk 和 worldSize 必须装进一个 wavefront、recv 容量必须覆盖最坏情况),combine 那条腿上的字节类型 单独拒绝(§3.7)。一个编不出来或跑错的 Cfg 是构造期错误,不是运行期的错误数字。 ### 3.6 环境变量 `MakeEpCfg` 末尾是**唯一**读 `MORI_V2_EP_*`(几何覆盖)的地方,而且只做覆盖不做决定。覆盖后的 Cfg 照常进 key,所以拿环境变量做 A/B 不会复用到对方的二进制。 工具链层另有一组只影响解析的变量:`MORI_JIT_ARCH`、`MORI_JIT_HIPCC`、`MORI_SOURCE_ROOT`、 `MORI_JIT_CACHE_DIR`、`MORI_JIT_EXTRA_FLAGS`(进 flags,所以进 key)、`MORI_DEVICE_NIC`, 以及两个诊断用的 `MORI_JIT_VERBOSE` / `MORI_JIT_KEEP_FAILED`。 ### 3.7 dtype 是传输属性,不是算术属性 dispatch 只**搬运**它的载荷(gfx9xx 是 `WarpCopy`,gfx125x 是一发 TDM tile),从不对元素做算术。 combine 要**归约**。这个不对称决定了 dtype 怎么建模: ```cpp enum class EpDType : int { Bf16 = 0, Fp32 = 1, Fp8 = 2, Fp4x2 = 3 }; ``` `Fp8` 和 `Fp4x2` 都是传输类型,都渲染成 `unsigned char`、都是一字节一**元素**,编出来的 kernel 体 逐字节相同。分成两个枚举值只为了让 kernel 名说得出是哪一个:`fp4x2` 的一个元素是 2 个 e2m1,所以 hiddenDim 在那里数的是**字节**、仍由调用方减半。共用一个标签的话 `_h3584` 既可能是 fp8@3584 也可能是 fp4@7168,profile 上分不出来。所以支持 fp8/fp4 dispatch **没有新增 kernel**,只是打通 dtype 这条链。`MakeEpCfg` 在 combine 那条腿上拒绝这两个——它要求和, 字节类型在那里能编过但会静默把字节加起来。 两条腿本来就是两个独立的 Plan,各带各的 dtype 和元素数,所以「非对称」(fp8/fp4 进、bf16 出)不是 一个特例路径,就是两个普通 kernel。arena 按 `token_nbytes` / `combine_token_nbytes` 分别定尺寸—— 这是 fp4 唯一正确的算法。mori 在这里**不做量化**:载荷进来时就已经是打包好的。 ## 4. op 层 op 在 Python,因为它持有的全是状态。C++ 侧没有对应的类。 ```python class EpDispatchCombineOp: # dispatch_combine_op.py —— 后端无关 + 入口 def __new__(cls, cfg, comm) # 按 cfg.kernel_backend 分发到子类 def dispatch(self, input, weights, scales, indices, *, routing=None, return_routing=False) def combine(self, input, weights=None, indices=None, *, routing) def _pick(self, num_tokens) # 在**已编好的**变体里挑 def reset(self) / close(self) ``` 对称内存由 `SymmArena` 管:一个 cco window 按 256B 对齐切成具名 region,peer 的同名 region 用 `cco.Window(handle).lsa_ptr(pe, off)` 取。两个后端共用同一套 region 名,所以任一后端的 kernel 都能读另一方布出来的 arena。 三条时序约束,顺序不能换: ``` comm → arena → 每变体一个 Plan(编译)→ launch ↑ graph capture 必须在这之后 ``` - **arena 在编译前**:kernel 要 window handle,它只有 arena 建好才存在。 - **编译在 graph 捕获前**:捕获期不能 fork hipcc。所以 `Prepare`/`Launch` 是两个接口, 而不是 DeepEP 那样每次 launch 都 generate+build 靠内存 map 兜。 - **变体表在 `_pick` 前编全**:`_pick` 只查表并 clamp 到真编出来的变体,关键路径上绝不触发编译。 与 opus_gemm 那条「启发式能返回的 kid 必须在编译子集里」是同一个不变量,只是靠 map 查而非 codegen 期 assert。 捕获已实测(`tests/python/ops/dispatch_combine_v2/test_graph_capture.py`):dispatch → identity expert → combine 整步捕成一张图,replay 反复复现同一结果。能成立还差一个条件,而它在 kernel 里: **跨设备 barrier 的 epoch 由设备侧自增**(dispatch 结尾 `atomicAdd(xdbFlag, 1)`)。若由 host 算好写 下去,捕获会把一个定值冻进图里,第一次之后每次 replay 要么空转要么白过——这正是那个测试要抓的东西, 另一个是 op 里混进 host 同步(`.item()`/`.cpu()`)会直接让捕获失败。 ## 5. 两个后端共用一个 op `EpDispatchCombineOp(cfg, comm)` 按 `cfg.kernel_backend`(或 `MORI_V2_KERNEL_BACKEND`,默认 `flydsl`)选出子类;也可直接实例化 `EpDispatchCombineOpHip`,`isinstance` 对两者都成立。 **父类拥有 op 本身**——`dispatch()`/`combine()`/`_pick`/视图/生命周期各只有一份。子类填三样: ```python _regions(cfg) # 这个后端要哪些 arena region _build_kernels(cfg, arena) # 编好的变体 + KernelSet _unsupported(cfg) # 它做不到什么 ``` **行为差异是数据,不是 override。** `KernelSet` 上挂 `stages_in_kernel`(combine 的暂存拷贝在 kernel 内还是 host 侧)、`self_resets_counters`、`capabilities` / `unsupported`。形状抄自 aiter 的 `MOEMetadata`——但不抄它事后拆 `functools.partial` 反查身份那一手,那是把身份丢掉又猜回来。 唯一没法变成数据的是**调用约定**,所以它被吸收进 `_build_kernels`:返回的 callable 已经是父类的 具名签名,FlyDSL 的位置参数和 ctypes plan 的关键字参数各自在 `_wrap_*` 里适配。 **特性面是真包含不是交集**:HIP 后端做 gather combine,dispatch 收 bf16/fp32/fp8/fp4、combine 出 bf16/fp32(§3.7),其余一概不接:scatter、量化(`quant_type`,与直接传已量化载荷是两回事)、 StdMoE、per-token scales、routing replay、`local_expert_count`。能力门在**构造期**跑、且在分配 arena 之前——问它要 scatter 会当场报错并列出缺什么,而不是发射时给出错误数字,也不会先占住一个对称 window 再失败。 `_regions` 归子类还消掉一个陷阱:FlyDSL 的 `off_out_tok` 对 dispatch 指 `disp_out`、对 combine 指 `out_tok`,任何共享的 offset 传递路径都会静默接错 buffer。HIP 后端的 internode 布局又是另一张表, 在 `internode_regions.py`:16 个具名 region,加上 `scale_dim > 0` 时才有的 `out_scales` 共 17 个, 尺寸按 v1 的分配顺序逐条转写而不是重新推导。 **选后端只 import 那一个**:`dispatch_combine_op` 不 import 任何后端,选 `hip` 不会拉进 flydsl, 所以它在没装 FlyDSL 的机器上能跑。 ### 5.1 几何调优表:每个后端一份,dispatch 和 combine 再分开 调优表也归后端。`hip_tuning_configs.py` 不 import FlyDSL 的 `tuning_configs`——不同 kernel、不同最优点, 借过来只会得到一个看起来合理的错答案。 表内再拆成两张:`_DISPATCH_TABLE` 带 dtype 键,`_COMBINE_TABLE` 不带。因为 **combine 根本不依赖 dispatch 的 dtype**——它只归约 bf16/fp32 的暂存区,不管前面搬来的是什么。合成一张表就必须给 combine 挂一个假的 dtype 轴,然后维护三份相同的答案。`lookup()` 把两张表合成 op 要的那条 schedule,能处理两边 桶边界不对齐的情况,所以任一半都能单独重调。 键是 `(world_size, hidden_dim, topk, experts_per_rank)`,值是一串 `(max_tok_inclusive|None, block, warp)` 的桶。实测结论直接编码在键里:**topk 会移动桶边界** (它决定 `_tpi = warpSize/topk`,也就是一个几何在多少 token 内能一轮盖完),**专家数不会** (64 vs 96 在所有几何/dtype/topk 上都在 2% 以内),所以后者用 `None` 通配——那个通配是个结论, 不是省事。没扫过的形状拿不到 schedule,退回单档默认值。 取值规则:**性能接近时取更小的几何**,因为少占 CU 在与专家 GEMM 重叠时是真收益。这类 3% 以内的取舍是 **策略不是测量**(单次 bench 偶尔会偏 20%);真正由数据定的是桶的**边界**,它们来自 10~40% 的差异。 internode 再是第三张表 `internode_tuning_configs.py`,键是 `(device_key, world_size, hidden_dim, topk)` 再按 dispatch 的 dtype 分档,值是一串 `(max_tok_inclusive|None, disp_block, disp_rdma, disp_warp, comb_block, comb_rdma, comb_warp)`。它和上面两张的差别在 `rdma_block_num`:这一维把 grid 切成 RDMA 一半和 node 内一半,kernel 对它有分支,所以**一套 pass 序列是按几何整套编出来的**,而不是编一份、launch 时挑; `HipBackend._internode_geometry_buckets` 在构建期把整张表走一遍,运行时的解析因此永远不会触发编译。也因为 dispatch 和 combine 在同一个 token 数、同一块 arena 上调到不同的 rdma/warp,一个 bucket 一套几何表达不了, 表里每档都是**成对**的行——不要单独重调其中一相。唯一硬不变量还是 `block_num <= CU 数`,`lookup()` 会 clamp。 目前只有 MI308X EP16 / hidden 6144 / topk 8 一行是实测过的。 ## 6. Python 绑定 C ABI 是**十一个符号,对所有 kernel 永久有效**: ``` mori_jit_plan_create / _launch / _launch_multi / _destroy / _info mori_jit_plan_args_schema / _args_size / _request_schema mori_jit_precompile / mori_jit_registered_plans / mori_jit_last_error ``` `_launch_multi` 是批量发射:N 个共享同一 args 布局的 plan,填一次参数结构、穿一次 ABI, 然后按序发射(C++ 侧就是对同一个 `argBuf` 循环 `vt->launch`,不认识任何具体 kernel)。 EP 的 internode 序列一次 dispatch 是 2 个 pass、一次 combine 是 4 个,本来要穿 6 次。 Python 侧对应 `plan_api.LaunchGroup`:**plan 集合固定时把校验和 handle 数组挪到构建期**, 发射路径只剩填参数和那一次 ABI 调用。「共享同一 args 布局」按完整的 `(name, offset, size)` 序列比对,不是只比 `sizeof`——理由见 §3.2:internode 的 args 有 37 个字段, 其中 14 个是裸指针,调换两个同类型的所有尺寸校验都过,然后按 `plans[0]` 的 schema 填、发给其余 plan, 静默读错 buffer。代价是 group 缓存了 handle:显式 `close()` 一个 plan 会让它失效, 而发射路径不再逐次检查——这正是省下来的那部分。 | | 机制 | |---|---| | **请求** | 以 `(name, value)` 对穿过边界。**没有结构体要声明两次**;未知字段名报错(拼错的旋钮悄悄不生效,正是测量描述错二进制的成因),缺失字段取 C++ 默认 | | **参数** | C++ 发布 schema(字段表 + `sizeof`),Python **据此构造** ctypes 结构并断言大小。只有一份声明 | 十一个入口全部 `noexcept`:异常穿过 `extern "C"` 进 ctypes 是 UB,实测会不留 traceback 地干掉解释器。 所以每个入口都 catch,并按 ABI 的方式报告——返回 null/负值,细节留在 `mori_jit_last_error()`。 通用绑定 `mori.jit.v2.plan_api`(即 libmori_jit.so 的 `mori_jit_*` C ABI 的 Python 侧,与 `src/jit/v2/plan_api.cpp` 对应)里没有任何 kernel 名字:`make_plan(kernel)` 由 schema 生成 Plan 类。 EP 专用的只有薄 shim `ops/dispatch_combine_v2/ep_plans.py`——它 `load_library("libmori_ops_v2.so")` 触发注册,再暴露 intranode 的 `EpDispatchPlan`/`EpCombinePlan`,以及 internode 的 `EP_INTERNODE_PLANS`:按 pass 名(`copystaging` / `dispatch` / `dispatch_ll` / `combinesync` / `combinesyncbarrier` / `combine` / `combine_ll` / `combineall`)索引的八个 plan,因为它的一次 dispatch/combine 就是一串 pass,每个 pass 各是一个模块。约定两条:标为 `e` 的字段接受 dtype 名或 torch dtype;launch 参数里 `off` 是 arena region 偏移,传 `arena=` 就绑定一次。internode 的枚举不走 通用的 `DTYPES`,而是 `make_plan(..., enums=...)` 传进去的两张自己的表 `INTERNODE_DTYPES` / `INTERNODE_QUANT_TYPES`,取值必须与 C++ 侧 v2 自己的 `EpInterNodeDType` / `EpQuantType` 一致 (同 §7 的那条:独立编号 = 静默给错 kernel)。 **为什么是 ctypes**:边界只传指针和标量,没有类型转换可做。pybind11 的代价是编译期(aiter 实测 libtorch+pybind11 的设备 pass 解析 ~15s)。绑定层里**没有 `import torch`**,靠鸭子类型取 `.data_ptr()`。 **交叉编译是进程级模式**:工具链只解析一次 arch,`arch=` 在那之前经 `SetArchOverride()` 落到一个 带锁的进程变量(不是 `setenv`——解析侧用 `getenv` 读,两线程并发建 plan 就是 POSIX 数据竞争); `MORI_JIT_ARCH` 仍作为静态覆盖有效。与已解析的不一致会**报错**,不是静默地"按 A 渲染、按 B 编译"。 ## 7. 几个有证据的决定 **arena offsets 和 rank 是运行时参数,不是 Cfg 字段。** | | dispatch 128/512 tok | |---|---| | 运行时参数 | 48.0 / 131.3 µs | | 编译期常量 | 49.5 / 130.6 µs | 噪声以内,而代价实打实:进 Cfg 就进 key,于是每套 arena 布局、每个 rank 各编一份二进制——一次 8 卡 run 新增 16 份而不是 2 份,AOT 也无从谈起。旁证:gfx942 上 8 条载入的微基准测出朴素常量化是**负收益** (VGPR 9→22,编译器丢掉"基址均匀",把地址重新物化成向量形式;VGPR 通常才是占用率瓶颈)。 **`EpDType` 的枚举值必须与绑定的 dtype 表数值一致。** `e` 标签的字段以裸整数过边界,而 `mori.jit.v2.plan_api.DTYPES` 是一张表管所有 kernel 的所有枚举字段。独立编号 = 静默给错 kernel。 **AOT 预编译暂时没有。** 发射几何来自 Python 的调度表,所以 C++ 侧的预编译表永远渲染不出活的 op 会渲染的 Cfg。要预热缓存,就是构建期把 op 构造一次。单实例编译约 1.6s,这个量级才是按需编译成立的前提。 ## 8. 性能 8×MI355 gfx950,EP8,hidden=7168 topk=8,eager,µs / 128·512·4096 tok/rank: | | hip | flydsl | |---|---|---| | dispatch | **47.5** / 128.8 / 857.4 | 76.0 / **127.8** / 857.5 | | combine | **57.8** / **124.4** / **781.8** | 82.7 / 133.6 / 888.8 | 4096 上 combine 快 12%,差在暂存拷贝:FlyDSL 在 kernel 前单独发一发 311MB 的 torch copy, HIP 在 kernel 内做、与 barrier 等待重叠——104µs 变 ~1µs。 4×gfx1250(MI450)EP4,hidden=7168 topk=8,对照 v1 `tools/ep_test.sh` 的出厂默认,µs: | tok/rank | 64 | 512 | 2048 | 4096 | 16384 | |---|---|---|---|---|---| | dispatch v1 → v2 | 80.9 → **73.1** | 82.8 → **74.7** | 104.6 → **97.4** | 171.4 → **152.0** | 584.5 → **519.6** | | combine v1 → v2 | 26.9 → **21.3** | 41.7 → **36.9** | 96.7 → **83.8** | 169.6 → **146.4** | 588.7 → **523.0** | 每档都快(dispatch 7~12%,combine 11~23%)。同几何下的那部分来自 Cfg 是编译期 NTTP:ISA 里整数除法 dispatch 41→3、combine 57→10(`/maxRecv`、`%numExpertPerRank` 变成移位),combine 的 load 数 225→120 (peer 地址是 window 算术,不是穿过 `SymmMemObj` 的三次相关联 load)。其余来自调优表。 窄 dtype 的收益更大:4096 档 dispatch bf16 156.6 → fp8 98.5 → fp4 80.0,16384 档 fp4 是 bf16 的约 3 倍快。 ## 9. 移植来源与覆盖范围 kernel 从 `origin/main` 的 intranode MoE kernel 移植(bf16 gather 路径)。移植是机械的,因为 v1 的 **整个**对称内存面只有两样:`SymmMemObjPtr::GetAs(pe)`(30 处,两次相关联的 load 查表)和三个 不带任何 shmem 状态的自旋等待。于是 ``` memObj->GetAs(pe) → ccoGetLsaPeerPtr(win, pe, args.offRegion) ``` 13 个 `SymmMemObjPtr` 塌成一个 window handle + 8 个 offset。生成的 TU 不 include `mori/shmem`, 所以没有设备全局变量、不需要 per-module init。 gfx125x 的 TDM body 同样是机械移植(`intranode_1250x.hpp` → `ep_intranode_1250x.hpp`):v1 的 cco 路径 本来就把每块对称 buffer 从同一个 LSA window 里 bump 分配,和 SymmArena 是同一个内存模型,源码里的 `shmem` 命名空间只是那层抽象加几个与后端无关的自旋。TDM 那套机器(`amd_gfx1250_TDM.h` builtin、 `_cusplit_*` 设备全局暂存池)原样保留。范围收到 hip 后端能服务的部分:非量化的 TDM dispatch + PULL/QUAD gather combine。 **HIP 后端未实现**(由能力门在构造期拒绝):scatter combine、`quant_type` 量化、StdMoE、per-token scales 转发、routing replay、`local_expert_count`。这些在 FlyDSL 后端都有。注意 fp8/fp4 **dispatch** 已经支持(§3.7)——那是搬运已量化的载荷,和让 mori 自己量化的 `quant_type` 是两回事。 **cross-node 也有了**:`src/ops/dispatch_combine_v2/` 现在是三个 body 头文件——两个 intranode (portable / gfx125x TDM)加一个 `ep_internode_kernel.hpp`,后者在一个头里放八个 pass 的 body, 对应 `ep_internode_spec.cpp` 注册的八个 Spec / 八个 plan(`ep_internode_` + `copystaging` / `dispatch` / `dispatch_ll` / `combinesync` / `combinesyncbarrier` / `combine` / `combine_ll` / `combineall`)。一轮 dispatch 发 copystaging + dispatch 或 dispatch_ll,一轮 combine 发 combinesync + combinesyncbarrier + combine 或 combine_ll + combineall。 选择仍然没有 `kernel_type` 枚举,但有两级选择器:走不走 internode 由 `gpu_per_node < world_size` (`EpDispatchCombineConfig.is_internode`)决定,只有 `hip` 后端实现它,FlyDSL 后端在能力门里直接 报错;走哪一族由 `internode_kernel = auto | v2 | v2_ll` 决定。`v2` 和 `v2_ll` 是**两族独立 kernel**——独立的 JIT 模块、入口符号和缓存 key,不是一个 body 的两个分支——所以点名一族就只编那一族; `auto`(默认)两族都编,按 launch 的 token 数在 `internode_auto_ll_max_tokens`(默认 512)处切换, 也只有它能在运行时切。 ## 10. 测试 | | 位置 | 覆盖 | |---|---|---| | `jit_core`(ctest) | `tests/cpp/jit/test_jit_core.cpp` | 渲染、字段计数、缓存 key、入口名、schema;用假工具链,不碰 GPU | | `jit_host_device_split`(ctest) | `tools/jit_v2/check_host_device_split.sh` | Cfg 头仍能不经 hipcc 编译 | | `test_jit_binding.py` | 单 rank,不需要 communicator | ctypes/C-ABI 缝:schema vs `sizeof`、请求强制转换、未知字段拒绝 | | `test_op.py` | EP8,`MORI_V2_KERNEL_BACKEND=hip` 切后端 | op 层对着两套 kernel 各跑一遍 | | `test_ep_backend_parity.py` | EP8,一个进程内两个后端 | 同一输入逐元素比对 | | `test_graph_capture.py` | EP8 | dispatch → identity expert → combine 整步捕成一张图并 replay | | `test_asym_dtype.py` | EP8 | fp8/fp4 dispatch + bf16 combine | | `test_internode_regions.py` | 单进程,不建 op、不碰 GPU | internode arena:region 名与 `_internode_static_args` 的契约(两侧独立转写),以及 kernel 索引算术蕴含的容量上界 | | `test_dispatch_combine_v2_internode.py` | 2 节点 × 8 GPU(EP16),torchrun 起,`gpu_per_node < world_size` 才建得起来 | identity expert 对解析 golden 的逐 rank 正确性;`--cmd bench` 对齐 v1 harness 的计时循环,`--cmd tuning` 是 `internode_tuning_configs` 的配对式 sweep;`--kernel-type auto\|v2\|v2_ll` | | `bench_ep.py` | EP4/EP8 | 性能 bench(各后端通用);`DBN`/`DWPB`/`CBN`/`CWPB` 钉住几何即用于 `hip_tuning_configs` 调优 | `test_jit_binding.py` 在 CI 里**从 `/tmp` 跑**,不是从 checkout 跑:建一个 plan 需要 `libmori_jit.so`、`libmori_ops_v2.so` 和 v2 的 kernel 源码,而在仓库目录下这三样都能从 `build/` 和 `src/` 解析到——哪怕安装包一样都没带。只有在仓库外,答得上来的才是真正打进包里的那份。