为什么Rust编译器在对象移动后不重用栈上的内存?

18

我曾认为一旦对象被移动,其在堆栈上占用的内存就可以被重用于其他目的。但是,下面的最简例子显示相反。

#[inline(never)]
fn consume_string(s: String) {
    drop(s);
}

fn main() {
    println!(
        "String occupies {} bytes on the stack.",
        std::mem::size_of::<String>()
    );

    let s = String::from("hello");
    println!("s at {:p}", &s);
    consume_string(s);

    let r = String::from("world");
    println!("r at {:p}", &r);
    consume_string(r);
}

在我的计算机上使用--release标志编译代码后,它会输出以下内容。

String occupies 24 bytes on the stack.
s at 0x7ffee3b011b0
r at 0x7ffee3b011c8

很明显,即使移动了sr也不会重用原本属于s的24字节堆栈块。我认为重用移动对象的堆栈内存是安全的,但为什么Rust编译器不这样做呢?我有遗漏任何角落吗?

更新: 如果我用花括号将s包围起来,r可以重用堆栈上的24字节块。

#[inline(never)]
fn consume_string(s: String) {
    drop(s);
}

fn main() {
    println!(
        "String occupies {} bytes on the stack.",
        std::mem::size_of::<String>()
    );

    {
        let s = String::from("hello");
        println!("s at {:p}", &s);
        consume_string(s);
    }

    let r = String::from("world");
    println!("r at {:p}", &r);
    consume_string(r);
}

以上代码会得到以下输出结果。

String occupies 24 bytes on the stack.
s at 0x7ffee2ca31f8
r at 0x7ffee2ca31f8
我认为花括号不应该有任何影响,因为在调用comsume_string(s)s的生命周期已经结束,其drop handler在comsume_string()内被调用。为什么添加花括号可以启用优化? 我使用的Rust编译器版本如下。
rustc 1.54.0-nightly (5c0292654 2021-05-11)
binary: rustc
commit-hash: 5c029265465301fe9cb3960ce2a5da6c99b8dcf2
commit-date: 2021-05-11
host: x86_64-apple-darwin
release: 1.54.0-nightly
LLVM version: 12.0.1
更新 2: 我想澄清一下我的问题重点。我想知道提出的“堆栈重用优化”属于哪个类别。
  1. 这是一种无效的优化。在某些情况下,如果我们执行“优化”,编译后的代码可能会失败。
  2. 这是一种有效的优化,但编译器(包括rustc前端和llvm)无法执行它。
  3. 这是一种有效的优化,但被暂时关闭了,就像这里
  4. 这是一种有效的优化,但被遗漏了。将来会添加。

3
根据链接问题中其他回答所述,LLVM决定是否在内存中为不同的对象重用地址空间,并且观察堆栈中地址对应的值可能会影响编译输出。Rust编译器本身不会强制实施任何一种行为。 - E net4
因此,归根结底,这主要是一个需要在LLVM中检查/报告的问题,除非它是由于MIR产生“错误”的LLVM-IR而发生的(我真的不知道)。与类似的C++代码进行比较可能会提供有用信息,但我不知道如何确保std::string已经“死亡”,因为移动后的值始终有效。 - Masklinn
1
@trentcl 我对打印应该放弃优化的想法表示反对:在 Rust 中,获取引用是非常普遍的,大多数方法调用都会这样做。如果这足以导致去优化,那么我们就有问题了(虽然不是很大的问题)。尽管 Emoun 在下面的调查似乎暗示问题可能出在其他地方。 - Masklinn
1
@Masklinn 你错了。仅仅获取一个引用并不会阻碍优化,因为对象的地址对代码的行为没有任何可观察的影响。直接打印或以其他方式观察对象的地址 阻碍优化,因为优化器必须使用非局部推理来得出“任何”值都可以被打印的结论。 - trent
1
@trentcl 我刚刚创建了另一个示例。https://godbolt.org/z/TEY76Wsjj 在这个示例中,(1) .push_str() 强制 String 实例占用栈空间 (2) 没有可观察到的行为被改变,因为没有可观察到的东西 (3) 这个空间在 s 结束其生命周期后不会被重复使用。 - Zhiyao
显示剩余17条评论
2个回答

8

我的简短结论是:错过了优化的机会。

因此,我首先调查了您的consume_string函数是否真正起到作用。为此,我创建了以下(稍微更多)最小示例:

struct Obj([u8; 8]);
fn main()
{
    println!(
        "Obj occupies {} bytes on the stack.",
        std::mem::size_of::<Obj>()
    );

    let s = Obj([1,2,3,4,5,6,7,8]);
    println!("{:p}", &s);
    std::mem::drop(s);
    
    let r = Obj([11,12,13,14,15,16,17,18]);
    println!("{:p}", &r);
    std::mem::drop(r);
}

我使用std::mem::drop代替consume_string,它专门用于消耗一个对象。这段代码的行为与你的代码完全一样:

Obj occupies 8 bytes on the stack.
0x7ffe81a43fa0
0x7ffe81a43fa8

删除drop不会影响结果。

那么问题就在于为什么rustc在r被引用之前并没有注意到s已经失效了。正如您的第二个例子所示,将s封闭在一个作用域内将允许优化。

为什么这样做有效呢?因为Rust语义规定对象在其作用域结束时被drop。由于s在内部作用域中,所以它会在该作用域退出之前被drop。如果没有这个作用域,则s将一直存活,直到main函数退出。

为什么当将s移动到一个函数中时,它应该在退出时被drop,但这种方法行不通? 可能是因为rust没有正确地将s使用的内存位置标记为函数调用后释放。正如评论中提到的那样,实际上是LLVM处理了这种优化(据我所知称为"栈着色"),这意味着rustc必须在内存不再使用时正确地告诉它。显然,从您的最后一个例子可以看出,rustc会在作用域退出时进行处理,但是当对象移动时似乎没有这样做。


我不会把它称为明显的优化,更多的是你想尽可能少地使用堆栈,而不是不用。无论如何,代码的运行速度都是相同的。 - Stargateur
5
考虑到缓存,较小的内存占用可以提高局部性和减少缓存未命中,从而使代码运行更快。此外,在嵌入式系统上,RAM是稀缺资源,优化可以产生显著差异。 - Zhiyao
5
在这种具体情况下,使用更多/更少的堆栈可能并不重要,但在现实世界中,使用更多/更少的堆栈可能会影响性能,因此更大的函数可能会从这种优化中受益。此外,LLVM在-O0及以上级别启用了这种优化,因此他们明确地认为它几乎总是值得的。 - Emoun
这也更不重要,因为堆栈使用是完全顺序的,所以即使它占用了更多的缓存行,结果大多数情况下会清除缓存行,而不是缓存未命中,因为数据没有从其写入位置“远离”读取。 - Masklinn
2
清除缓存行可能会导致其他数据的读写错失。例如,该函数可能会清除其调用者使用的缓存行,这意味着调用者可能会在以后遇到缺失。 - Emoun
3
我已经在 https://github.com/rust-lang/rust/issues/85230 上报了这个问题。 - Jeff Muizelaar

0

我认为 fn drop 不会释放 S 的内存,只是调用了 fn drop。 在第一种情况下,s 仍然使用堆栈内存,Rust 无法重复使用。 在第二种情况下,由于 {} 作用域,内存被释放。因此,堆栈内存得以重复使用


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接