Bootstrap

Mojo 学习 —— 并行化

Mojo 学习 —— 并行化


Mojo 标准库 algorithm 模块中提供了很多并行化操作函数。包括

  • functional 模块: 实现了一些高级函数
  • memory 模块: 实现了并行内存拷贝

这些函数可以直接从 algorithm 中导入,例如

from algorithm import map

我们首先定义一个矩阵结构,用于后续并行化分析

from random import rand

alias type = DType.float32

struct Matrix[rows: Int, cols: Int]:
    # 使用 DTypePointer 类型的指针
    var data: DTypePointer[type]

    # 将数据初始化为 0
    fn __init__(inout self):
        self.data = DTypePointer[type].alloc(rows * cols)
        memset_zero(self.data, rows * cols)

    # 或使用指针数据初始化
    fn __init__(inout self, data: DTypePointer[type]):
        self.data = data

    # 将矩阵的值初始化为随机值
    @staticmethod
    fn rand() -> Self:
        var data = DTypePointer[type].alloc(rows * cols)
        rand(data, rows * cols)
        return Self(data)
    # 使用 [] 获取指定位置的值
    fn __getitem__(self, y: Int, x: Int) -> Scalar[type]:
        return self.load[1](y, x)
    # [] 设置值
    fn __setitem__(self, y: Int, x: Int, val: Scalar[type]):
        self.store[1](y, x, val)
    # 返回 x,y 后面的 nelts 个值
    fn load[nelts: Int](self, y: Int, x: Int) -> SIMD[type, nelts]:
        return self.data.load[width=nelts](y * self.cols + x)
    # 设置  x,y 后面的 nelts 个值
    fn store[nelts: Int](self, y: Int, x: Int, val: SIMD[type, nelts]):
        return self.data.store[width=nelts](y * self.cols + x, val)

然后定义一个基准测试函数(矩阵乘法),用于测试不同版本的函数执行效率的区别。

import benchmark

# 定义常量,矩阵的大小
alias M = 1024
alias N = 1024
alias K = 1024

@always_inline
fn bench[
    func: fn (Matrix, Matrix, Matrix) -> None](base_gflops: Float64):
    # 创建三个矩阵
    var C = Matrix[M, N]()
    var A = Matrix[M, K].rand()
    var B = Matrix[K, N].rand()

    # 调用矩阵乘法函数
    @always_inline
    @parameter
    fn test_fn():
        _ = func(C, A, B)
    # 基准测试
    var secs = benchmark.run[test_fn](max_runtime_secs=1).mean()
    # 释放内存
    A.data.free()
    B.data.free()
    C.data.free()
    # 打印测试结果
    var gflops = ((2 * M * N * K) / secs) / 1e9
    var speedup: Float64 = gflops / base_gflops

    print(gflops, "GFLOP/s, a", speedup, "x speedup over Python")

使用嵌套循环计算矩阵乘法

fn matmul_naive(C: Matrix, A: Matrix, B: Matrix):
    for m in range(C.rows):
        for k in range(A.cols):
            for n in range(C.cols):
                C[m, n] += A[m, k] * B[k, n]

Python 的计算结果为基准,测试 Mojo 运行结果

fn main():
    var python_gflops = 0.002
    bench[matmul_naive](python_gflops)
# 2.5135788794310976 GFLOP/s, a 1256.7894397155487 x speedup over Python

functional

functional 目前有 11 个高级函数,以及一列类型别名。我们主要介绍这些函数的使用

map

函数签名为:

map[func: fn(Int, /) capturing -> None](size: Int)

其功能为将 0-size 分别作为输入函数的参数,并运行,例如

fn map_demo():
    @parameter
    fn helper(num: Int):
        print(num, end=' ')

    map[helper](5)

map_demo()

注意,要使用闭包的形式,闭包的第一个参数为每次迭代获取的 0-size 的值,且闭包无返回值。输出结果为

0 1 2 3 4 

vectorize

函数签名为:

vectorize[func: fn[Int](Int, /) capturing -> None, simd_width: Int, /, *, unroll_factor: Int = 1](size: Int)

通过在从 0-size 的范围内映射函数来简化 SIMD 循环的优化,以 simd_width 为步长递增。剩余部分(size % simd_width)将在单独的迭代中运行。例如

from algorithm.functional import vectorize

# 要循环遍历的元素数量
alias size = 10
# 一个 SIMD 寄存器可以存储几个 Dtype.int32 元素(128 可以保存 8 个)
alias simd_width = simdwidthof[DType.int32]()

