Rust: LLVM循环优化可以使安全程序崩溃

创建于 2015-09-29  ·  97评论  ·  资料来源: rust-lang/rust

在当前稳定版,测试版和夜间版本中,以发布模式编译时,以下代码段崩溃:

enum Null {}

fn foo() -> Null { loop { } }

fn create_null() -> Null {
    let n = foo();

    let mut i = 0;
    while i < 100 { i += 1; }
    return n;
}

fn use_null(n: Null) -> ! {
    match n { }
}


fn main() {
    use_null(create_null());
}

https://play.rust-lang.org/?gist=1f99432e4f2dccdf7d7e&version=stable

这基于以下LLVM示例删除了一个我已经意识到的循环: https :
似乎发生的是,由于C允许LLVM删除无副作用的无限循环,因此我们最终执行了必须武装的match

A-LLVM C-bug E-medium I-needs-decision I-unsound 💥 P-medium T-compiler WG-embedded

最有用的评论

如果有人想打高尔夫测试用例代码:

pub fn main() {
   (|| loop {})()
}

使用@sfanxiang在https://github.com/rust-lang/rust/pull/59546中添加的-Z insert-sideeffect rustc标志,它继续循环:)

之前:

main:
  ud2

后:

main:
.LBB0_1:
  jmp .LBB0_1

所有97条评论

优化代码的LLVM IR为

; Function Attrs: noreturn nounwind readnone uwtable
define internal void @_ZN4main20h5ec738167109b800UaaE() unnamed_addr #0 {
entry-block:
  unreachable
}

这种优化打破了通常应适用于无人居住类型的主要假设:应该没有那种类型的值。
rust-lang / rfcs#1216建议在Rust中显式处理此类类型。 可能有效的方法是确保LLVM永远不必处理它们,并注入适当的代码以确保在需要时有所不同(IIUIC这可以通过适当的属性或内部调用来实现)。
LLVM邮件列表中最近也讨论了该主题: http :

分流:我提名

好像不好! 如果LLVM没有办法说“是的,这个循环确实是无限的”,但是我们可能只需要静观其变,等待上游讨论解决。

防止无限循环被优化的一种方法是在其中添加unsafe {asm!("" :::: "volatile")} 。 这类似于LLVM邮件列表中建议的llvm.noop.sideeffect内在函数,但是它可能会阻止某些优化。
为了避免性能损失并仍然保证不会优化分散的函数/循环,我相信如果存在无人居住的值,那么插入一个空的不可优化循环(即loop { unsafe { asm!("" :::: "volatile") } } )就足够了范围。
如果LLVM优化了应该发散到不再发散的代码,则此类循环将确保控制流程仍然无法进行。
在LLVM无法优化发散代码的“幸运”情况下,这种循环将被DCE消除。

这与#18785有关吗? 关于无限递归就是UB,但是听起来根本原因可能是相似的:LLVM并不认为不停顿是一种副作用,因此,如果一个函数除了不停顿之外没有其他副作用,我们很乐意进行优化它走了。

@geofft

这是同样的问题。

是的,看起来一样。 在该问题的更深处,他们展示了如何获取undef ,我认为从中获取(似乎安全的)程序崩溃并不难。

:+1:

所以我一直想知道有人报告这要多久。 :)我认为,最好的解决方案当然是如果我们可以告诉LLVM不要对潜在的无限循环这么激进。 否则,我认为我们唯一能做的就是对Rust本身进行保守分析,以确定是否:

  1. 循环将终止或
  2. 循环将产生副作用(I / O操作等,我恰好忘记了如何在C中定义)

这两个都应足以避免未定义的行为。

分流:P-中

我们希望在投入大量精力之前先了解LLVM会做什么,而且这似乎不太可能在实践中引起问题(尽管我个人在开发编译器时也遇到了麻烦)。 没有向后的不兼容性问题值得关注。

引用LLVM邮件列表讨论:

 The implementation may assume that any thread will eventually do one of the following:
   - terminate
   - make a call to a library I/O function
   - access or modify a volatile object, or
   - perform a synchronization operation or an atomic operation

 [Note: This is intended to allow compiler transformations such as removal of empty loops, even
  when termination cannot be proven. — end note ]

@dotdash您引用的摘录来自C ++规范; 这基本上是对“如何在C中定义[具有副作用]的答案”(也已由标准委员会确认:http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1528 .htm)。

关于LLVM IR的预期行为是什么,有些困惑。 https://llvm.org/bugs/show_bug.cgi?id=24078表明LLVM IR中似乎没有准确而明确地说明无限循环的语义。 它符合C ++的语义,很可能是出于历史原因和方便起见(我仅设法跟踪https://groups.google.com/forum/#!topic/llvm-dev/j2vlIECKkdE,这显然是指时间如果没有优化无限循环,则需要一段时间才能更新C / C ++规范以允许使用它)。

从线程中可以明显看出,人们希望尽可能有效地优化C ++代码(即,还要考虑消除无限循环的机会),但是在同一线程中,有几个开发人员(包括积极为LLVM做出贡献的开发人员)拥有对保持无限循环的能力表现出了兴趣,这是其他语言所需要的。

@ ranma42我知道这一点,我只是引用它作为参考,因为解决此问题的一种可能性是检测生锈中的此类循环并将其添加到其中之一,以阻止LLVM执行此优化。

这是健全性问题吗? 如果是这样,我们应该这样标记它。

是的,在@ ranma42的示例之后,这种方式显示了它如何轻松游乐场链接

@bluss

政策是错误代码问题(也包括健全性问题)(即大多数错误问题)应标记I-wrong

