Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions docs/en/dev/language/01-external-kernels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Integrating Hand-Written C++ Kernels

PyPTO can call an **existing hand-written C++ InCore kernel** from a
PyPTO-authored orchestration, without going through PyPTO's tile codegen. You
declare a *signature-only function header* in the DSL; the orchestration calls
it like any InCore kernel, but the compiler skips codegen for its body and
compiles the referenced `.cpp` as the kernel instead.

Use this when you already have a tuned AICore kernel (e.g. a bespoke attention
kernel) and want to drive it from a PyPTO orchestration, reusing PyPTO's
task scheduling, dependency analysis, and runtime dispatch.

## Contract

An external kernel is a normal InCore kernel from the runtime's point of view,
so the hand-written source must meet the same ABI PyPTO-generated kernels do:

- Export a single `extern "C" void kernel_entry(__gm__ int64_t* args)` entry
(one entry `.cpp` per kernel; the runtime dispatches by `func_id`, the symbol
is fixed). This is the same entry PyPTO's own generated kernels export.
- The declared **parameter order and directions** (`pl.Out` / `pl.InOut`) must
match how the kernel reads its arguments — the orchestration builds the task
payload (`add_input` / `add_inout` / `add_output`) from the declaration.

The declaration carries only the signature; its body is a bare `...`.

### Multi-file kernels

`external_source` names the single entry `.cpp` (the one exporting
`kernel_entry`). That file may `#include` any number of sibling files —
PyPTO **references it at its original path** (it does not copy it), so
relative includes resolve against the original tree. A kernel laid out as

```text
my_kernel/
aic/entry.cpp # external_source; #include "../kernel/impl.cce"
kernel/impl.cce # #include "../tiling/params.h"
tiling/params.h
```

works unchanged — point `external_source` at `aic/entry.cpp` and the whole
`../kernel/` / `../tiling/` include chain is picked up at compile time.
Headers on the runtime include path (e.g. `tensor.h`, `intrinsic.h`,
`pto/pto-inst.hpp`) resolve as usual.

## `@pl.program` route

Declare the kernel with `@pl.function(type=AIC/AIV, external_source=...)` and an
empty body. A mixed **AIC + AIV** kernel (one `MixedKernels` submit) is a
`pl.FunctionType.Group` of one AIC member and one AIV member:

```python
from pathlib import Path
import pypto.language as pl

KDIR = Path(__file__).parent / "kernels"

@pl.program
class PagedAttention:
@pl.function(type=pl.FunctionType.AIC, external_source=KDIR / "aic/pa.cpp")
def PA_AIC(self, query: pl.Tensor[[B, H, D], pl.FP16], ...,
out: pl.Out[pl.Tensor[[B, H, D], pl.FP16]], ...
) -> pl.Tensor[[B, H, D], pl.FP16]:
... # body lives in aic/pa.cpp

@pl.function(type=pl.FunctionType.AIV, external_source=KDIR / "aic/pa.cpp")
def PA_AIV(self, ...same signature...) -> ...:
...

@pl.function(type=pl.FunctionType.Group)
def PA(self, ...same signature...) -> ...:
r = self.PA_AIC(...) # defines the group members
self.PA_AIV(...)
return r

@pl.function(type=pl.FunctionType.Orchestration)
def entry(self, query, ..., out):
# build tiling / workspace tensors here (host-side), then dispatch:
out = self.PA(query, ..., out) # -> MixedKernels submit
return out
```

`external_source` accepts an absolute path or one relative to the file that
defines the program. The AIC and AIV members may point at the **same** source
(compiled once per core) or different files.

For a single-core kernel, declare just one `AIC` or `AIV` function and call it
directly from the orchestration (no group).

## `@pl.jit.extern` route

Under `@pl.jit`, declare the kernel with `@pl.jit.extern`. A `core_type="mixed"`
kernel auto-expands to the AIC + AIV + Group form above:

```python
@pl.jit.extern(core_type="mixed",
aic_source="kernels/aic/pa.cpp",
aiv_source="kernels/aic/pa.cpp")
def pa(query: pl.Tensor[[B, H, D], pl.FP16], ...,
out: pl.Out[pl.Tensor[[B, H, D], pl.FP16]], ...
) -> pl.Tensor[[B, H, D], pl.FP16]: ...

@pl.jit
def decode(query: pl.Tensor, ..., out: pl.Out[pl.Tensor]):
out = pa(query, ..., out) # dep discovered automatically
return out
```

