跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 23 章 自动调度与约束求解(昇腾)

/code/23-autoschedule-smt/

toy_scheduler/toy_schedule.py13868 字节
#!/usr/bin/env python3
"""A最小可复现的昇腾软件流水调度玩具模型(纯 Python,无第三方依赖)。

本文件把 `_vendor/tilelang-v0.1.15/tilelang/ascend/transform/z3_scheduler.py`
里 `z3_schedule_loop_python` 的模型缩到能一眼看懂的规模:

* 每个任务 = (name, latency, ii, pipe);
* 依赖 = `data_deps (u, v, distance, latency)`,`distance < 0` 表示"自动多缓冲";
* 资源冲突 = `resource_deps (i, j)`,同一硬件流水不能同时发射;
* 片上容量 = `memory_groups`,每组的 `num_stages` 份物理版本不能超容量;
* 目标 = 最小化启动间隔 II(start = k * II + r)。

它和 z3_scheduler.py 的差别只有两处(都在正文里说明过):

1. 求解器换成回溯搜索,而不是 Z3;对 4~5 个任务的玩具实例是精确解;
2. 玩具模型不带 owner-exclusion(共享同一物理 ring 的两个 owner)约束。

用法:
    python3 toy_schedule.py demo            # 默认示例,打印调度结果
    python3 toy_schedule.py sensitivity     # 延迟估计不准会怎样
    python3 toy_schedule.py --print-smt     # 打印等价的 Z3 编码(伪代码)
    python3 toy_schedule.py --z3            # 装了 z3-solver 时用 Z3 复算并比对
"""

from __future__ import annotations

import argparse
import itertools
from dataclasses import dataclass, field

# --------------------------------------------------------------------------
# 1. 任务与资源标签
# --------------------------------------------------------------------------

# 与 src/ascend/transform/ascend_pipe.h 的 ResourcePipe 位掩码一致
MTE1, MTE2, MTE3, CUBE, VECTOR, FIXPIPE, SCALAR = 1, 2, 4, 8, 16, 32, 64
PIPE_NAME = {
    MTE1: "MTE1",
    MTE2: "MTE2",
    MTE3: "MTE3",
    CUBE: "Cube",
    VECTOR: "Vector",
    FIXPIPE: "Fixpipe",
    SCALAR: "Scalar",
}


@dataclass
class Task:
    name: str
    latency: int  # 发射到结果可用的周期数
    ii: int  # 同一硬件流水两次发射之间的最小间隔
    pipe: int  # ResourcePipe 位掩码


@dataclass
class Problem:
    tasks: list[Task]
    data_deps: list[tuple[int, int, int, int]]  # (u, v, distance, latency)
    resource_deps: list[tuple[int, int]]  # 同一流水上不能并发的无序任务对
    buffer_sizes: list[int] = field(default_factory=list)  # 自动多缓冲的字节数
    memory_groups: list[list[int]] = field(default_factory=list)  # [cap, buf_idx...]
    num_stages: int = 2  # 每个自动 buffer 允许的最大版本数
    max_k: int = 1  # 允许的 k 上界(1 = 最多两级 stage)

    @property
    def n(self) -> int:
        return len(self.tasks)


# --------------------------------------------------------------------------
# 2. 约束检查(逐条对应 z3_scheduler.py 里的 solver.add(...))
# --------------------------------------------------------------------------


def check_partial(problem: Problem, ii: int, assign: dict[int, tuple[int, int]],
                  bufvar: list[int]) -> bool:
    """检查已赋值任务之间的全部约束;返回 False 表示可以剪枝。"""

    def start(idx: int) -> int:
        k, r = assign[idx]
        return k * ii + r

    for u, v, distance, latency in problem.data_deps:
        if u not in assign or v not in assign:
            continue
        if distance >= 0:
            # start_v - start_u >= latency - II * distance
            if start(v) - start(u) < latency - ii * distance:
                return False
        else:
            # 负 distance 编码自动 buffer:start_v - start_u >= latency - II * buf
            b = bufvar[-distance - 1]
            if start(v) - start(u) < latency - ii * b:
                return False

    for i, j in problem.resource_deps:
        if i not in assign or j not in assign:
            continue
        _ki, ri = assign[i]
        _kj, rj = assign[j]
        # 取 (r_j - r_i) mod II 作为模序;两个方向只有一种等价写法
        d = (rj - ri) % ii
        if not (d >= problem.tasks[i].ii and (ii - d) % ii >= problem.tasks[j].ii):
            return False
    return True


