Triton实现矩阵乘法

python 复制代码
# %%
"""
用 Triton 实现分块矩阵乘法 C = A @ B。

建议先记住三个层次:

1. 整体矩阵
       A: (M, K)    B: (K, N)    C: (M, N)

2. 一个 Triton program(程序实例)负责 C 的一个二维小块
       C_block: (BLOCK_SIZE_M, BLOCK_SIZE_N)

3. 计算这个 C 小块时,沿 K 方向分批读取
       A_block: (BLOCK_SIZE_M, BLOCK_SIZE_K)
       B_block: (BLOCK_SIZE_K, BLOCK_SIZE_N)
       C_block += A_block @ B_block

例如 BM=2、BN=2、BK=2、K=4 时,一个 C 小块要累加两轮:

    C_block = A[:, 0:2] @ B[0:2, :]
            + A[:, 2:4] @ B[2:4, :]

所以分块矩阵乘法没有改变数学结果,只是改变了 GPU 读取和计算数据的方式。
"""

import os

import torch
import triton
import triton.language as tl


# 当前 Triton 后端对应的 PyTorch 设备。
# CUDA 环境通常类似 cuda:0;HIP/ROCm 环境在 PyTorch 中也常通过 torch.cuda 接口暴露。
DEVICE = triton.runtime.driver.active.get_active_torch_device()


def is_cuda():
    """当前 Triton 后端是否为 NVIDIA CUDA。"""
    return triton.runtime.driver.active.get_current_target().backend == "cuda"