因此,仅回顾一下先前的讨论,实际上我可以看到两种选择:

  • 等待LLVM提供解决方案。
  • 在可能存在无限循环或无限递归的地方引入no-op asm语句(#18785)。

后者是不好的,因为它会抑制优化,因此我们想稍微谨慎一点-基本上在我们无法证明自己终止的地方。 您还可以想象将其更多地与LLVM的优化方式联系起来-即,仅当我们能够检测到LLVM可能被认为是无限循环/递归的情况时才进行介绍-但(a)需要跟踪LLVM和(b )至少需要比我拥有的知识更深的知识。

等待LLVM提供解决方案。

跟踪此问题的LLVM错误是什么?

旁注: while true {}表现出此行为。 也许应该将皮棉升级为默认错误,并获得一条注释,说明当前该皮棉可能表现出不确定的行为?

另外,请注意,这对C无效。LLVM发出此参数表示clang中存在错误。

void foo() { while (1) { } }

void create_null() {
        foo();

        int i = 0;
        while (i < 100) { i += 1; }
}

__attribute__((noreturn))
void use_null() {
        __builtin_unreachable();
}


int main() {
        create_null();
        use_null();
}

优化会崩溃; 根据C11标准,这是无效行为

An iteration statement whose controlling expression is not a constant
expression, [note 156] that performs no  input/output  operations,
does  not  access  volatile  objects,  and  performs  no synchronization or
atomic operations in its body, controlling expression, or (in the case of
a for statement) its expression-3, may be   assumed   by   the
implementation to terminate. [note 157]

156: An omitted controlling expression is replaced by a nonzero constant,
     which is a constant expression.
157: This  is  intended  to  allow  compiler  transformations  such  as
     removal  of  empty  loops  even  when termination cannot be proven. 

请注意,“其控制表达式不是常量表达式”- while (1) { }1是常量表达式,因此不能删除

循环删除是我们可以简单地删除的优化通道吗?

@ubsan

您是在LLVM的bugzilla中找到了有关该错误的报告还是填写了报告? 似乎在C ++无限循环中,_can_永不终止是未定义的行为,但是在C中,它们是已定义的行为(在某些情况下可以安全删除,在其他情况下则不能删除)。

@gnzlbg我正在提交错误。

https://llvm.org/bugs/show_bug.cgi?id=31217

在#42009中重复我自己:在某些情况下,此错误可能导致发出完全不包含机器指令的可外部调用的函数。 这永远都不会发生。 如果LLVM推断出pub fn永远都不能被正确的代码调用,则它至少应发出陷阱指令作为该函数的主体。

这是在今天的博客文章中提到的: https

复制更简单: https

为此的LLVM错误是https://bugs.llvm.org/show_bug.cgi?id=965 (于2006年开放)。

@zackw LLVMTrapUnreachable 。 我尚未对此进行测试,但看起来应该将Options.TrapUnreachable = true;LLVMRustCreateTargetMachine应该可以解决您的问题。 尽管我没有做任何测量,但它的成本可能很低,默认情况下可以完成。

@ oli-obk不幸的是,不仅是循环删除操作。 问题来自广泛的假设,例如:(a)分支没有副作用,(b)不包含具有副作用的指令的函数也没有副作用,并且(c)可以移动对没有副作用的函数的调用,或者已删除。

似乎有一个补丁: https :

@sunfishcode看起来像您的LLVM补丁程序在https://reviews.llvm.org/D38336于10月3日被“接受”,您能否提供有关LLVM发行过程的最新信息? 超出接受范围的下一步是什么,您是否知道将来的LLVM版本将包含此补丁?

我与一些离线用户进行了交谈,他们建议我们使用llvmdev线程。 线程在这里:

http://lists.llvm.org/pipermail/llvm-dev/2017-October/118558.html

现在已经结束,结果是我需要进行其他更改。 我认为这些更改会很好,尽管它们将使我花费更多时间。

感谢您的更新,也非常感谢您的努力!

请注意, https: //reviews.llvm.org/rL317729已进入LLVM。 此修补程序计划有一个后续修补程序,该修补程序将使默认情况下无限循环显示定义的行为,因此AFAICT我们需要做的就是等待,最终这将为上游解决。

@zackw我现在创建了#45920来解决不包含代码的函数的问题。

@bstrie是的,第一步已着陆,我正在第二步,默认情况下使LLVM提供无限循环定义的行为。 这是一个复杂的更改,我尚不知道要花多长时间才能完成,但是我将在此处发布更新。

我现在无法对此进行复制: https ://play.rust-lang.org/?gist = stable

@jsgf仍在复制。 您选择了发布模式吗?

@kennytm糟糕,没关系。

请注意, https: //reviews.llvm.org/rL317729已进入LLVM。 此修补程序计划有一个后续修补程序,该修补程序将使默认情况下无限循环显示定义的行为,因此AFAICT我们需要做的就是等待,最终这将为上游解决。

此评论已经过去了几个月。 有人知道后续补丁是否会发生或仍会发生?

或者,似乎我们正在使用的LLVM版本中存在llvm.sideeffect内部函数:我们可以通过将Rust无限循环转换为包含副作用内部函数的LLVM循环来自己解决此问题吗?

如所看到的是https://github.com/rust-lang/rust/issues/38136https://github.com/rust-lang/rust/issues/54214 ,这对即将到来的panic_implementation尤其不利loop {} ,这将使所有出现的panic! UB都没有任何unsafe代码。 哪个……可能会发生的更糟。

只是从另一个角度遇到了这个问题。 这是一个例子:

pub struct Container<'f> {
    string: &'f str,
    num: usize,
}

impl<'f> From<&'f str> for Container<'f> {
    #[inline(always)]
    fn from(string: &'f str) -> Container<'f> {
        Container::from(string)
    }
}

fn main() {
    let x = Container::from("hello");
    println!("{} {}", x.string, x.num);

    let y = Container::from("hi");
    println!("{} {}", y.string, y.num);

    let z = Container::from("hello");
    println!("{} {}", z.string, z.num);
}

该示例可靠地对稳定,beta和夜间进行分段,并显示构造任何类型的未初始化值是多么容易。 这是在操场上

@SergioBenitez该程序不存在段错误,它以堆栈溢出终止(您需要在调试模式下运行它)。 这是正确的行为,因为您的程序只是无限递归,需要无限数量的堆栈空间,在某些时候,堆栈空间将超过可用的堆栈空间。 最小的工作示例

在发行版本中,LLVM可以假定您没有无限递归,并对其进行了优化( mwe )。 这与循环AFAICT无关,而是与https://stackoverflow.com/a/5905171/1422197