Single-core form: `@pl.jit.extern(core_type="aic"|"aiv", source="k.cpp")`.

Paths resolve relative to the file defining the kernel. Editing the referenced
`.cpp` changes the JIT cache key, so a kernel change triggers recompilation even
though the Python stub is unchanged.

## What the compiler does

- **Passes**: the header-only function survives the pass pipeline unchanged
(its empty body is a no-op for tile passes); it is exempt only from the
`ReturnParamsExplicit` property, which requires a `ReturnStmt` a header cannot
have.
- **Orchestration codegen**: assigns the kernel a `func_id` and emits the submit
exactly as for a DSL kernel — a single AIC/AIV `rt_submit_*_task`, or a
`MixedKernels{aic_id, aiv_id, ...}` + `rt_submit_task` for a group.
- **Backend**: skips ptoas for the external kernel and lists it in the
generated `kernel_config.py` manifest like a generated kernel (`func_id`,
`core_type`, `signature`), but with `source` pointing at the original
hand-written `.cpp` (referenced in place, so its sibling files stay
reachable) rather than a copy under the artifact dir.

## Restrictions

- `external_source` is only valid on `FunctionType.AIC` / `FunctionType.AIV`.
- The body must be a bare `...` (signature only).
- A `Group` must be all-external or all-DSL — mixing external and DSL members in
one group is rejected.
125 changes: 125 additions & 0 deletions docs/zh-cn/dev/language/01-external-kernels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 集成手写 C++ Kernel

PyPTO 可以从 PyPTO 编写的 orchestration(编排)中直接调用一个**已有的手写 C++
InCore kernel(核内核函数)**,而不经过 PyPTO 的 tile 代码生成。你在 DSL 里声明一个
*仅有签名的函数头*;orchestration 像调用普通 InCore kernel 那样调用它,但编译器会
跳过其函数体的代码生成,转而把所引用的 `.cpp` 编译为该 kernel。

适用场景:你已有一个调优过的 AICore kernel(例如定制的 attention kernel),希望用
PyPTO orchestration 驱动它,复用 PyPTO 的任务调度、依赖分析与运行时派发。

## 契约(Contract)

从运行时的角度看,外部 kernel 就是一个普通 InCore kernel,因此手写源码必须满足与
PyPTO 生成 kernel 相同的 ABI:

- 导出唯一的 `extern "C" void kernel_entry(__gm__ int64_t* args)` 入口(一个入口
`.cpp` 一个 kernel;运行时按 `func_id` 派发,符号名固定)。这与 PyPTO 自身生成
kernel 导出的入口完全一致。
- 声明的**参数顺序与方向**(`pl.Out` / `pl.InOut`)必须与 kernel 读取参数的方式一致
—— orchestration 会依据声明构建任务负载(`add_input` / `add_inout` /
`add_output`)。

声明只携带签名,函数体为一个裸的 `...`。

### 多文件 kernel

`external_source` 指向唯一的入口 `.cpp`(导出 `kernel_entry` 的那个)。该文件可以
`#include` 任意数量的兄弟文件 —— PyPTO **在原始路径引用它**(不做拷贝),因此相对
include 会相对原始目录树解析。形如下面这样的 kernel:

```text
my_kernel/
aic/entry.cpp # external_source; #include "../kernel/impl.cce"
kernel/impl.cce # #include "../tiling/params.h"
tiling/params.h
```

无需改动即可使用 —— 把 `external_source` 指向 `aic/entry.cpp`,整条 `../kernel/` /
`../tiling/` include 链会在编译时被拉入。位于运行时 include 路径上的头文件(如
`tensor.h`、`intrinsic.h`、`pto/pto-inst.hpp`)照常解析。

## `@pl.program` 写法

用 `@pl.function(type=AIC/AIV, external_source=...)` + 空体声明 kernel。混合的
**AIC + AIV** kernel(一次 `MixedKernels` 派发)表示为一个 `pl.FunctionType.Group`,
其成员为一个 AIC 与一个 AIV:

```python
from pathlib import Path
import pypto.language as pl

KDIR = Path(__file__).parent / "kernels"

@pl.program
class PagedAttention:
@pl.function(type=pl.FunctionType.AIC, external_source=KDIR / "aic/pa.cpp")
def PA_AIC(self, query: pl.Tensor[[B, H, D], pl.FP16], ...,
out: pl.Out[pl.Tensor[[B, H, D], pl.FP16]], ...
) -> pl.Tensor[[B, H, D], pl.FP16]:
... # 函数体在 aic/pa.cpp 中

@pl.function(type=pl.FunctionType.AIV, external_source=KDIR / "aic/pa.cpp")
def PA_AIV(self, ...same signature...) -> ...:
...

@pl.function(type=pl.FunctionType.Group)
def PA(self, ...same signature...) -> ...:
r = self.PA_AIC(...) # 定义 group 成员
self.PA_AIV(...)
return r

@pl.function(type=pl.FunctionType.Orchestration)
def entry(self, query, ..., out):
# 在此构建 tiling / workspace 张量(host 侧),然后派发:
out = self.PA(query, ..., out) # -> MixedKernels 派发
return out
```

`external_source` 接受绝对路径,或相对于定义该 program 的文件的相对路径。AIC 与 AIV
成员可指向**同一**源文件(按核各编译一次),也可指向不同文件。

单核 kernel:只声明一个 `AIC` 或 `AIV` 函数,并在 orchestration 中直接调用(无需
group)。

## `@pl.jit.extern` 写法

在 `@pl.jit` 下,用 `@pl.jit.extern` 声明 kernel。`core_type="mixed"` 会自动展开为上面
的 AIC + AIV + Group 形式:

```python
@pl.jit.extern(core_type="mixed",
aic_source="kernels/aic/pa.cpp",
aiv_source="kernels/aic/pa.cpp")
def pa(query: pl.Tensor[[B, H, D], pl.FP16], ...,
out: pl.Out[pl.Tensor[[B, H, D], pl.FP16]], ...
) -> pl.Tensor[[B, H, D], pl.FP16]: ...

@pl.jit
def decode(query: pl.Tensor, ..., out: pl.Out[pl.Tensor]):
out = pa(query, ..., out) # 依赖自动发现
return out
```

单核写法:`@pl.jit.extern(core_type="aic"|"aiv", source="k.cpp")`。

路径相对于定义该 kernel 的文件解析。修改所引用的 `.cpp` 会改变 JIT 缓存键,因此即使
Python stub 未变,kernel 变更也会触发重新编译。

## 编译器行为

- **Passes**:仅有函数头的函数原样穿过 pass 流水线(空体对 tile passes 是 no-op);它
仅豁免 `ReturnParamsExplicit` 属性 —— 该属性要求存在 `ReturnStmt`,而函数头本就没有。
- **Orchestration 代码生成**:为该 kernel 分配 `func_id`,并像 DSL kernel 一样生成派发
—— 单核 AIC/AIV 的 `rt_submit_*_task`,或 group 的 `MixedKernels{aic_id, aiv_id, ...}`
- `rt_submit_task`。
- **Backend**:对外部 kernel 跳过 ptoas,并在生成的 `kernel_config.py` manifest 中
像生成 kernel 一样列出它(`func_id`、`core_type`、`signature`),但 `source` 指向
原始手写 `.cpp`(在原始路径引用,使其兄弟文件仍可达),而非拷贝到产物目录下。

## 约束

