从循环展开到达夫设备

介绍循环展开优化,达夫设备,以及达夫设备的应用

假设你需要连续完成某个相同操作 2 次时,你可能这样写代码:

1
2
Foo();
Foo();

通常情况下你都会这样做,而不是使用一个 for loop。

当这个此时大于或等于 10 次时,你大概就不会一个一个写了,而是使用 for 循环。

然而,如果你稍微思考一下,你都知道:

1
2
3
4
Foo();
Foo();
Foo();
// 省略 99997 条 Foo call。

1
2
3
4
for (ulong i = 0; i < 100_000; i++)
{
    Foo();
}

并不完全等价。

让我们以 CPU 的视角审视一下这个过程:

对于第一个版本,就完全是 100, 000 条 call 指令。

对于第二个版本,来说则是

1
2
3
4
5
6
7
8
9
设置寄存器 i = 0 // xor rcx, rcx, rcx

.loop_begin
判断 i 是否小于 100, 000, 如果不是就跳转 .exit_loop  // cmp rcx, 100000 && jge label
调用 Foo 函数 // call Foo
自增索引寄存器 // inc rcx
回到循环开始 // jmp .loop_begin

.exit_loop

这两个版本各有各的优势以及缺陷:

对于第一个版本:

  • 完成所有任务将执行 100, 000 条指令
  • 所有指令也将占用 100, 000 * 4 字节的内存

对于第二个版本:

  • 完成所有任务将执行大约 100,000 * 6 条指令
  • 所有指令仅占用 6 * 4 字节的内存

从性能上讲,第一个版本少执行大量指令,意味着更高的性能。但是从内存占用上讲,第一个版本将消耗大量内存,意味着更大的二进制可执行文件。详细可以看最后一节

思考

那我们能否找到一个平衡点,即,损失一点内存占用,但是提高性能?

不难发现,第二个版本的性能损失来自于每次循环中的以下部分:

  • 判断循环索引寄存器
  • 自增循环索引寄存器
  • 跳转 label

因此,我们的任务便是减少这三条指令的执行次数。那么如何减少呢?

答案非常简单,让我们按照第一个版本的代码,在循环体中多次调用 Foo()。这样,我们就可以减少循环次数,也就减少了额外的指令开销。

例如,我们可以按照以下方式改写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 循环次数变成了原来的 1/10
for (ulong i = 0; i < 10_000; i++)
{
    // 循环一次执行十条 call
    Foo();
    Foo();
    Foo();
    Foo();
    Foo();
    Foo();
    Foo();
    Foo();
    Foo();
    Foo();
}

这下,我们只需要执行大约 10_000 * 5 + 100_000 条指令,以及大幅减少了执行指令的条数。

这个过程就是循环优化。大部分编译器都支持这种优化。

问题

循环优化好是好,但是事情并不总是这么美好。当循环次数不是展开倍率的整数倍时,我们需要在循环完成后再手动执行。当循环次数不是常量时,就更麻烦了!

要实现一个对于任意给定循环次数 n 都能够正确执行的循环展开,我们可以使用以下代码来实现:

注意我切换成了 C 语言,这是为了给后面的达夫设备埋下伏笔

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void task(int n)
{
    const int UNROLL_COUNT = 10;

    size_t loop_count = (n + UNROLL_COUNT - 1) / UNROLL_COUNT;

    for (size_t i = 0; i < loop_count; i++)
    {
        foo();
        foo();
        foo();
        // 省略 7 行
    }

    // 执行剩余的次数
    switch (n % UNROLL_COUNT)
    {
        case 9: foo();
        case 8: foo();
        case 7: foo();
        case 6: foo();
        case 5: foo();
        case 4: foo();
        case 3: foo();
        case 2: foo();
        case 1: foo();
    }
}

达夫设备

我猜大部分人都会这样写,虽然可能会有一些小区别。然而 Tom Duff 给出了一个估计只有外星人才能一眼看明白的解法1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void task(int count)
{
    register count;
    {
        register n = (count + 7) / 8;
        switch(count % 8) {
            case 0: do{ foo();
            case 7:     foo();
            case 6:     foo();
            case 5:     foo();
            case 4:     foo();
            case 3:     foo();
            case 2:     foo();
            case 1:     foo();
                    } while (--n > 0);
        }
    }
}