@gnzlbg对不起,但您不正确。

程序在释放模式下出现段错误。 这就是重点; 优化导致不合理的行为-LLVM和Rust的语义在这里不一致-我可以使用rustc编写和编译安全的Rust程序,该程序允许我使用未初始化的内存,检查任意内存以及在类型之间任意转换,从而违反了语言的语义。 这与该线程中说明的观点相同。 请注意,原始程序在调试模式下也不会分段。

您似乎还建议在这里进行_different_,非循环优化。 尽管不太重要,但这不太可能,尽管在这种情况下可能需要单独解决。 我的猜测是LLVM注意到尾部递归,将其视为无限循环,并再次优化了此问题的确切含义。

@gnzlbg好吧,稍微将您的优化方法从无限递归中移开(在此处),它的确会生成未初始化的值NonZeroUsize (原来是…0,因此是无效值)。

这就是@SergioBenitez在他们的示例中所做的事情,除了它是与指针有关的,并因此产生了段错误。

我们是否同意@SergioBenitez程序在调试和发布中都有堆栈溢出?

如果是这样,我在@SergioBenitez示例中找不到任何loop s,所以我不知道这个问题将如何应用(毕竟,这个问题涉及无限的loop s)。 如果我错了,请在您的示例中将我指向loop

如前所述,LLVM假定无限递归不会发生(它假定所有线程最终都将终止),但这将是一个与之不同的问题。

我没有检查过LLVM所做的优化或其中一个程序的生成代码,但是请注意,如果发生了段错误,则不会出现段错误。 特别是,被捕获的堆栈溢出(通过堆栈探测+堆栈结束后的未映射保护页)并且不会引起任何内存安全问题,也将显示为段错误。 当然,segfaults也可以指示内存损坏或疯狂的写入/读取或其他健全性问题。

@rkruppe我的程序出现段错误,因为允许构造对随机存储器位置的引用,并且随后读取了该引用。 可以对该程序进行微不足道的修改,以改为写入一个随机的存储位置,而没有太大的困难,可以读取/写入一个特定的存储位置。

@gnzlbg程序在释放模式下不会堆栈溢出。 在释放模式下,程序进行零个函数调用; 堆栈被压入有限次数,纯粹是为了分配本地变量。

程序在释放模式下不会堆栈溢出。

所以? 唯一重要的是示例程序基本上是fn foo() { foo() } ,具有无限递归,这是LLVM不允许的。

唯一重要的是示例程序基本上是fn foo(){foo()},具有无限递归,这是LLVM不允许的。

我不知道您为什么这样说可以解决任何问题。 LLVM考虑了无限递归并循环UB并进行了相应的优化,但在Rust中还是安全的,这是整个问题的重点!

https://reviews.llvm.org/rL317729的作者在此处,确认我尚未实施后续补丁。

您今天可以插入@llvm.sideeffect调用,以确保不会优化循环。 这可能会禁用某些优化,但是理论上并不会太多,因为已经教会了主要的优化方法如何理解它。 如果有人在所有循环中放入@llvm.sideeffect调用,或者将可能变成循环的事情(递归,展开,内联asm ,其他?),从理论上讲就足以解决此问题。

显然,最好有第二个补丁,这样就不必这样做了。 我不知道什么时候才能恢复实施。

两者之间有一些细微的差别,但我不确定是否重要。

递归

#[allow(unconditional_recursion)]
#[inline(never)]
pub fn via_recursion<T>() -> T {
    via_recursion()
}

fn main() {
    let a: String = via_recursion();
}
define internal void @_ZN10playground4main17h1daf53946e45b822E() unnamed_addr #2 personality i32 (i32, i32, i64, %"unwind::libunwind::_Unwind_Exception"*, %"unwind::libunwind::_Unwind_Context"*)* <strong i="9">@rust_eh_personality</strong> {
_ZN4core3ptr13drop_in_place17h95538e539a6968d0E.exit:
  ret void
}

循环

#[inline(never)]
pub fn via_loop<T>() -> T {
    loop {}
}

fn main() {
    let b: String = via_loop();
}
define internal void @_ZN10playground4main17h1daf53946e45b822E() unnamed_addr #2 {
start:
  unreachable
}

Rust 1.29.1,在发布模式下编译,查看LLVM IR。

我认为一般来说我们无法检测到递归(特征对象,C FFI等),因此除非我们可以证明该调用能够,否则我们几乎必须在每个调用站点上使用llvm.sideeffect网站不会递归。 在可以证明的情况下证明没有递归需要过程间分析,除了最​​琐碎的程序,例如fn main() { main() } 。 最好了解实施此修复程序有什么影响,以及是否有替代方法可以解决此问题。

@gnzlbg是的,尽管您可以将@ llvm.sideeffects放在函数的条目处,而不是在调用站点。

奇怪的是,我无法在@SergioBenitez的测试用例中本地复制SEGFAULT。

另外,对于堆栈溢出,是否应该没有其他错误消息? 我以为我们有一些代码可以打印“堆栈溢出”?

@RalfJung您是否在调试模式下尝试过? (我可以在我的机器和游乐场中以调试模式可靠地重现堆栈溢出,因此,如果不是这种情况,也许您需要填补一个错误)。 在--release您不会出现堆栈溢出,因为所有这些代码都没有被优化。


@sunfishcode

没错,尽管您可以将@ llvm.sideeffects放在函数的条目中,而不是在调用位置。

在不确切知道llvm.sideeffects可以阻止哪些优化的情况下,很难说出最佳的前进方式。 尝试生成尽可能少的@llvm.sideeffects是否值得? 如果没有,那么将其放入每个函数调用中可能是最直接的事情。 否则,IIUC是否需要@llvm.sideeffect取决于呼叫站点的作用:

trait Foo {
    fn foo(&self) { self.bar() }
    fn bar(&self);
}

struct A;
impl Foo for A {
    fn bar(&self) {} // not recursive
}
struct B;
impl Foo for B {
    fn bar(&self) { self.foo() } // recursive
}