def capacity_ok(problem: Problem, bufvar: list[int]) -> bool:
    for group in problem.memory_groups:
        cap, members = group[0], group[1:]
        usage = sum(bufvar[b] * problem.buffer_sizes[b] for b in members)
        if usage > cap:
            return False
    return True


# --------------------------------------------------------------------------
# 3. 回溯搜索:给定 II 与 buffer 版本,判断是否可行
# --------------------------------------------------------------------------


def feasible(problem: Problem, ii: int, bufvar: list[int], want_model: bool = False):
    """返回 None(不可行)或 start_times 列表。"""
    n = problem.n
    # k 取 [0, max_k + 1] 再在最后按最小 start 归一化,等价于 C++ 里
    # `start_times[i] - offset * ii` 的做法(见 auto_schedule.cc:1117-1131)。
    domains = [
        [(k, r) for k in range(problem.max_k + 2) for r in range(ii)] for _ in range(n)
    ]
    assign: dict[int, tuple[int, int]] = {}
    # 模序对全体 r 的平移不变(资源约束只看 (r_j - r_i) mod II,数据约束只看
    # start 之差),所以可以固定 r_0 = 0 而不丢解;这正是把搜索空间砍到
    # (2 * ii)^(n-1) 的关键。
    domains[0] = [(0, 0)]
    order = list(range(n))

    def dfs(pos: int) -> bool:
        if pos == len(order):
            return True
        idx = order[pos]
        for k, r in domains[idx]:
            assign[idx] = (k, r)
            if check_partial(problem, ii, assign, bufvar) and dfs(pos + 1):
                return True
            del assign[idx]
        return False

    if not dfs(0):
        return None
    if not want_model:
        return []
    starts = [assign[i][0] * ii + assign[i][1] for i in range(n)]
    offset = min(starts)
    # 归一化到从 0 开始:等价于 C++ 里 `start_times[i] - offset * ii`
    return [s - offset for s in starts]


def solve(problem: Problem, verbose: bool = False):
    """线性扫描 II,返回 (ii, start_times, buffer_versions)。"""
    lower = max(t.ii for t in problem.tasks)
    upper = sum(t.latency for t in problem.tasks) + 1
    num_buffers = len(problem.buffer_sizes)
    combos = list(itertools.product(range(1, problem.num_stages + 1), repeat=num_buffers))
    combos.sort(key=sum)  # 同 II 下优先用更少的内存
    for ii in range(lower, upper + 1):
        for bufvar in combos:
            if not capacity_ok(problem, list(bufvar)):
                continue
            starts = feasible(problem, ii, list(bufvar), want_model=True)
            if starts is not None:
                if verbose:
                    print(f"  first feasible II = {ii}, buffer versions = {list(bufvar)}")
                return ii, starts, list(bufvar)
    raise RuntimeError("no feasible schedule found within the search bound")


# --------------------------------------------------------------------------
# 4. 玩具实例:一个 5 任务的 GEMM 流水
# --------------------------------------------------------------------------


def gemm_like_problem(mte2_latency: int = 40) -> Problem:
    """把 example_gemm.py 的内循环缩成 4 个任务。

    真实延迟量级见 src/ascend/transform/estimate_latency.h(例如
    mte2_gm_to_l1_base_latency=190、l1_to_l0a_bandwidth=256)。这里为了手算
    方便把数字等比缩小,模型结构不变。
    """
    tasks = [
        Task("loadA(MTE2 GM->L1)", mte2_latency, 10, MTE2),
        Task("l1->l0(MTE1)", 15, 3, MTE1),
        Task("cube(Cube)", 30, 8, CUBE),
        Task("l0c->gm(Fixpipe)", 25, 6, FIXPIPE),
    ]
    data_deps = [
        # 同迭代真依赖:A 就绪 -> MTE1;MTE1 -> Cube;Cube -> Fixpipe
        (0, 1, 0, mte2_latency),
        (1, 2, 0, 15),
        (2, 3, 0, 30),
        # 跨迭代 WAR:下一轮的 loadA 覆盖 L1 前,上一轮的 MTE1 必须读完。
        # distance = -(buf_id + 1),即交给调度器决定 ring 宽度。
        (1, 0, -1, 15),
    ]
    resource_deps = [(2, 1)]  # Cube 与 MTE1 争 L0 端口
    return Problem(
        tasks=tasks,
        data_deps=data_deps,
        resource_deps=resource_deps,
        buffer_sizes=[1000],  # L1 中 A 一份的字节数(示意)
        memory_groups=[[4096, 0]],  # L1 容量与成员
        num_stages=2,
        max_k=3,
    )