我特意关闭了语法高亮。一眼看上去,这 tm 是人能写出来的?你大概甚至很难相信这个代码能够通过编译。但它确实可以,并且据 Tom Duff 所说,它运行得很好1

我这里给出我直译的版本。尽管仍有一点区别,但是这个区别正是达夫设备的价值所在。

直译版

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void task(int count)
{
    register count;
    {
        register n = (count + 7) / 8;
        switch(count % 8) {
            case 0: goto .remainder_is_0;
            case 7: goto .remainder_is_7;
            case 6: goto .remainder_is_6;
            case 5: goto .remainder_is_5;
            case 4: goto .remainder_is_4;
            case 3: goto .remainder_is_3;
            case 2: goto .remainder_is_2;
            case 1: goto .remainder_is_1;
        }

        do
        {
            .remainder_is_0: foo();
            .remainder_is_1: foo();
            .remainder_is_2: foo();
            .remainder_is_3: foo();
            .remainder_is_4: foo();
            .remainder_is_5: foo();
            .remainder_is_6: foo();
            .remainder_is_7: foo();
        } while(--n > 0);
	}
}

当刚进入函数时,会先计算余数,并根据余数跳转到循环体内部,执行相应次数的 foo()。接着,将会开始 do-while 循环,通过循环的方式执行剩下的次数。

当我们仅考虑 foo() 的执行情况,我们可以得出以下版本的代码。

意译版

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void task(int count)
{
    register count;
    {
        register n = (count + 7) / 8;
        switch(count % 8) {
            case 0: foo();
            case 7: foo();
            case 6: foo();
            case 5: foo();
            case 4: foo();
            case 3: foo();
            case 2: foo();
            case 1: foo();
        }

        do
        {
            foo();
            foo();
            foo();
            foo();
            foo();
            foo();
            foo();
            foo();
        } while(--n > 0);
	}
}

这个版本的代码仅用于你了解这个过程中发生了什么。让我们回到上面直译版的代码。

我前面提到,直译版的代码于 Tom Duff 给出的版本仍有一点区别。这个区别就是,我在 switch 语句的各个 case 中又使用 goto 跳转到了对应的位置,但是 Tom Duff 直接利用了 switch 语句跳转到了对应的部分。

在这个过程中,每一个 case 就像 label 一样仅用于标识指令的地址而不影响 switch 语句内部其他的语句的语义,也不影响内部的控制流。

达夫设备的性能

从性能上讲,达夫设备与我们编写的循环展开或者上面的意译版性能相同。不过,达夫设备并不总是在所有情况下提供最高性能(假设循环展开次数相同)。你可以查看 达夫设备 - 维基百科 了解更多。

达夫设备应用?

那么这个“特性”有什么作用呢?

PuTTY 的作者使用这种特性,在 C 语言中实现了不改变代码控制流的情况下的无栈协程。

通常来说,对于这样的协程方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public static IEnumerable<int> Fib()
{
    int prev = 0, next = 1;
    yield return prev;
    yield return next;

    while (true)
    {
        int sum = prev + next;
        yield return sum;
        prev = next;
        next = sum;
    }
}

会被编译成一个完全状态机:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public static IEnumerable<int> Fib() => new FibStateMachine(-2);

[CompilerGenerated]
private sealed class FibStateMachine : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
{
    private int state;
    private int current;
    private int prev;
    private int next;
    private int sum;

    int IEnumerator<int>.Current => this.current;
    object IEnumerator.Current => this.current;

    public FibStateMachine(int state)
        => this.state = state;

    private bool MoveNext()
    {
        switch (this.state)
        {
            default:
                return false;
            case 0:
                this.state = -1;
                this.prev = 0;
                this.next = 1;
                this.current = this.prev;
                this.state = 1;
                return true;
            case 1:
                this.state = -1;
                this.current = this.next;
                this.state = 2;
                return true;
            case 2:
                this.state = -1;
                break;
            case 3:
                this.state = -1;
                this.prev = this.next;
                this.next = this.sum;
                break;
        }
        this.sum = this.prev + this.next;
        this.current = this.sum;
        this.state = 3;
        return true;
    }

    IEnumerator<int> IEnumerable<int>.GetEnumerator()
    {
        if (this.state == -2)
        {
            this.state = 0;
            return this;
        }
        return new FibStateMachine(0);
    }

    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<int>)this).GetEnumerator();
    void IEnumerator.Reset() => throw new NotSupportedException();
    void IDisposable.Dispose() { }
}