fn main():
    var p = DTypePointer[DType.int32].alloc(size)

    # 定义闭包来捕获指针
    @parameter
    fn closure[simd_width: Int](i: Int):
        print("storing", simd_width, "els at pos", i)
        p.store[width=simd_width](i, i)

    vectorize[closure, simd_width](size)
    print(p.load[width=size]())

SIMD 寄存器大小为 256 的机器上,每次迭代将设置 8Int32 的值。10 % 8 的余数是 2,因此最后两个元素将在两次不同的迭代中设置:

storing 8 els at pos 0
storing 1 els at pos 8
storing 1 els at pos 9
[0, 0, 0, 0, 0, 0, 0, 0, 8, 9]

您还可以将循环展开,通过牺牲存储来提高性能:

vectorize[closure, width, unroll_factor=2](size)

类似于下面的伪代码,最后两次循环会重复运行

closure[8](0)
# 除非将 `size` 作为 `parameter` 传递,否则剩余循环不会展开
for i in range(8, 10):
    closure[1](i)
    closure[1](i)

如果我们在编译时可以确定 size 的值,可以将其作为 parameter,而不是 argument 从而减少余数的迭代次数。

只有当剩余循环为 2 的指数(24816…)时,才会生效。如果余数不是 2 的指数,余下的循环仍会展开以提高性能。

重载函数的签名为:

vectorize[func: fn[Int](Int, /) capturing -> None, simd_width: Int, /, *, size: Int, unroll_factor: Int = 1]()

我们只需将上面倒数第二行代码改为

vectorize[closure, simd_width, size=size]()

其运行结果将会是

storing 8 els at pos 0
storing 2 els at pos 8
[0, 0, 0, 0, 0, 0, 0, 0, 8, 8]

我们使用 vectorize 来加速矩阵乘法的计算

# 每次向量化计算的长度
alias nelts = simdwidthof[DType.float32]() * 2

fn matmul_vectorized(C: Matrix, A: Matrix, B: Matrix):

    for m in range(C.rows):
        for k in range(A.cols):
            @parameter
            fn dot[nelts: Int](n: Int):
                C.store(m, n, C.load[nelts](m, n) + A[m, k] * B.load[nelts](k, n))
            vectorize[dot, nelts, size = C.cols]()
# 14.092552861535482 GFLOP/s, a 7046.2764307677407 x speedup over Python

提升差不多 6

parallelize

parallelizesync_parallelize 都可以执行并行任务,区别可能一个是同步一个是异步。使用方式差不多,parallelize 还有一个额外的参数

parallelize[func: fn(Int, /) capturing -> None](num_work_items: Int, num_workers: Int)

例如,可以使用并行化来赋值

from algorithm import sync_parallelize, parallelize, map

fn main():
    alias size = 10
    var p = Pointer[Int].alloc(size)
    @parameter
    fn fill_value(i: Int):
        p.store(i, i)
    print('store value to pointer')
    # sync_parallelize[fill_value](size)
    parallelize[fill_value](size, 3)

    @parameter
    fn print_value(i: Int):
        print(p.load(i), end=' ')
    print('get value from pointer')
    map[print_value](size)
    p.free()
store value to pointer
get value from pointer
0 1 2 3 4 5 6 7 8 9

我们可以把 parallelize 加入,进一步提升矩阵乘法的计算速度

fn matmul_parallelized(C: Matrix, A: Matrix, B: Matrix):
    @parameter
    fn calc_row(m: Int):
        for k in range(A.cols):
            @parameter
            fn dot[nelts : Int](n : Int):
                C.store[nelts](m,n, C.load[nelts](m,n) + A[m,k] * B.load[nelts](k,n))
            vectorize[dot, nelts, size = C.cols]()
    parallelize[calc_row](C.rows, C.rows)
# 116.56057998764479 GFLOP/s, a 58280.289993822393 x speedup over Python

woooooo~,这次比使用 vectorize 的方法提升了 8 倍多。速度杠杠的!

tile

tile 是一个生成器,可按照指定的任务大小列表,即将任务进行切片分组,并分组执行函数。

例如 func[3](5) 应启动对第 567 项的计算,在语义上应等同于 func[1](5)func[1](6)func[1](7)

例如,tile[func, (3,2,1)](offset, upperbound) 会尝试从 offset 开始调用 func[3],直到离 upperbound 还有三个任务,然后尝试调用 func[2],再调用 func[1]

例如,将 9 个任务分为 3 组,每组 3 个任务