- `external_source` 仅在 `FunctionType.AIC` / `FunctionType.AIV` 上有效。
- 函数体必须是裸的 `...`(仅签名)。
- 一个 `Group` 必须全为外部 kernel 或全为 DSL kernel —— 不允许在同一 group 中混用外部
与 DSL 成员。
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ line-ending = "lf"
wheel.packages = ["python/pypto"]
# Install the package data files (*.pyi, py.typed)
wheel.install-dir = "pypto"
# Set minimum CMake version
cmake.minimum-version = "3.15"
# Set minimum CMake version (cmake.minimum-version was removed in scikit-build-core 1.0)
cmake.version = ">=3.15"
# Build type
cmake.build-type = "RelWithDebInfo"
# Build directory, better for incremental build
Expand Down
55 changes: 51 additions & 4 deletions python/pypto/backend/pto_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,7 @@ def _generate_config_file(
func_name_to_core_type: dict[str, _ir_core.CoreType],
func_name_to_signature: dict[str, list[str]] | None = None,
orchestration_signature: list[str] | None = None,
func_name_to_external_source: dict[str, str] | None = None,
*,
block_dim: int | None = None,
) -> str:
Expand Down Expand Up @@ -786,6 +787,7 @@ def _generate_config_file(
copied back (the pre-existing behavior).
"""
func_name_to_signature = func_name_to_signature or {}
func_name_to_external_source = func_name_to_external_source or {}
orchestration_signature = orchestration_signature or []
has_signatures = any(func_name_to_signature.values()) or bool(orchestration_signature)

Expand Down Expand Up @@ -829,11 +831,16 @@ def _generate_config_file(
for name, func_id in sorted(func_name_to_id.items(), key=lambda x: x[1]):
core_type = func_name_to_core_type[name]
ct_str = "aiv" if core_type == _ir_core.CoreType.VECTOR else "aic"
# External kernels are referenced in place at their original path so the
# entry .cpp keeps its sibling files (relative #include "../..." resolve);
# DSL kernels are generated at kernels/<ct>/<name>.cpp under the artifact.
ext_source = func_name_to_external_source.get(name)
if ext_source is not None:
source_expr = repr(ext_source)
else:
source_expr = f'str(_ROOT_DIR / "kernels" / "{ct_str}" / "{name}.cpp")'
entry = (
f'\t{{"func_id": {func_id}, '
f'"name": "{name}", '
f'"source": str(_ROOT_DIR / "kernels" / "{ct_str}" / "{name}.cpp"), '
f'"core_type": "{ct_str}"'
f'\t{{"func_id": {func_id}, "name": "{name}", "source": {source_expr}, "core_type": "{ct_str}"'
)
signature = func_name_to_signature.get(name)
if signature:
Expand Down Expand Up @@ -965,6 +972,19 @@ def _get_kernel_output_path(
return os.path.join("kernels", ct_str, f"{func.name}.{suffix}")


def _external_source_of(func: _ir_core.Function) -> str | None:
"""Return the ``external_source`` path of a header-only external kernel, else None.

External kernels (declared via ``@pl.function(type=AIC/AIV,
external_source=...)``) carry the absolute path to a hand-written C++
``.cpp`` in their ``external_source`` attr and have an empty ``...`` DSL
body. The backend skips PyPTO codegen for them and references the source at
this original path in the manifest (so its sibling files stay reachable),
instead of generating a kernel.
"""
return dict(func.attrs).get("external_source")


def _compile_pto_module(
pto_code: str,
unit_name: str,
Expand Down Expand Up @@ -1513,6 +1533,15 @@ def _generate_single_chip(

groups, ungrouped = _build_group_mapping(transformed_program)

# External kernels are referenced at their original path in the manifest
# (kept beside their sibling sources so relative #include "../..." resolve),
# so PyPTO neither codegens nor copies them.
func_name_to_external_source: dict[str, str] = {
f.name: src
for f in transformed_program.functions.values()
if (src := _external_source_of(f)) is not None
}

# ── Phase 1: IR → MLIR (sequential, fast) ────────────────────────
# PTOCodegen converts IR to MLIR strings. This is cheap (pure string
# generation) and runs sequentially so that we don't contend on the GIL.
Expand All @@ -1524,6 +1553,19 @@ def _generate_single_chip(
# Grouped functions: one MLIR module per group
for group_name, members in groups.items():
try:
# External kernels are referenced in place (see the manifest map);
# skip PyPTO codegen for them. A group must be all-external or
# all-DSL — mixing is rejected (the DSL members would need cross-core
# protocol wiring the external source can't participate in).
ext_members = [m for m in members if _external_source_of(m) is not None]
if ext_members:
if len(ext_members) != len(members):
dsl = ", ".join(m.name for m in members if _external_source_of(m) is None)
raise RuntimeError(
f"Group '{group_name}' mixes external and DSL kernels "
f"(DSL members: {dsl}). A group must be all-external or all-DSL."
)
continue
grouped_program = _ir_core.Program(members, group_name, transformed_program.span)
stage = StageRecord(name=f"kernel_codegen:{group_name}", start=time.perf_counter())
ir_record = StageRecord(name="ir_to_mlir", start=time.perf_counter())
Expand All @@ -1538,6 +1580,10 @@ def _generate_single_chip(

for func in ungrouped:
try:
# External kernel: referenced in place (see the manifest map);
# skip PyPTO codegen.
if _external_source_of(func) is not None:
continue
peer_names = _extract_peer_function_names(func)
peer_funcs: list[_ir_core.Function] = []
for name in peer_names:
Expand Down Expand Up @@ -1578,6 +1624,7 @@ def _generate_single_chip(
orch_result.func_name_to_core_type,
orch_result.func_name_to_signature,
orch_result.orchestration_signature,
func_name_to_external_source,
block_dim=block_dim,
)
except Exception as e:
Expand Down
Loading
Loading