Rust 有一天能在对象移动过程中优化掉按位复制吗?

问题描述 投票:0回答:2

考虑片段

struct Foo {
    dummy: [u8; 65536],
}

fn bar(foo: Foo) {
    println!("{:p}", &foo)
}

fn main() {
    let o = Foo { dummy: [42u8; 65536] };
    println!("{:p}", &o);
    bar(o);
}

该计划的典型结果

0x7fffc1239890
0x7fffc1229890

地址不同。

显然,大数组

dummy
已被复制,正如编译器移动实现中所预期的那样。不幸的是,这可能会对性能产生不小的影响,因为
dummy
是一个非常大的数组。这种影响可能会迫使人们选择通过引用传递参数,即使函数实际上在概念上“消耗”了参数。

由于

Foo
不导出
Copy
,因此对象
o
被移动。由于 Rust 禁止访问移动的对象,那么是什么阻止
bar
“重用”原始对象
o
,迫使编译器生成可能昂贵的按位副本?是否存在根本性的困难,或者我们有一天会看到编译器优化掉这个按位复制?

rust move-semantics llvm-codegen
2个回答
27
投票

鉴于在 Rust 中(与 C 或 C++ 不同),值的地址不被认为是重要的,因此就语言而言,没有任何东西可以阻止副本的省略。

然而,今天 rustc 没有优化任何东西:所有优化都委托给 LLVM,看来你在这里遇到了 LLVM 优化器的限制(目前还不清楚这个限制是由于 LLVM 接近 C 的语义还是只是一个遗漏) ).

因此,有两种改进代码生成的方法:

  • 教导 LLVM 执行此优化(如果可能)
  • 教 rustc 执行此优化(现在 rustc 具有 MIR,优化过程即将到来)

但现在您可能只是想避免在堆栈上分配如此大的对象,您可以

Box
例如。


0
投票

实际上没有什么可以阻止编译器像这样优化数组副本。问题在于,将值传递给像

println!
这样的东西似乎非常谨慎,因为它们实际上总是收到对这些值的引用,并且可能由于某种原因依赖于这些值的确切地址。这正是您的示例中的情况:您甚至明确要求提供地址,因此编译器足够聪明,不会优化这些地址 - 毕竟您要求的是副本。

让我们看一下您问题中稍微简化的示例:

fn bar(foo: [u8; 65536]) {
    println!("{}", foo[123]);
}

pub fn main() {
    let o = [42u8; 65536];
    println!("{}", o[123]);
    bar(o);
}

您可以使用 -C opt-level=2 通过

Compiler Explorer
运行它,并看到以下汇编代码:

example::main::hd2bfa2df25bfe7d7:
        push    r15
        push    r14
        push    r12
        push    rbx
        mov     r11, rsp
        sub     r11, 131072
.LBB0_1:
        sub     rsp, 4096
        mov     qword ptr [rsp], 0
        cmp     rsp, r11
        jne     .LBB0_1
        sub     rsp, 72
        lea     rbx, [rsp + 65608]
        mov     edx, 65536
        mov     rdi, rbx
        mov     esi, 42
        call    qword ptr [rip + memset@GOTPCREL]
        lea     rax, [rsp + 65731]
        mov     qword ptr [rsp + 8], rax
        mov     r14, qword ptr [rip + core::fmt::num::imp::<impl core::fmt::Display for u8>::fmt::ha81407c30cb780ca@GOTPCREL]
        mov     qword ptr [rsp + 16], r14
        lea     r15, [rip + .L__unnamed_1]
        mov     qword ptr [rsp + 72], r15
        mov     qword ptr [rsp + 80], 2
        mov     qword ptr [rsp + 104], 0
        lea     rax, [rsp + 8]
        mov     qword ptr [rsp + 88], rax
        mov     qword ptr [rsp + 96], 1
        mov     r12, qword ptr [rip + std::io::stdio::_print::hd6837e34a66547dd@GOTPCREL]
        lea     rdi, [rsp + 72]
        call    r12
        lea     rdi, [rsp + 72]
        mov     edx, 65536
        mov     rsi, rbx
        call    qword ptr [rip + memcpy@GOTPCREL]
        lea     rax, [rsp + 195]
        mov     qword ptr [rsp + 56], rax
        mov     qword ptr [rsp + 64], r14
        mov     qword ptr [rsp + 8], r15
        mov     qword ptr [rsp + 16], 2
        mov     qword ptr [rsp + 40], 0
        lea     rax, [rsp + 56]
        mov     qword ptr [rsp + 24], rax
        mov     qword ptr [rsp + 32], 1
        lea     rdi, [rsp + 8]
        call    r12
        add     rsp, 131144
        pop     rbx
        pop     r12
        pop     r14
        pop     r15
        ret

大部分代码在这里并不真正相关,但重要的是它调用