可以看到,方法的控制流被编译器完全改变,因为每一个 yield return 语句都意味着下一次进入函数时要从一个新的状态开始继续运行。不仅如此,原有方法现在仅仅返回一个新的状态机对象,而不包含任何实现。这意味着,在原方法中,不改变控制流的情况下很难实现协程效果。

Simon Tatham 利用达夫设备和宏,在 C 语言中,仅需插入少许代码即可实现无栈协程!就像我在前面给出的 C# 版本的协程一样。你一眼就能理解修改后的协程方法的控制流。

你可以点击以下链接进行了解

然而 Simon Tatham 的无栈协程也仅仅能存在于理论中,并且他给出的代码使用全局变量来保存协程上下文,因此不能同时调用同一个协程方法。而且,就像 Simon Tatham 在最后说的一样,这些“可怕破坏性的 crReturn 宏”,“非常糟糕的清晰度”以及“难如登天的重写复杂度”都阻止你在任何场合使用它。

不过在了解这些原理的过程中,你的能力又提升了不少,不是吗?

循环展开的性能

让我们回到文章一开始的三个代码片段。

并非第一个版本的性能就是第二个版本的 1/6,由于存在大量指令,CPU 取指令同样消耗时间,并且这是一个相对耗时的任务。CPU 也具有分支预测等优化技巧来减少每次条件判断的耗时。并且在这种情况下,分支预测通常有较高的正确率。

同时,Foo 内部的实现也影响执行效率的倍率。当 Foo 内部的实现越复杂,指令越多,循环所导致的性能缺陷就越不明显。

因此,当你实际 Benchmark 这两段代码时,可能并不会有那么可观的差距。

我使用以下代码进行 Benchmark:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class BenchmarkLoopUnroll
{
    [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
    public static void Foo()
    {
    }

    [Benchmark]
    [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
    public void ForLoopCompilerOptimized()
    {
        for (int i = 0; i < 1000; i++)
        {
            Foo();
        }
    }

    [Benchmark]
    [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
    public void ForLoop10TimesUnroll()
    {
        for (int i = 0; i < 100; i++)
        {
            Foo();
            Foo();
            Foo();
            Foo();
            Foo();
            Foo();
            Foo();
            Foo();
            Foo();
            Foo();
        }
    }

    [Benchmark]
    [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
    public void ForLoopNoOptimization()
    {
        for (int i = 0; i < 1000; i++)
        {
            Foo();
        }
    }

    [Benchmark(Baseline = true)]
    [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
    public void HardCodedThousandCall()
    {
        Foo();
        Foo();
        Foo();
        // 省略 997 行
    }
}

使用 RyuJIT 得到以下测试结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// * Summary *

BenchmarkDotNet v0.14.0, Ubuntu 24.04 LTS (Noble Numbat) WSL
13th Gen Intel Core i5-13500H, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.108
  [Host]     : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX2
  DefaultJob : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX2

| Method                   | Mean     | Error     | StdDev    | Ratio | RatioSD |
|------------------------- |---------:|----------:|----------:|------:|--------:|
| HardCodedThousandCall    | 1.095 us | 0.0129 us | 0.0120 us |  1.00 |    0.02 |
| ForLoopNoOptimization    | 1.110 us | 0.0211 us | 0.0187 us |  1.01 |    0.02 |
| ForLoop10TimesUnroll     | 1.050 us | 0.0193 us | 0.0180 us |  0.96 |    0.02 |
| ForLoopCompilerOptimized | 1.070 us | 0.0117 us | 0.0109 us |  0.98 |    0.01 |

// * Legends * 
  Mean    : Arithmetic mean of all measurements
  Error   : Half of 99.9% confidence interval
  StdDev  : Standard deviation of all measurements
  Ratio   : Mean of the ratio distribution ([Current]/[Baseline])
  RatioSD : Standard deviation of the ratio distribution ([Current]/[Baseline])
  1 us    : 1 Microsecond (0.000001 sec)

// ***** BenchmarkRunner: End *****
Run time: 00:00:59 (59.07 sec), executed benchmarks: 4

可以看到,ForLoopNoOptimization 相较于 HardCodedThousandCall 并没有慢很多,虽然确实慢了一些。不过我们手动展开的方法 ForLoop10TimesUnroll 确实是有效的,并且比编译器自带的优化效果还要好。


MIT LICENSE
使用 Hugo 构建
主题 StackJimmy 设计