fn main() {
    let a = A;
    a.bar(); // Ok - no @llvm.sideeffect needed anywhere
    let b = B;
    b.bar(); // We need @llvm.sideeffect on this call site
    let c: &[&dyn Foo] = &[&a, &b];
    for i in c {
        i.bar(); // We need @lvm.sideeffect here too
    }
}

AFAICT,我们必须在函数中放入@llvm.sideeffect ,以防止它们被删除,因此即使这些“优化”值得,我也不认为它们与当前模型直接相关。 即使是这样,这些优化也将依赖于能够证明没有递归。

您是否在调试模式下尝试过? (我可以在调试模式下可靠地在我的机器和游乐场中重现堆栈溢出,因此,如果不是这种情况,那么可能需要填补一个错误)

可以,但是在调试模式下,LLVM不会进行循环优化,因此没有问题。

如果程序在调试模式下溢出,则不应授予LLVM许可证来创建UB。 问题是要弄清楚最终程序是否具有UB,并且从盯着IR看我无法分辨。 它存在段错误,但我不知道为什么。 但是,对于我来说,将“堆栈溢出”程序“优化”为一个段错误程序似乎是一个错误。

如果程序在调试模式下溢出,则不应授予LLVM许可证来创建UB。

但是,对于我来说,将“堆栈溢出”程序“优化”为一个段错误程序似乎是一个错误。

在C语言中,假定执行线程终止,执行易失性存储器访问,I / O或同步原子操作。 如果LLVM-IR无论是偶然还是设计都没有演变为具有相同的语义,这将令我感到惊讶。

Rust代码包含一个永不终止的执行线程,并且不执行任何操作以使其成为C中的UB。我怀疑我们生成的LLVM-IR与具有未定义行为的C程序会一样,所以LLVM对这个Rust程序进行了错误的优化并不奇怪。

它存在段错误,但我不知道为什么。

LLVM删除了无限递归,因此,如上述@SergioBenitez所述,程序将继续执行以下操作:

允许构造对随机存储位置的引用,然后读取该引用。

程序执行的部分就是这一部分:

let x = Container::from("hello");  // invalid reference created here
println!("{} {}", x.string, x.num);  // invalid reference dereferenced here

其中Container::from开始无限递归,LLVM断定永远不会发生,并替换为某个随机值,然后将其取消引用。 您可以在此处查看多种优化方式之一: https ://rust.godbolt.org/z/P7Snex在操场上(https://play.rust-lang.org/?gist=f00d41cc189f9f6897d429350f3781ec&version=stable&mode = release&edition = 2015)由于此优化,人们从调试版本中释放出了不同的恐慌,但是UB是UB是UB。

Rust代码包含一个永不终止的执行线程,并且不执行任何操作以使其成为C中的UB。我怀疑我们生成的LLVM-IR与具有未定义行为的C程序会一样,所以LLVM对这个Rust程序进行了错误的优化并不奇怪。

我的印象是,你前面所说,这是一样的错误的无限循环下发行。 看来我误读了您的消息。 对困惑感到抱歉。

因此,下一步似乎不错的做法是向我们生成的IR中注入一些llvm.sideffect ,并进行一些基准测试?

在C语言中,假定执行线程终止,执行易失性存储器访问,I / O或同步原子操作。