def describe(problem: Problem, ii: int, starts: list[int], bufvar: list[int]) -> None:
    print(f"  II = {ii}(相邻两轮迭代的最小启动间隔)")
    print(f"  buffer versions = {bufvar}  "
          f"(容量占用 {sum(b * s for b, s in zip(bufvar, problem.buffer_sizes))}"
          f" / {problem.memory_groups[0][0]})")
    print("  start(k, r) 按开始时间排序:")
    for idx in sorted(range(problem.n), key=lambda i: starts[i]):
        t = problem.tasks[idx]
        k = starts[idx] // ii
        r = starts[idx] % ii
        print(f"    [{starts[idx]:3d}] {t.name:<26s} k={k} r={r:2d} "
              f"latency={t.latency:2d} ii={t.ii:2d} pipe={PIPE_NAME[t.pipe]}")
    makespan = max(starts[i] + problem.tasks[i].latency for i in range(problem.n))
    print(f"  makespan(单轮)= {makespan} cycles")


# --------------------------------------------------------------------------
# 5. 模式:demo / sensitivity / print-smt / z3
# --------------------------------------------------------------------------


def run_demo() -> None:
    problem = gemm_like_problem()
    ii, starts, bufvar = solve(problem, verbose=True)
    describe(problem, ii, starts, bufvar)


def run_sensitivity() -> None:
    """延迟估计不准会怎样:真值 40,估计值 16。"""
    true_problem = gemm_like_problem(mte2_latency=40)
    est_problem = gemm_like_problem(mte2_latency=16)

    print("== 真值 ==")
    ii_t, starts_t, buf_t = solve(true_problem)
    describe(true_problem, ii_t, starts_t, buf_t)

    print("\n== EstimateLatency 低估 MTE2(40 -> 16)后调度器看到的世界 ==")
    ii_e, starts_e, buf_e = solve(est_problem)
    describe(est_problem, ii_e, starts_e, buf_e)

    print("\n== 用真值模型复核这份调度 ==")
    violations = []
    for u, v, distance, latency in true_problem.data_deps:
        if distance < 0:
            continue
        slack = starts_e[v] - starts_e[u] - latency
        if slack < 0:
            violations.append((u, v, slack))
    if violations:
        for u, v, slack in violations:
            print(f"  违约:{true_problem.tasks[v].name} 比 "
                  f"{true_problem.tasks[u].name} 早了 {-slack} cycles "
                  f"(数据未就绪)")
    else:
        print("  没有违约——低估延迟只是让 II 更小,但安排恰好仍然合法。")
    print(f"\n  结论:估计值 {est_problem.tasks[0].latency} 让调度器选了 II={ii_e},"
          f"真值是 II={ii_t}。模型误差直接变成时间表误差。")