def get_cuda_autotune_config():
    """返回 CUDA 后端的候选调优配置,由 Triton 实测后选择最快的一套。"""

    # BLOCK_SIZE_M / N:一个 program 计算的 C 小块尺寸。
    # BLOCK_SIZE_K:每轮点积处理的 K 方向长度。
    # GROUP_SIZE_M:只改变 program 的排列顺序,以提高缓存复用,不改变分块尺寸和结果。
    # num_warps:一个 program 由多少个线程束协作执行。
    # num_stages:软件流水线深度;较深时可以更充分地重叠加载与计算,但会占用更多资源。
    #
    # 大块可以提高数据复用率,却会增加寄存器占用;小块资源压力低,却可能增加 program 数量。
    # 因此没有一套参数适合所有矩阵尺寸和 GPU,需要自动调优。
    return [
        triton.Config({'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 256, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 8},
                      num_stages=3, num_warps=8),
        triton.Config({'BLOCK_SIZE_M': 64, 'BLOCK_SIZE_N': 256, 'BLOCK_SIZE_K': 32, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 128, 'BLOCK_SIZE_K': 32, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 64, 'BLOCK_SIZE_K': 32, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 64, 'BLOCK_SIZE_N': 128, 'BLOCK_SIZE_K': 32, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 32, 'BLOCK_SIZE_K': 32, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 64, 'BLOCK_SIZE_N': 32, 'BLOCK_SIZE_K': 32, 'GROUP_SIZE_M': 8},
                      num_stages=5, num_warps=2),
        triton.Config({'BLOCK_SIZE_M': 32, 'BLOCK_SIZE_N': 64, 'BLOCK_SIZE_K': 32, 'GROUP_SIZE_M': 8},
                      num_stages=5, num_warps=2),

        # K 分块较大的候选项。FP8 元素占用空间较小,部分硬件上可受益于更大的 K 分块。
        triton.Config({'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 256, 'BLOCK_SIZE_K': 128, 'GROUP_SIZE_M': 8},
                      num_stages=3, num_warps=8),
        triton.Config({'BLOCK_SIZE_M': 256, 'BLOCK_SIZE_N': 128, 'BLOCK_SIZE_K': 128, 'GROUP_SIZE_M': 8},
                      num_stages=3, num_warps=8),
        triton.Config({'BLOCK_SIZE_M': 256, 'BLOCK_SIZE_N': 64, 'BLOCK_SIZE_K': 128, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 64, 'BLOCK_SIZE_N': 256, 'BLOCK_SIZE_K': 128, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 128, 'BLOCK_SIZE_K': 128, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 64, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 64, 'BLOCK_SIZE_N': 128, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
        triton.Config({'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 32, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 8},
                      num_stages=4, num_warps=4),
    ]


def get_hip_autotune_config():
    """返回 AMD HIP/HCU 后端的候选调优配置。"""

    # AMD 与 NVIDIA 的矩阵指令、线程束组织和资源限制不同,因此单独准备候选配置。
    sizes = [
        {'BLOCK_SIZE_M': 32, 'BLOCK_SIZE_N': 32, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 6},
        {'BLOCK_SIZE_M': 64, 'BLOCK_SIZE_N': 32, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 4},
        {'BLOCK_SIZE_M': 32, 'BLOCK_SIZE_N': 64, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 6},
        {'BLOCK_SIZE_M': 64, 'BLOCK_SIZE_N': 64, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 6},
        {'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 64, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 4},
        {'BLOCK_SIZE_M': 128, 'BLOCK_SIZE_N': 128, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 4},
        {'BLOCK_SIZE_M': 256, 'BLOCK_SIZE_N': 128, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 4},
        {'BLOCK_SIZE_M': 256, 'BLOCK_SIZE_N': 256, 'BLOCK_SIZE_K': 64, 'GROUP_SIZE_M': 6},
    ]

    # 字典合并运算 size | {...} 不会修改原 size,而是为每套配置统一添加 AMD 参数。
    # matrix_instr_nonkdim=16 是 AMD 矩阵指令的非 K 维尺寸提示;它不是 C 的分块尺寸。
    return [
        triton.Config(
            size | {'matrix_instr_nonkdim': 16},
            num_warps=8,
            num_stages=2,
        )
        for size in sizes
    ]


def get_autotune_config():
    """根据当前后端选择 CUDA 或 HIP/HCU 调优配置。"""
    return get_cuda_autotune_config() if is_cuda() else get_hip_autotune_config()


# 自动调优过程:
# 1. 第一次遇到某组 (M, N, K) 时编译并测试候选配置;
# 2. 选择最快配置;
# 3. 缓存结果,后续相同 key 通常直接复用。
#
# key 只包含 M/N/K,意味着输入布局或 dtype 改变时未必重新调优。如果实际项目会混用多种
# dtype/stride,应该评估是否需要让它们也进入调优 key,或拆成不同内核。
@triton.autotune(configs=get_autotune_config(), key=['M', 'N', 'K'])
@triton.jit
def matmul_kernel(
        # A、B、C 的首地址。
        a_ptr, b_ptr, c_ptr,
        # 逻辑形状:A(M, K) @ B(K, N) -> C(M, N)。
        M, N, K,
        # stride 的单位是"元素个数",而不是字节数。
        # A[i, j] 的元素偏移 = i * stride_am + j * stride_ak。
        stride_am, stride_ak,
        stride_bk, stride_bn,
        stride_cm, stride_cn,
        # constexpr 表示编译期常量。不同分块配置会产生不同版本的 GPU 内核。
        BLOCK_SIZE_M: tl.constexpr,
        BLOCK_SIZE_N: tl.constexpr,
        BLOCK_SIZE_K: tl.constexpr,
        GROUP_SIZE_M: tl.constexpr,
        ACTIVATION: tl.constexpr,
):
    """计算 C = A @ B;每个 Triton program 负责 C 的一个二维分块。"""

    # -------------------------------------------------------------------------
    # 第 1 步:把一维 program 编号 pid 映射为二维输出块坐标 (pid_m, pid_n)
    # -------------------------------------------------------------------------
    #
    # "program"不是 Python 进程,也不是单个 GPU 线程;它是 Triton 的一个并行工作单元,
    # 内部由 num_warps 指定的多个线程束共同计算一个 C 小块。
    pid = tl.program_id(axis=0)

    # C 在 M、N 方向各有多少个块。cdiv 是向上取整除法:cdiv(130, 64) == 3。
    num_pid_m = tl.cdiv(M, BLOCK_SIZE_M)
    num_pid_n = tl.cdiv(N, BLOCK_SIZE_N)

    # GROUP_SIZE_M 仅控制 program 顺序,目的是让相邻 program 更可能复用缓存中的 B 数据。
    #
    # 具体例子:num_pid_m=4、num_pid_n=3、GROUP_SIZE_M=2。
    # C 的块坐标为:
    #
    #                    N块0       N块1       N块2
    #       M块0          C00        C01        C02
    #       M块1          C10        C11        C12
    #       M块2          C20        C21        C22
    #       M块3          C30        C31        C32
    #
    # 下方映射让 pid 顺序变成:
    #
    #       pid 0,1   -> C00, C10   (共同使用 B 的第0列块)
    #       pid 2,3   -> C01, C11   (共同使用 B 的第1列块)
    #       pid 4,5   -> C02, C12
    #       pid 6,7   -> C20, C30
    #       pid 8,9   -> C21, C31
    #       pid 10,11 -> C22, C32
    #
    # 因此 GROUP_SIZE_M=2 不是让一个 program 同时计算两个块,而是让相邻的两个 program
    # 优先处理同一个 N 块下的两个 M 块。GROUP_SIZE_M=8 同理,只是每组最多有 8 个 M 块。
    num_pid_in_group = GROUP_SIZE_M * num_pid_n
    group_id = pid // num_pid_in_group
    first_pid_m = group_id * GROUP_SIZE_M

    # 最后一组可能不足 GROUP_SIZE_M。例如 num_pid_m=10、GROUP_SIZE_M=8 时,
    # 第一组有 M块0~7,最后一组只有 M块8~9,所以最后一组的 group_size_m 是 2。
    group_size_m = min(num_pid_m - first_pid_m, GROUP_SIZE_M)

    pid_in_group = pid % num_pid_in_group
    pid_m = first_pid_m + pid_in_group % group_size_m
    pid_n = pid_in_group // group_size_m

    # 这些 assume 不执行运行时检查,而是把已知范围告诉编译器,帮助优化整数和地址运算。
    tl.assume(pid_m >= 0)
    tl.assume(pid_n >= 0)
    tl.assume(stride_am > 0)
    tl.assume(stride_ak > 0)
    tl.assume(stride_bn > 0)
    tl.assume(stride_bk > 0)
    tl.assume(stride_cm > 0)
    tl.assume(stride_cn > 0)

    # -------------------------------------------------------------------------
    # 第 2 步:生成当前 program 所负责数据的逻辑下标
    # -------------------------------------------------------------------------
    #
    # 若 BM=4 且 pid_m=2,则原始 M 行号为 [8, 9, 10, 11]。
    # 若 BN=4 且 pid_n=1,则原始 N 列号为 [4, 5, 6, 7]。
    # offs_k 是当前一轮 K 小块内的相对下标,例如 BK=4 时为 [0, 1, 2, 3]。
    #
    # M/N 方向使用 % 回绕,保证读取 A/B 时地址有效。最后写 C 时使用真实下标和 mask,
    # 所以回绕读取产生的"临时结果"不会被写到越界位置。
    #
    # 例:M=10、BM=4、pid_m=2,原始行是 [8,9,10,11],取模后为 [8,9,0,1]。
    # 前两行是真正结果;后两行只是安全占位,最终 c_mask 会禁止写回它们。
    offs_am = (pid_m * BLOCK_SIZE_M + tl.arange(0, BLOCK_SIZE_M)) % M
    offs_bn = (pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N)) % N
    offs_k = tl.arange(0, BLOCK_SIZE_K)

    # -------------------------------------------------------------------------
    # 第 3 步:由下标和 stride 生成 A、B 小块的二维"指针张量"
    # -------------------------------------------------------------------------
    #
    # offs_am[:, None] 的形状是 (BM, 1),offs_k[None, :] 的形状是 (1, BK),
    # 广播相加后得到 (BM, BK) 个地址。因此 a_ptrs 的形状为 (BM, BK)。
    # 同理,b_ptrs 的形状为 (BK, BN)。
    #
    # 小例子:offs_am=[2,3]、offs_k=[0,1],A 连续且每行4个元素,stride_am=4、stride_ak=1:
    #
    #     offs_am[:,None] * 4 = [[ 8],       offs_k[None,:] = [[0,1]]
    #                            [12]]
    #
    #     广播相加得到元素偏移 [[8,9], [12,13]],即 A[2:4, 0:2] 的四个地址。
    #
    # 注意:a_ptrs/b_ptrs 此时只是地址集合,真正读数据发生在下面的 tl.load。
    a_ptrs = a_ptr + offs_am[:, None] * stride_am + offs_k[None, :] * stride_ak
    b_ptrs = b_ptr + offs_k[:, None] * stride_bk + offs_bn[None, :] * stride_bn

    # 当前 program 的输出小块,使用 FP32 累加以降低长点积的舍入误差。
    accumulator = tl.zeros((BLOCK_SIZE_M, BLOCK_SIZE_N), dtype=tl.float32)

    # -------------------------------------------------------------------------
    # 第 4 步:沿 K 方向分块加载、矩阵乘加
    # -------------------------------------------------------------------------
    #
    # 数学上,一个输出元素是完整行列点积:
    #     C[i,j] = sum(A[i,k] * B[k,j], k=0...K-1)
    #
    # Triton 把这条长点积切成多段再相加。例如 K=6、BK=2:
    #     第0轮处理 k=0,1
    #     第1轮处理 k=2,3
    #     第2轮处理 k=4,5
    # 最终结果与一次完成完整点积相同(浮点累加顺序不同可能造成末位微小差异)。
    for k in range(0, tl.cdiv(K, BLOCK_SIZE_K)):
        # 最后一轮可能不足 BK 个元素。例如 K=5、BK=2 时需要3轮,最后一轮只有 k=4 有效。
        # 无效位置用 0 填充,因为补 0 不改变点积结果。
        k_remaining = K - k * BLOCK_SIZE_K
        a = tl.load(a_ptrs, mask=offs_k[None, :] < k_remaining, other=0.0)
        b = tl.load(b_ptrs, mask=offs_k[:, None] < k_remaining, other=0.0)

        # 形状规则:(BM, BK) @ (BK, BN) -> (BM, BN)。
        # 这里不是只得到一个数,而是同时完成 BM*BN 个"分段行列点积"。
        accumulator = tl.dot(a, b, accumulator)

        # 地址移动到下一段 K:A 沿列方向移动 BK,B 沿行方向移动 BK。
        a_ptrs += BLOCK_SIZE_K * stride_ak
        b_ptrs += BLOCK_SIZE_K * stride_bk

    # -------------------------------------------------------------------------
    # 第 5 步:可选融合激活,然后把有效输出写回 C
    # -------------------------------------------------------------------------
    if ACTIVATION == "leaky_relu":
        accumulator = leaky_relu(accumulator)

    # 本例固定输出 FP16。转换只影响存储精度,前面的累加仍是 FP32。
    c = accumulator.to(tl.float16)

    # 写回必须使用未取模的真实 C 下标,否则越界行回绕后会覆盖合法结果。
    offs_cm = pid_m * BLOCK_SIZE_M + tl.arange(0, BLOCK_SIZE_M)
    offs_cn = pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N)
    c_ptrs = c_ptr + offs_cm[:, None] * stride_cm + offs_cn[None, :] * stride_cn

    # 二维 mask 的形状是 (BM, BN):只有真实位于矩阵内部的位置才写入显存。
    c_mask = (offs_cm[:, None] < M) & (offs_cn[None, :] < N)
    tl.store(c_ptrs, c, mask=c_mask)


@triton.jit
def leaky_relu(x):
    """逐元素 Leaky ReLU:正数不变,负数乘 0.01。"""
    return tl.where(x >= 0, x, 0.01 * x)


# %%
# Python 调用接口

def matmul(a, b, activation=""):
    """检查输入、创建输出张量,并启动 Triton 矩阵乘法内核。"""

    # A(M,K) @ B(K,N) 的公共维 K 必须相等。
    assert a.ndim == 2 and b.ndim == 2, "A 和 B 必须都是二维矩阵"
    assert a.shape[1] == b.shape[0], "A 的列数必须等于 B 的行数"
    assert a.is_contiguous(), "本示例要求矩阵 A 连续存储"
    assert a.device == b.device, "A 和 B 必须位于同一设备"

    M, K = a.shape
    _, N = b.shape
    c = torch.empty((M, N), device=a.device, dtype=torch.float16)

    # 每个 program 负责一个 C 小块,所以 program 总数是:
    #     ceil(M/BM) * ceil(N/BN)
    #
    # 例:M=130、N=70、BM=64、BN=32,需要 3*3=9 个 program。
    # grid 写成 lambda,是因为自动调优会改变 BM/BN,program 数也必须同步重算。
    grid = lambda meta: (
        triton.cdiv(M, meta['BLOCK_SIZE_M'])
        * triton.cdiv(N, meta['BLOCK_SIZE_N']),
    )

    matmul_kernel[grid](
        a, b, c,
        M, N, K,
        a.stride(0), a.stride(1),
        b.stride(0), b.stride(1),
        c.stride(0), c.stride(1),
        ACTIVATION=activation,
    )
    return c


# %%
# 正确性测试

torch.manual_seed(0)
a = torch.rand((512, 512), device=DEVICE, dtype=torch.float16) - 0.5
b = torch.rand((512, 512), device=DEVICE, dtype=torch.float16) - 0.5

triton_output = matmul(a, b)
torch_output = torch.matmul(a, b)

print(f"FP16 输入下的 Triton 输出:{triton_output}")
print(f"FP16 输入下的 PyTorch 输出:{torch_output}")

if torch.allclose(triton_output, torch_output, atol=1e-2, rtol=0):
    print("✓ Triton 与 PyTorch 的结果一致")
else:
    print("✗ Triton 与 PyTorch 的结果不一致")


# 仅当 PyTorch 与 CUDA GPU 都支持 float8_e5m2 时测试 FP8。
# FP8 有效位较少,因此容差比 FP16 更宽松。
TORCH_HAS_FP8 = hasattr(torch, "float8_e5m2")
if TORCH_HAS_FP8 and is_cuda():
    torch.manual_seed(0)
    a = torch.randn((512, 512), device=DEVICE, dtype=torch.float16)
    b = torch.randn((512, 512), device=DEVICE, dtype=torch.float16)
    a = a.to(torch.float8_e5m2)

    # 这里是方阵,所以转置后形状仍为 (512,512)。若改成非方阵,必须重新检查逻辑形状。
    b = b.T.to(torch.float8_e5m2)
    triton_output = matmul(a, b)
    torch_output = torch.matmul(a.to(torch.float16), b.to(torch.float16))

    if torch.allclose(triton_output, torch_output, atol=0.125, rtol=0):
        print("✓ FP8:Triton 与 PyTorch 参考结果一致")
    else:
        print("✗ FP8:Triton 与 PyTorch 参考结果不一致")


# %%
# 性能基准测试

ref_lib = 'cuBLAS' if is_cuda() else 'rocBLAS'

configs = []
for fp8_inputs in [False, True]:
    if fp8_inputs and (not TORCH_HAS_FP8 or not is_cuda()):
        continue

    configs.append(
        triton.testing.Benchmark(
            x_names=["M", "N", "K"],
            x_vals=[128 * i for i in range(2, 33)],  # 方阵边长从256增加到4096。
            line_arg="provider",
            line_vals=["triton"] if fp8_inputs else [ref_lib.lower(), "triton"],
            line_names=["Triton"] if fp8_inputs else [ref_lib, "Triton"],
            styles=[("green", "-"), ("blue", "-")],
            ylabel="TFLOPS",
            plot_name="matmul-performance-" + ("fp16" if not fp8_inputs else "fp8"),
            args={"fp8_inputs": fp8_inputs},
        )
    )


@triton.testing.perf_report(configs)
def benchmark(M, N, K, provider, fp8_inputs):
    """测量指定矩阵尺寸下 Triton 与参考库的吞吐量。"""
    a = torch.randn((M, K), device=DEVICE, dtype=torch.float16)
    b = torch.randn((K, N), device=DEVICE, dtype=torch.float16)

    if TORCH_HAS_FP8 and fp8_inputs:
        a = a.to(torch.float8_e5m2)
        b = b.T.to(torch.float8_e5m2)

    # quantiles依次返回中位、较快和较慢耗时。
    quantiles = [0.5, 0.2, 0.8]
    if provider == ref_lib.lower():
        ms, min_ms, max_ms = triton.testing.do_bench(
            lambda: torch.matmul(a, b), quantiles=quantiles
        )
    elif provider == 'triton':
        ms, min_ms, max_ms = triton.testing.do_bench(
            lambda: matmul(a, b), quantiles=quantiles
        )
    else:
        raise ValueError(f"未知 provider: {provider}")

    # GEMM 约有 2*M*N*K 次浮点运算:每个输出元素含 K 次乘法和约 K 次加法。
    # elapsed_ms * 1e-3 把毫秒转成秒,1e-12 把 FLOPS 转成 TFLOPS。
    perf = lambda elapsed_ms: 2 * M * N * K * 1e-12 / (elapsed_ms * 1e-3)
    return perf(ms), perf(max_ms), perf(min_ms)


os.makedirs("../result", exist_ok=True)
benchmark.run(show_plots=True, print_data=True, save_path="../result")
相关推荐
noipp8 小时前
推荐题目:洛谷 B2099 矩阵交换行
线性代数·算法·矩阵
ssl_xxy10 小时前
行列式杂题第二弹
线性代数·矩阵
中微极客14 小时前
2026年4月LLM排名矩阵:从SWE-Bench到终端性能的实战选型指南
矩阵
文祐3 天前
C语言利用数组计算矩阵乘法
c语言·开发语言·矩阵
AI科技星3 天前
线性算子不是空间映射函数,是全域双螺旋场之间拉伸、旋转、耦合、坍缩的跨空间标准化变换载体《全域数学vs传统数学:人类文明进阶200讲》第80讲
线性代数·算法·矩阵·数据挖掘·回归·乖乖数学·全域数学
米罗篮3 天前
矩阵快速幂 (Exponentiation By Squaring Applied To Matrices)
c++·线性代数·算法·矩阵
Ivanqhz4 天前
DRN(深度强化学习推荐网络)
人工智能·线性代数·机器学习·矩阵·dnn
学究天人5 天前
数学公理体系大全:第十四章 向量空间与模:线性代数的公理化与推广
线性代数·算法·矩阵·动态规划·抽象代数
风华圆舞6 天前
鸿蒙HarmonyOS矩阵加载动效实战 —— 从「假旋转」到可复用的波前反馈组件
华为·矩阵·harmonyos·arkui·gradientspin·波前动效·oklab