顺便说一句,这是不完全正确的-以恒定的条件的循环(如while (true) { /* ... */ }明确标准允许,即使它不包含任何副作用。 这在C ++中是不同的。 LLVM此处未正确实现C标准。

我给您的印象是您在上面争论过,这与无限循环问题不同。

始终定义非终止Rust程序的行为,而仅在满足某些条件时定义非终止LLVM-IR程序的行为。

我认为这个问题是关于为无限循环修复Rust的实现,以便定义生成的LLVM-IR的行为,为此, @llvm.sideeffect听起来是一个不错的解决方案。

@SergioBenitez提到,还可以使用递归来创建不终止的Rust程序, @ rkruppe认为无限递归和无限循环是等效的,因此它们都是相同的错误。

我不同意这两个问题是相关的,甚至它们是同一个错误,但对于我来说,这两个问题看起来略有不同:

  • 在解决方案方面,我们从仅将优化障碍( @llvm.sideeffect )应用于非终止循环,然后将其应用于每个Rust函数。

  • 在值方面,无限loop很有用,因为程序永远不会终止。 对于无限递归,程序是否终止取决于优化级别(例如LLVM是否将递归转换为循环),以及何时以及如何终止程序取决于平台(堆栈大小,受保护的保护页面等)。 要使Rust实现听起来不错,就必须同时修复这两个问题,但是对于无限递归而言,如果用户希望其程序永远递归,则合理的实现仍然会“错误”,因为它并不总是永远递归。

在解决方案方面,我们从仅将优化障碍(@ llvm.sideeffect)应用于非终止循环,然后将其应用于每个Rust函数。

需要进行的分析表明,循环体实际上具有副作用(不仅仅是潜在的影响,如对外部函数的调用一样),因此不需要llvm.sideeffect插入是非常棘手的,可能顺序大致相同。表示与可能属于无限递归的函数相同的幅度。 不首先进行大量优化就很难证明循环即将终止,因为大多数Rust循环都涉及迭代器。 因此,我认为我们最终会将llvm.sideeffect放入绝大多数循环中。 诚然,有很多函数不包含循环,但是对我来说,这似乎还不是质的区别。

如果我正确理解问题,要解决无限循环问题,将llvm.sideeffect插入loop { ... }while <compile-time constant true> { ... } ,其中循环的主体不包含break表达式。 这捕获了无限循环的C ++语义和Rust语义之间的区别:在Rust中,与C ++不同,不允许编译器假定在编译时知道_doesn't_时,循环终止。 (我不确定在面对身体可能会惊慌的循环时,我们有多少需要担心的正确性,但是以后总是可以改善的。)

我不知道如何处理无限递归,但我同意RalfJung的观点,即将无限递归优化为无关的段错误是不理想的行为。

@zackw

如果我正确理解了问题,要解决无限循环的情况,将llvm.sideeffect插入循环{...}和{...},其中循环的主体不包含break表达式。

我认为不是那么简单,例如loop { if false { break; } }是一个包含break表达式的无限循环,但是我们需要插入@llvm.sideeffect以防止llvm将其删除。 除非我们能证明循环总是终止,否则我们必须插入@llvm.sideeffect

@gnzlbg

loop { if false { break; } }是一个包含break表达式的无限循环,但是我们需要插入@llvm.sideeffect以防止llvm将其删除。

嗯,是的,这很麻烦。 但是我们不必太完美,只是保守地正确。 像这样的循环

while spinlock.load(Ordering::SeqCst) != 0 {}

(从std::sync::atomic文档中)很容易被视为不需要@llvm.sideeffect ,因为控制条件不是恒定的(原子加载操作最好作为LLVM的副作用。 ,或者我们有更大的问题)。 程序生成器可能会发出的一种有限循环,

loop {
    if /* runtime-variable condition */ { break }
    /* more stuff */
}

也不应该麻烦。 实际上,在任何情况下,“循环主体中的no break表达式”规则都出错了_besides_

loop {
    if /* provably false at compile time */ { break }
}

我认为这个问题是关于为无限循环修复Rust的实现,以便定义生成的LLVM-IR的行为,为此,@ llvm.sideeffect听起来是一个不错的解决方案。

很公平。 但是,正如您所说,问题(Rust语义和LLVM语义之间的不匹配)实际上是关于非终止而不是循环。 所以我认为这就是我们应该在这里跟踪的内容。

@zackw

如果我正确理解了问题,要解决无限循环的情况,将llvm.sideeffect插入循环{...}和{...},其中循环的主体不包含break表达式。 这捕获了无限循环的C ++语义和Rust语义之间的区别:在Rust中,与C ++不同,不允许编译器假定在编译时不知道循环的情况下终止循环。 (我不确定在面对身体可能会惊慌的循环时,我们有多少需要担心的正确性,但是以后总是可以改善的。)

您描述的内容适用于C。在Rust中,任何循环都可以分开。 其他一切都做不到。

因此,例如

while test_fermats_last_theorem_on_some_random_number() { }

在Rust中是一个不错的程序(但在C和C ++中都没有),它将永远循环而不引起副作用。 因此,必须是所有循环,但我们可以证明将终止的循环除外。

@zackw

在任何情况下,“循环主体中没有break表达式”规则都会出错

不只是if /*compile-time condition */ 。 所有控制流均受到影响( whilematchfor ,...),并且运行时条件也受到影响。

但是我们不必完美无缺,只是保守地正确。

考虑:

fn foo(x: bool) { loop { if x { break; } } }

其中x是运行时条件。 如果我们在这里不发出@llvm.sideeffect ,那么如果用户在某个地方写入foo(false) ,则可以内联foo ,并通过不断传播和消除无效代码,将循环优化为无副作用的无限循环,导致优化不当。

如果这是有道理的,则允许LLVM进行的一种转换是将foo替换foo_opt

fn foo_opt(x: bool) { if x { foo(true) } else { foo(false) } }

如果不使用@llvm.sideeffect ,则两个分支都将被独立优化,第二个分支将被错误地优化。

也就是说,为了能够省略@llvm.sideeffect ,我们将需要证明LLVM在任何情况下都不会错误地优化该循环。 证明这一点的唯一方法是要么证明循环总是终止,要么证明如果循环没有终止,它无条件地执行了防止错误优化的事情之一。 即使这样,诸如循环拆分/剥离之类的优化也可以将一个循环转换为一系列循环,对于其中一个循环而言,没有@llvm.sideeffect就足以发生错误的优化。

在我看来,有关此错误的所有内容听起来像是从LLVM解决要比从rustc解决起来容易得多。 (免责声明:我真的不知道这两个项目的代码库)

据我了解,LLVM的修复方法是将优化从运行(证明非终止||不能证明两者)更改为仅在可以证明非终止(或相反)的情况下运行。 我并不是说这很容易(无论如何),但是LLVM已经(我想)已经包含了试图证明(非)终止循环的代码。

另一方面, rustc只能这样做,增加@llvm.sideeffect ,这对优化有更大的影响,而不仅仅是“禁用”不适当使用非终止的优化。 rustc必须嵌入新代码以尝试检测(非)终止循环。

所以我认为前进的方向是:

  1. 在每个循环和函数调用上添加@llvm.sideeffect以解决此问题
  2. 修复LLVM不会对非终止循环执行错误的优化,并删除@llvm.sideeffects

你怎么看待这件事? 我希望步骤1的性能影响不会太可怕,即使一旦实施2就消失了……

@Ekleog就是@sunfishcode的第二个补丁: https//lists.llvm.org/pipermail/llvm-dev/2017-October/118595.html

功能属性建议的一部分是
将LLVM IR的默认语义更改为具有已定义的行为
无限循环,然后向op-UB添加一个选择属性。 所以
如果我们这样做的话,@ llvm.sideeffect的作用就会变得有点
微妙的-这将是C之类的语言的前端选择的一种方式
加入潜在的UB以获得某项功能,但随后退出个人
循环该功能。

为了对LLVM公平起见,编译器作者不会从“我要编写证明循环是非终止的优化,以便我可以进行脚踏实地的优化!”的角度来探讨该主题。 取而代之的是,在某些常见的编译器算法中自然会产生循环终止或产生副作用的假设。 解决这个问题不仅是对现有代码的调整; 这将需要大量的新复杂性。

考虑以下用于测试功能主体是否“无副作用”的算法:如果主体中的任何指令都具有潜在的副作用,则该功能主体可能具有副作用。 漂亮又简单。 然后,稍后删除对“无副作用”的函数的调用。 凉。 除此之外,分支指令被认为没有副作用,因此仅包含分支的函数似乎没有副作用,即使它可能包含无限循环。 哎呀。

它是可修复的。 如果有人对此感兴趣,我的基本想法是将“有副作用”的概念分为“有实际副作用”和“可能不会终止”的独立概念。 然后遍历整个优化器,找到所有关心“有副作用”的地方,并弄清它们实际需要哪些概念。 然后讲授循环遍历,以将元数据添加到不属于循环的分支中,或者分支所在的循环可证明是有限的,从而避免了悲观化。


可能的折衷办法是,当用户从字面上写一个空的loop { } (或类似值)或无条件递归(已经有一个皮棉)时,将rustc插入@ llvm.sideeffect。 这种折衷将使实际上确实打算使用无限无效旋转循环的人们获得它,同时避免其他所有人的开销。 当然,这种妥协不会使崩溃安全代码成为不可能,但是这可能会减少意外发生代码的可能性,并且看起来应该很容易实现。

取而代之的是,在某些常见的编译器算法中自然会产生循环终止或产生副作用的假设。

但是,即使您甚至开始考虑这些转换的正确性也是完全不自然的。 坦率地说,我仍然认为允许这种假设是C的一个巨大错误,但是很好。

如果体内的任何指令有潜在的副作用,则功能主体可能会产生副作用。

当您开始正式看待事物时,通常有一个很好的理由将“非终止”视为一种效果。 (Haskell并非纯粹,它具有两个作用:非终止和异常。)

可能的折衷办法是,当用户从字面上写一个空循环{}(或类似值)或无条件递归(已经有一个皮棉)时,将rustc插入@ llvm.sideeffect。 这种折衷将使实际上确实打算使用无限无效旋转循环的人们获得它,同时避免其他所有人的开销。 当然,这种妥协不会使崩溃安全代码成为不可能,但是这可能会减少意外发生代码的可能性,并且看起来应该很容易实现。

正如您自己指出的那样,这仍然是不正确的。 我认为我们不应该接受我们知道是不正确的“解决方案”。 编译器是我们基础架构不可或缺的一部分,我们不应该只希望没有错。 这是无法建立坚实基础的方法。


这里发生的是,正确性的概念建立在编译器所做的事情的周围,而不是从“我们希望从编译器中得到什么”开始,然后制定其规范。 正确的编译器不会使程序始终变成终止的程序。 我发现这是不言而喻的,但是在Rust具有合理的字体系统的情况下,甚至可以在类型中清楚地看到这一点,这就是为什么该问题经常出现的原因。

鉴于我们正在使用的约束(即LLVM),我们应该做的是从在足够的地方添加llvm.sideeffect以确保每个不同的执行都可以无限执行“其中的许多”。 然后我们达到了合理的(合理且正确的)基准,并且可以在可以保证不需要它们时通过删除这些注释来谈论改进。

为了使我的观点更准确,我认为以下是一个不错的Rust板条箱,其中pick_a_number_greater_2返回(不确定)某种big-int:

fn test_fermats_last_theorem() -> bool {
  let x = pick_a_number_greater_2();
  let y = pick_a_number_greater_2();
  let z = pick_a_number_greater_2();
  let n = pick_a_number_greater_2();
  // x^n + y^n = z^n is impossible for n > 2
  pow(x, n) + pow(y, n) != pow(z, n)
}

pub fn diverge() -> ! {
  while test_fermats_last_theorem() { }
  // This code is unreachable, as proven by Andrew Wiles
  unsafe { mem::transmute(()) }
}

如果我们消除这个分歧循环,那就是一个错误,应该修复。

到目前为止,对于天真地解决此问题所需的性能,我们甚至还没有任何数字。 在我们这样做之前,我没有理由故意中断上述程序。

实际上, fn foo() { foo() }总是会由于资源耗尽而终止,但是由于Rust抽象机具有无限大的堆栈框架(AFAIK),因此将代码转换成fn foo() { loop {} }是有效的,终止(或者更晚一些,当宇宙冻结时)。 这种转换是否有效? 我会说是的,因为否则我们将无法执行尾部调用优化,除非我们能证明终止,这是不幸的。

有一个unsafe内部函数声明给定的循环,递归,...总是终止会有意义吗? N1528给出了一个例子,如果不能假设循环终止,则循环融合不能应用于遍历链接列表的指针代码,因为链接列表可能是循环的,证明链接列表不是循环的并不是现代编译器可以做到的做。

我完全同意,我们需要彻底解决此问题。 但是,我们的处理方式应注意以下可能性:“在无法证明没有必要的所有地方添加llvm.sideeffect ”可能会使今天正确编译的程序的代码质量下降。 尽管最终需要有一个健全的编译器来解决这些问题,但谨慎的做法是延迟适当的修复时间,以换取避免性能下降和平均而言提高Rust程序员生活质量的交换。时间。 我提议:

  • 与其他针对长期存在的健全性错误的潜在性能下降修复程序(#10184)一样,我们应该在-Z标志后面实施此修复程序,以便能够评估野外对代码库的性能影响。
  • 如果影响可忽略不计,那就太好了,我们可以默认打开此修复程序。
  • 但是,如果有真正的回归,我们可以将该数据带给LLVM人员,并尝试首先改善LLVM(或者我们可以选择吃掉回归并在以后进行修正,但无论如何我们都会做出明智的决定)
  • 如果我们决定由于回归而默认不打开该修复程序,那么我们至少可以继续在语法上空的循环中添加llvm.sideeffect :它们很常见,并且编译错误,导致多个人花费惨重小时内调试怪异的问题(#38136,#47537,#54214,当然还有更多),因此,即使这种缓解措施与健全性错误无关,我们也可以通过适当的方式为开发人员带来切实的收益错误修复。

诚然,这个问题已经存在多年了,这一事实为这种观点提供了信息。 如果这是一次全新的回归,我将更愿意更快地修复它或恢复引入它的PR。

同时,只要此问题尚未解决,是否应该在https://doc.rust-lang.org/beta/reference/behavior-considered-undefined.html中提及?

有一个unsafe内部函数声明给定的循环,递归,...总是终止会有意义吗?

std::hint::reachable_unchecked

偶然地,我遇到了为TCP消息系统编写实际代码的过程。 我有一个无限循环作为权宜之计,直到我建立了一个真正的停止机制,但线程立即退出。

如果有人想打高尔夫测试用例代码:

fn main() {
    (|| loop {})()
}

```
$货物运行-释放
非法指令(核心已转储)

如果有人想打高尔夫测试用例代码:

pub fn main() {
   (|| loop {})()
}

使用@sfanxiang在https://github.com/rust-lang/rust/pull/59546中添加的-Z insert-sideeffect rustc标志,它继续循环:)

之前:

main:
  ud2

后:

main:
.LBB0_1:
  jmp .LBB0_1

顺便说一下,跟踪此问题的LLVM错误是https://bugs.llvm.org/show_bug.cgi?id=965 ,我尚未在该线程中看到它。

@RalfJung是否可以将问题描述中的超链接https://github.com/simnalamburt/snippets/blob/master/rust/src/bin/infinite.rs更新到https://github.com/simnalamburt/snippets/blob /12e73f45f3/rust/infinite.rs这个吗? 以前的超链接已中断很长时间,因为in并不是永久链接。 谢谢! 😛

@simnalamburt完成了,谢谢!

在以下情况下,增加MIR opt级别似乎可以避免优化错误:

pub fn main() {
   (|| loop {})()
}

--emit=llvm-ir -C opt-level=1

define void @_ZN7example4main17hf7943ea78b0ea0b0E() unnamed_addr #0 !dbg !6 {
  unreachable
}

--emit=llvm-ir -C opt-level=1 -Z mir-opt-level=2

define void @_ZN7example4main17hf7943ea78b0ea0b0E() unnamed_addr #0 !dbg !6 {
  br label %bb1, !dbg !10

bb1:                                              ; preds = %bb1, %start
  br label %bb1, !dbg !11
}

https://godbolt.org/z/N7VHnj

rustc 1.45.0-nightly (5fd2f06e9 2020-05-31)

pub fn oops() {
   (|| loop {})() 
}

pub fn main() {
   oops()
}

它为特殊情况提供了帮助,但总体上不能解决问题。 https://godbolt.org/z/5hv87d

通常,只有在使用任何相关优化之前,rustc或LLVM可以证明一个纯函数完全可以解决此问题。

确实,我并不是说它解决了问题。 这种微妙的效果对其他人来说足够有趣,在这里似乎也值得一提。 -Z insert-sideeffect继续纠正这两种情况。

LLVM方面正在发生一些变化:有人建议添加一个功能级别的属性来控制进度保证。 https://reviews.llvm.org/D85393

我不确定为什么每个人(在此处和在LLVM线程上)似乎都在强调有关前进的条款。

消除循环似乎是内存模型的直接结果:只要在值使用之前发生值的计算,就可以移动它们。 现在,如果有证据证明不能使用该值,那就是证明事前没有发生,并且代码可以无限远地迁移到将来,并且仍然满足内存模型。

或者,如果您不熟悉内存模型,请考虑将整个循环抽象为一个计算值的函数。 现在,通过调用该函数来替换循环外的所有值读取。 这种转变当然是有效的。 现在,如果没有使用该值,则不会执行无限循环的函数调用。

只要在使用值之前发生值的计算,就可以移动它们。 现在,如果有证据证明不能使用该值,那就是证明事前没有发生,并且代码可以无限远地迁移到将来,并且仍然满足内存模型。

仅当保证终止计算时,此语句才是正确的。 不终止是一个副作用,就像您可能不会删除打印到stdout的计算(它是“不纯”的)一样,您也可能不会删除不终止的计算。

即使未使用结果,也不能删除以下函数调用:

fn sideeffect() -> u32 {
  println!("Hello!");
  42
}

fn main() {
  let _ = sideffect(); // May not be removed.
}

这对于任何种类的副作用都是正确的,当您用loop {}替换打印内容时,它仍然适用。

关于将非终止作为副作用的主张不仅要求达成一致(没有争议),而且还应就何时应当遵守达成一致。

如果循环计算该值,则将观察到未终止的确定性。 如果允许您重新排序不依赖于循环结果的计算,则不会观察到非终止。

就像LLVM线程上的示例一样。

x = y % 42;
if y < 0 return 0;
...

除法的终止属性与重新排序无关。 现代CPU将尝试并行执行除法,比较,分支预测和成功分支的预取。 因此,如果y为负数,则不能保证观察到在返回0时完成除法。 (这里所说的“观察”是指实际上是使用CPU所在的示波器进行测量,而不是通过程序进行测量)

如果您无法观察到分区已完成,则无法观察到分区已开始。 因此,上面示例中的除法通常将允许重新排序,这是编译器可能会做的:

if y < 0 return 0;
x = y % 42;
...

我说“通常”是因为,也许有些语言不允许这样做。 我不知道Rust是否是这种语言。

纯循环没有什么不同。


我并不是说这不是问题。 我只是说前进的保证不是让它发生的事情。

关于将非终止作为副作用的主张不仅要求达成一致(没有争议),而且还要求就何时应当遵守这一观点达成一致。

我要表达的是编程语言和编译器整个研究领域的共识。 当然,您可以自由地不同意,但是您不妨重新定义“编译器正确性”之类的术语,这对与他人进行讨论没有帮助。

允许的观测值始终在源级别定义。 语言规范定义了一个“抽象机器”,该机器(理想情况下是在详尽的数学细节上)描述了程序允许的可观察行为。 本文档没有讨论任何优化。

然后,根据编译器生成的程序是否仅表现出规范规定的源程序可以观察到的行为来衡量其正确性。 这就是每一种认真对待正确性的编程语言都是如何工作的,这是我们知道的唯一方法,即当编译器正确时如何精确捕获。

每种语言的用处在于定义在源代码级别上可以确切观察到的内容,以及哪些源代码行为被视为“未定义”,因此编译器可能认为它们永远不会发生。 之所以会出现此问题,是因为C ++认为没有其他副作用(“无声发散”)的无限循环是未定义的行为,但是Rust并未这么说。 这意味着Rust中的非终止总是可观察到的,并且必须由编译器保留。 大多数编程语言都选择该选项,因为C ++的选择可以很容易将意外的未定义行为(从而导致严重的错误)引入程序。 Rust保证安全代码不会发生未定义的行为,并且由于安全代码可以包含无限循环,因此必须在Rust中定义无限循环(并因此保留)行为。

如果这些事情令人困惑,我建议做一些背景阅读。 我可以推荐Benjamin Pierce的“类型和编程语言”。 尽管可能很难判断作者的真实情况,但您可能还会在这里找到很多博客文章。

具体而言,如果您的部门示例更改为

x = 42 % y;
if y <= 0 { return 0; }

那么我希望你会同意将条件_cannot_悬挂在除法之上,因为当y为零(从崩溃到返回零)时,这将改变可观察的行为。

同样地,

x = if y == 0 { loop {} } else { y % 42 };
if y < 0 { return 0; }

Rust抽象机允许将其重写为

if y == 0 { loop {} }
else if y < 0 { return 0; }
x = y % 42;

但是第一个条件和循环不能被丢弃。

拉尔夫(Ralf),我不假装不知道您所做的事情的一半,也不想介绍新的含义。 我完全同意正确性的定义(执行顺序必须与程序顺序相对应)。 我只是认为不可终止的“何时”是它的一部分,例如:如果您没有观看循环结果,那么您就没有见证终止的证据(因此不能声称其不正确) 。 我需要重新审视执行模型。

谢谢你陪我

@zackw谢谢。 那是不同的代码,这当然会导致不同的优化。

我关于以与除法相同的方式优化循环的前提是有缺陷的(看不到除法==的结果,看不到循环终止),所以其余的都没有关系。

@olotenko我不知道“观看循环结果”是什么意思。 一个非终止循环使整个程序发散,这被认为是可观察到的行为-这意味着它可以在程序外部观察到。 与之类似,用户可以运行该程序并看到它永远运行。 永远运行的程序可能不会编译成终止的程序,因为这会改变用户对程序的观察。

无论该循环在计算什么,或者是否使用该循环的“返回值”都无关紧要。 重要的是用户在运行程序时可以观察到的内容。 编译器必须确保此可观察的行为保持不变。 不终止被认为是可观察到的。

再举一个例子:

fn main() {
  loop {}
  println!("Hello");
}

由于循环,该程序将永远不会打印任何内容。 但是,如果您优化了循环(或通过打印对循环进行了重新排序),程序将突然打印“ Hello”。 因此,这些优化更改了程序的可观察行为,因此是不允许的。

@RalfJung没关系,我现在知道了。 我最初的问题是“前进进度保证”在这里扮演什么角色。 完全可以根据数据依赖性进行优化。 我的错误是,实际上数据依赖关系不是程序顺序的一部分:实际上,表达式是根据语言语义完全排序的。 如果程序顺序是总计,则没有前向进度保证(我们可以将其重新声明为“程序顺序的任何子路径是有限的”),我们只能(在执行顺序中)仅对可以终止的表达式进行重新排序(并且保留其他一些属性,例如可观察到的同步操作,OS调用,IO等。

我需要对此进行更多思考,但是我想我可以看到为什么我们可以在示例中使用x = y % 42假装除法的原因,即使该除法并未真正为某些输入执行,但是为什么相同的规则不适用于任意循环。 我的意思是总(程序)顺序和部分(执行)顺序的对应关系的微妙之处。

我认为“可观察的行为”可能比这更微妙,因为无限递归最终将导致堆栈溢出崩溃(“终止”,即“用户观察结果”),但是尾部调用优化将其变成一个非终止循环。 至少这是Rust / LLVM将要做的另一件事。 但是,我们不必讨论这个问题,因为那实际上并不是我的问题所在(除非您愿意!我很高兴了解是否可以预料到)。

堆栈溢出

堆栈溢出对于建模确实是一个挑战,这是一个很好的问题。 对于内存不足的情况也是如此。 作为一个近似,我们正式假设它们不会发生。 更好的方法是说,每次调用函数时,都可能由于堆栈溢出而出错,或者程序可能会继续执行-这是对每次调用的不确定性选择。 这样,您就可以大致估计出实际发生的情况。

我们可以(按执行顺序)仅对可以证明为终止的表达式进行重新排序

确实。 而且它们必须“纯净”,即无副作用-您不能对两个println!重新排序。 这就是为什么我们通常也将非终止表示为效果的原因,因为这样所有操作都会简化为“纯表达式可以重新排序”和“非终止表达式不纯”(不纯=有副作用)。

除数也可能不纯,但仅当除以0时才引起恐慌,即控制效果。 这不是直接可观察到的,而是间接观察到的(例如,通过让应急处理程序将某些内容打印到stdout,然后再观察)。 因此,除法只能重新排序,因为我们确定我们不会除以0。

我有一些演示代码,我认为可能是这个问题,但我不确定。 如有必要,我可以将其放入新的错误报告中。
我将其代码放在https://github.com/uglyoldbob/rust_demo的git仓库中

优化了我的无限循环(带有副作用),并生成了陷阱指令。

我不知道这是否是此问题的实例或其他原因...嵌入式设备根本不是我的专长,并且所有这些外部包装箱依赖性我都不知道该代码还在做什么。^^但是您的程序是没有安全,它确实有环路挥发性的访问,所以我会说这是一个单独的问题。 当我将您的示例放在操场上时,我认为它已正确编译,因此我怀疑问题出在额外的依赖项之一。

似乎循环中的所有内容都是对局部变量的引用(没有转义到任何其他线程)。 在这些情况下,很容易证明不存在易失性存储,也没有可观察到的影响(没有它们可以与之同步的存储)。 如果Rust不会对volatile添加特殊含义,那么可以将此循环简化为纯无限循环。

@uglyoldbob如果llvm-objdump并没有明显地无济于事(并且不准确),那么您的示例中真正发生的事情将更加清楚。 bl #4 (实际上不是有效的汇编语法)在这里意味着在bl指令的末尾(也就是main函数的末尾)又分支到4个字节。下一个功能的开始。 下一个函数称为(在我构建时) _ZN11broken_loop18__cortex_m_rt_main17hbe300c9f0053d54dE是您实际的main函数。 与未重整名称的功能main不是你的功能,而是一个完全不同的功能所产生#[entry]宏提供cortex-m-rt 。 您的代码实际上并没有被优化。 (实际上,由于您是在调试模式下构建的,因此优化器甚至没有运行。)

此页面是否有帮助?
0 / 5 - 0 等级