def print_smt() -> None:
    """把同一套模型写成 Z3 的约束,便于与 z3_scheduler.py 对照。"""
    problem = gemm_like_problem()
    n = problem.n
    b = len(problem.buffer_sizes)
    print("# 以下编码与 tilelang/ascend/transform/z3_scheduler.py 的")
    print("# z3_schedule_loop_python 一一对应(去掉 owner-exclusion)。")
    print('ctx    = z3.Context()          # 每次求解用私有 context,保证可复现')
    print('solver = z3.Solver(ctx=ctx); solver.set("rlimit", 1000000)')
    print("k      = [z3.Int('k_' + str(i), ctx) for i in range(" + str(n) + ")]")
    print("r      = [z3.Int('r_' + str(i), ctx) for i in range(" + str(n) + ")]")
    print("buf    = [z3.Int('buf_' + str(i), ctx) for i in range(" + str(b) + ")]"
          "  # 1 <= buf <= num_stages")
    print("start  = [k[i] * II + r[i] for i in range(" + str(n) + ")]")
    print("# 模域:0 <= r[i] < II,k[i] >= 0")
    for u, v, distance, latency in problem.data_deps:
        if distance >= 0:
            rhs = str(latency) if distance == 0 else f"{latency} - II * {distance}"
            print(f"solver.add(start[{v}] - start[{u}] >= {rhs})")
        else:
            print(f"solver.add(start[{v}] - start[{u}] >= "
                  f"{latency} - II * buf[{-distance - 1}])")
    for i, j in problem.resource_deps:
        print(f"# 资源对 ({i},{j}):二选一建立模序,并保护绕回")
        print(f"o = z3.Bool('O_{i}_{j}', ctx)")
        print(f"solver.add(z3.Implies(o, r[{j}] - r[{i}] >= {problem.tasks[i].ii}))")
        print(f"solver.add(z3.Implies(o, r[{i}] - r[{j}] + II >= {problem.tasks[j].ii}))")
        print(f"solver.add(z3.Implies(z3.Not(o), r[{i}] - r[{j}] >= {problem.tasks[j].ii}))")
        print(f"solver.add(z3.Implies(z3.Not(o), r[{j}] - r[{i}] + II >= {problem.tasks[i].ii}))")
    for cap, *members in problem.memory_groups:
        terms = " + ".join(f"buf[{m}] * {problem.buffer_sizes[m]}" for m in members)
        print(f"solver.add({terms} <= {cap})   # 片上容量")
    print("# 目标:对 II 做二分,取满足约束的最小 II")


def run_z3() -> None:
    try:
        import z3  # type: ignore
    except ImportError:
        print("[skip] 本机没有 z3-solver;--print-smt 输出的编码等价,"
              "但不执行。")
        return
    problem = gemm_like_problem()
    py_ii, py_starts, py_buf = solve(problem)
    print(f"[python 回溯] II={py_ii} starts={py_starts} buf={py_buf}")
    # 用与 z3_scheduler.py 相同的约束复算同一个 II,验证两条路是否一致。
    ctx = z3.Context()
    solver = z3.Solver(ctx=ctx)
    solver.set("rlimit", 1000000)
    n, b = problem.n, len(problem.buffer_sizes)
    k = [z3.Int(f"k_{i}", ctx) for i in range(n)]
    r = [z3.Int(f"r_{i}", ctx) for i in range(n)]
    buf = [z3.Int(f"buf_{i}", ctx) for i in range(b)]
    ii = py_ii
    for i in range(n):
        solver.add(k[i] >= 0, r[i] >= 0, r[i] < ii)
    for i in range(b):
        solver.add(buf[i] >= 1, buf[i] <= problem.num_stages)
    starts = [k[i] * ii + r[i] for i in range(n)]
    for u, v, distance, latency in problem.data_deps:
        if distance >= 0:
            solver.add(starts[v] - starts[u] >= latency - ii * distance)
        else:
            solver.add(starts[v] - starts[u] >= latency - ii * buf[-distance - 1])
    for i, j in problem.resource_deps:
        o = z3.Bool(f"O_{i}_{j}", ctx)
        solver.add(z3.Implies(o, r[j] - r[i] >= problem.tasks[i].ii))
        solver.add(z3.Implies(o, r[i] - r[j] + ii >= problem.tasks[j].ii))
        solver.add(z3.Implies(z3.Not(o), r[i] - r[j] >= problem.tasks[j].ii))
        solver.add(z3.Implies(z3.Not(o), r[j] - r[i] + ii >= problem.tasks[i].ii))
    for cap, *members in problem.memory_groups:
        solver.add(z3.Sum([buf[m] * problem.buffer_sizes[m] for m in members]) <= cap)
    print(f"[z3] II={ii} 可行? {solver.check() == z3.sat}  "
          f"(z3 {z3.get_version_string()})")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("mode", nargs="?", default="demo",
                        choices=["demo", "sensitivity", "print-smt", "z3"])
    parser.add_argument("--print-smt", action="store_true",
                        help="打印等价的 Z3 约束")
    parser.add_argument("--z3", action="store_true",
                        help="本机装有 z3-solver 时复算并比对")
    args = parser.parse_args()
    if args.print_smt or args.mode == "print-smt":
        print_smt()
    elif args.z3 or args.mode == "z3":
        run_z3()
    elif args.mode == "sensitivity":
        run_sensitivity()
    else:
        run_demo()


if __name__ == "__main__":
    main()

← 浏览本章全部代码