memset
(创建一个 65536 字节长的数组,所有值都设置为 42),然后在调用
memcpy
之前调用
bar()
。如果你仔细观察,你会发现
bar
甚至已经被内联在这里,但数组仍在被复制。

现在让我们再次更改示例,以消除在

println!
语句中对数组的任何直接使用:

fn bar(foo: [u8; 65536]) {
    let val = foo[123];
    println!("{}", val);
}

pub fn main() {
    let o = [42u8; 65536];
    let val = o[123];
    println!("{}", val);
    bar(o);
}

编译器资源管理器

现在汇编代码看起来完全不同了:

example::main::hd2bfa2df25bfe7d7:
        push    r15
        push    r14
        push    r12
        push    rbx
        sub     rsp, 72
        mov     byte ptr [rsp + 6], 42
        lea     rax, [rsp + 6]
        mov     qword ptr [rsp + 8], rax
        mov     rbx, qword ptr [rip + core::fmt::num::imp::<impl core::fmt::Display for u8>::fmt::ha81407c30cb780ca@GOTPCREL]
        mov     qword ptr [rsp + 16], rbx
        lea     r14, [rip + .L__unnamed_1]
        mov     qword ptr [rsp + 24], r14
        mov     qword ptr [rsp + 32], 2
        mov     qword ptr [rsp + 56], 0
        lea     r15, [rsp + 8]
        mov     qword ptr [rsp + 40], r15
        mov     qword ptr [rsp + 48], 1
        mov     r12, qword ptr [rip + std::io::stdio::_print::hd6837e34a66547dd@GOTPCREL]
        lea     rdi, [rsp + 24]
        call    r12
        mov     byte ptr [rsp + 7], 42
        lea     rax, [rsp + 7]
        mov     qword ptr [rsp + 8], rax
        mov     qword ptr [rsp + 16], rbx
        mov     qword ptr [rsp + 24], r14
        mov     qword ptr [rsp + 32], 2
        mov     qword ptr [rsp + 56], 0
        mov     qword ptr [rsp + 40], r15
        mov     qword ptr [rsp + 48], 1
        lea     rdi, [rsp + 24]
        call    r12
        add     rsp, 72
        pop     rbx
        pop     r12
        pop     r14
        pop     r15
        ret

没有

memset
memcpy
或其他大块内存操作的痕迹。这些值已在编译时计算并直接传递到
std::io::stdio::_print()
的两次调用中。当然,这个例子过于简单了,但重点是编译器可以优化这些东西。

最后一个例子:

#[inline(never)]
fn bar(foo: [u8; 65536]) {
    let val = foo[123];
    println!("{}", val);
}

pub fn main() {
    let o = [42u8; 65536];
    bar(o);
}

编译器资源管理器

注意

#[inline(never)]
处的
fn bar()
!这会编译成以下汇编代码:

example::bar::hd9b3b9e4606cd696:
        sub     rsp, 72
        movzx   eax, byte ptr [rdi + 123]
        mov     byte ptr [rsp + 7], al
        lea     rax, [rsp + 7]
        mov     qword ptr [rsp + 8], rax
        mov     rax, qword ptr [rip + core::fmt::num::imp::<impl core::fmt::Display for u8>::fmt::ha81407c30cb780ca@GOTPCREL]
        mov     qword ptr [rsp + 16], rax
        lea     rax, [rip + .L__unnamed_1]
        mov     qword ptr [rsp + 24], rax
        mov     qword ptr [rsp + 32], 2
        mov     qword ptr [rsp + 56], 0
        lea     rax, [rsp + 8]
        mov     qword ptr [rsp + 40], rax
        mov     qword ptr [rsp + 48], 1
        lea     rdi, [rsp + 24]
        call    qword ptr [rip + std::io::stdio::_print::hd6837e34a66547dd@GOTPCREL]
        add     rsp, 72
        ret

example::main::hd2bfa2df25bfe7d7:
        push    rbx
        mov     r11, rsp
        sub     r11, 65536
.LBB1_1:
        sub     rsp, 4096
        mov     qword ptr [rsp], 0
        cmp     rsp, r11
        jne     .LBB1_1
        mov     rbx, rsp
        mov     edx, 65536
        mov     rdi, rbx
        mov     esi, 42
        call    qword ptr [rip + memset@GOTPCREL]
        mov     rdi, rbx
        call    example::bar::hd9b3b9e4606cd696
        add     rsp, 65536
        pop     rbx
        ret

在这里您可以看到,即使

main
函数创建了实际的数组,它也没有被复制到
bar()
中。相反,它的地址只是保存在
rbx
寄存器中,然后作为
rdi
中的指针传递给
bar
函数。零拷贝,我们都好!

© www.soinside.com 2019 - 2024. All rights reserved.