fn tile_test():
    @parameter
    fn func[tile_x: Int](x: Int):
        print(tile_x, x)
    tile[func, tile_size_list=3](0, 9)
# 3 0
# 3 3
# 3 6

类似于

fn tile_test():
    @parameter
    fn func(x: Int, y: Int):
        print(x, y)
    tile[func](0, 9, 3)
# 0 3
# 3 3
# 6 3

我们将 tile 也加入矩阵乘法的计算中

from algorithm import tile

fn matmul_tiled_parallelized(C: Matrix, A: Matrix, B: Matrix):
    @parameter
    fn calc_row(m: Int):
        @parameter
        fn calc_tile[tile_x: Int, tile_y: Int](x: Int, y: Int):
            for k in range(y, y + tile_y):
                @parameter
                fn dot[nelts: Int](n: Int):
                    C.store(m, n + x, C.load[nelts](m, n + x) + A[m, k] * B.load[nelts](k, n + x))
                vectorize[dot, nelts, size = tile_x]()

        # We hardcode the tile factor to be 4.
        alias tile_size = 4
        tile[calc_tile, nelts * tile_size, tile_size](0, 0, A.cols, C.cols)

    parallelize[calc_row](C.rows, C.rows)
# 176.02634276604189 GFLOP/s, a 88013.171383020948 x speedup over Python

elementwise

函数签名为

elementwise[func: fn[Int, Int](StaticIntTuple[$1], /) capturing -> None, simd_width: Int, rank: Int](shape: StaticIntTuple[rank])

将函数闭包 func[width, rank](indices) 作为子任务运行,子任务的数量会根据指定的宽度和形状进行拆分并覆盖所有数据,当所有子任务完成时返回。

例如,我们可以为一个矩阵类型的数据指针,按行赋值

from algorithm.functional import elementwise

alias nrow = 3
alias ncol = 4

fn main():
    var p = DTypePointer[DType.int32].alloc(nrow * ncol)

    @parameter
    fn wise[i: Int, j: Int](x: StaticIntTuple[j]):
        var pos = x[1] + x[0] * i
        print("storing", i, "elements at position", pos)
        p.store[width=i](pos, x[0])

    elementwise[wise, ncol, 2]((nrow, ncol))
    print(p.load[width=nrow * ncol]())

输出结果

storing 4 elements at position 0
storing 4 elements at position 4
storing 4 elements at position 8
[0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2]

unswitch

unswitch 是一种简单的模式,类似于循环内的判断条件外提。该模式有助于进行代码转换,从而减少生成代码中的分支数量。

简单来说就是,将下面这种循环模式

for i in range(...)
    if i < xxx:
        ...

转变为

if i < ...
    for i in range(...)
        ...
else
    for i in range(...)
        if i < xxx:
            ...

减少循环中的判断次数

from algorithm import unswitch

fn tile_test():
    @parameter
    fn switch[sign: Bool]():
        if sign:
            for i in range(10):
                print(i, end=' ')
        else:
            for i in range(10):
                print(i * -1, end=' ')

    unswitch[switch](False)
    unswitch[switch](True)
# 0 -1 -2 -3 -4 -5 -6 -7 -8 -9 0 1 2 3 4 5 6 7 8 9

memory

该模块中目前只有一个函数 parallel_memcpy,其重要功能是并行将内存缓冲区 src 中的 count 个元素复制到 dest

先导入进来

from algorithm import parallel_memcpy

例如,使用简单的并行拷贝

from algorithm import parallel_memcpy

fn main():
    var src = DTypePointer[DType.int8].alloc(8)
    src.store(0, SIMD[DType.int8, 8](1, 2, 3, 4, 5, 6, 7, 8))

    var dest = DTypePointer[DType.int8].alloc(4)
    parallel_memcpy(dest, src, 4)
    
    print(dest.load[width=4]())
    
    src.free()
    dest.free()
# [1, 2, 3, 4]

还可以指针任务数量以及每个任务复制元素的个数。例如

from algorithm import parallel_memcpy

fn main():
    var src = DTypePointer[DType.int8].alloc(8)
    src.store(0, SIMD[DType.int8, 8](1, 2, 3, 4, 5, 6, 7, 8))

    var dest = DTypePointer[DType.int8].alloc(8)
    parallel_memcpy(dest, src, 8, count_per_task=4, num_tasks=2)

    print(dest.load[width=8]())

    src.free()
    dest.free()
# [1, 2, 3, 4, 5, 6, 7, 8]
;