Why match is better than if?

I built code below in release mode, and gets the same assembly for stable, beta and nightly :

pub fn change_value(val: Result<i32, ()>) -> Result<i32, ()> {
    if let Ok(x) = val {
        Ok(x * 2)
    } else {
        Err(())
    }
}

pub fn change_value2(val: Result<i32, ()>) -> Result<i32, ()> {
    match val {
        Ok(x) => Ok(x * 2),
        Err(()) => Err(())
    }
}

which transform to

change_value:
    xorl	%eax, %eax
	testl	%edi, %edi
	setne	%al
	leal	(%rsi,%rsi), %edx
	retq
change_value2:
    movl	%edi, %eax
	leal	(%rsi,%rsi), %edx
	retq

so for match generated much better code, is it general rule, or in this case exceptional?

5 Likes

Funny, if you put it in godbolt and test against Rust 1.60, it generates the same assembly for both (and also seems to recognize it's a duplicate function and consolidates them into a single function)

However, when Rust 1.68 is used I get the same asm as above, which perhaps suggests this is a recently introduced optimization?

3 Likes

I thought if let desugared to match?

1 Like

It changed between 1.64.0 and 1.65.0, probably from upgrading LLVM 14 to 15.

1 Like

Interesting, that the rust-playground

shows that rustic generates different llvm-ir for functions:

define { i32, i32 } @_ZN10playground12change_value17h0230bd4091fd6529E(i32 noundef %0, i32 %1) unnamed_addr #0 {
start:
  %2 = icmp eq i32 %0, 0
  %_4 = shl i32 %1, 1
  %not. = xor i1 %2, true
  %.sroa.0.0 = zext i1 %not. to i32
  %.sroa.3.0 = select i1 %2, i32 %_4, i32 undef
  %3 = insertvalue { i32, i32 } undef, i32 %.sroa.0.0, 0
  %4 = insertvalue { i32, i32 } %3, i32 %.sroa.3.0, 1
  ret { i32, i32 } %4
}

vs

define { i32, i32 } @_ZN10playground13change_value217h3302fbc2853d0a41E(i32 noundef %0, i32 %1) unnamed_addr #0 {
start:
  %switch = icmp eq i32 %0, 0
  %_4 = shl i32 %1, 1
  %.sroa.3.0 = select i1 %switch, i32 %_4, i32 undef
  %2 = insertvalue { i32, i32 } undef, i32 %0, 0
  %3 = insertvalue { i32, i32 } %2, i32 %.sroa.3.0, 1
  ret { i32, i32 } %3
}

so it is rustc by itself generate for if and match different code?

So between 1.64 and 1.65 was some kind of new mir optimization pass was introduced?

2 Likes

Looking at the mir[1], the relevant difference seems between a control flow of

switchInt(move _2) -> [0: bb1, otherwise: bb3]

vs a control flow of

switchInt(move _2) -> [0: bb3, 1: bb1, otherwise: bb2]

for inspecting the discriminant.[2] The latter case leads to bb2: { unreachable } for the otherwise case.

Unsurprisingly, code that clearly asserts that discriminant values other than 0 and 1 are impossible, would seem a bit easier to optimize.


  1. available in the three-dots dropdown next to “BUILD” ↩︎

  2. Of course, one needs to ignore the fact that the meanings of bb1 and bb3 differ, and essentially switch places. ↩︎

1 Like
pub fn change_value3(val: Result<i32, ()>) -> Result<i32, ()> {
    match val {
        Ok(x) => Ok(x * 2),
        _ => Err(())
    }
}

also compiles to the longer assembly.

Thus I suspect the difference is that in the Err(()) => Err(()) version LLVM is willing to leave the input value unchanged, but in the _ => Err(()) version LLVM is unwilling to assume the input has the correct discriminant.

You can imagine an exhaustive match as having an extra _ => unsafe { hint::unreachable_unchecked() }; in the case with a default arm, LLVM presumably assumes that disallowed discriminants should go to the default arm instead of treating them as UB.

Assuming this is the culprit, rustc could mitigate this by only taking the match default arm for valid discriminants and emitting an unreachable_unchecked for invalid discriminants. Or at least when there's only one discriminant for the default arm it makes sense; when multiple discriminants go to it, it might still be better to continue to keep them lumped together into the default switch case, if just to emit slightly less LLVM-IR.

That sounds like a reasonable MIR transform, actually: take switchInt where the otherwise: case is only one case, make it for just that case, and add a new otherwise: which goes to unreachable instead.

11 Likes

Unfortunately, LLVM tends to undo that immediately, because if you have a two-arm switchInt then it "optimizes" that to a br, losing track of the unreachable part.

(This has long been a problem with ?, though nikic has done some good work to improve it https://github.com/rust-lang/rust/issues/85133#issuecomment-1072168354 .)

2 Likes

Manually cleaned, trimmed, and annotated, out of interest:

Input Rust
pub fn change_value1(val: Result<i32, ()>) -> Result<i32, ()> {
    if let Ok(x) = val {
        Ok(x * 2)
    } else {
        Err(())
    }
}

pub fn change_value2(val: Result<i32, ()>) -> Result<i32, ()> {
    match val {
        Ok(x) => Ok(x * 2),
        Err(()) => Err(())
    }
}

pub fn change_value3(val: Result<i32, ()>) -> Result<i32, ()> {
    match val {
        Ok(x) => Ok(x * 2),
        _ => Err(())
    }
}

pub unsafe fn change_value4(val: (bool, MaybeUninit<i32>)) -> (bool, MaybeUninit<i32>) {
    if val.0 {
        (true, MaybeUninit::uninit())
    } else {
        (false, MaybeUninit::new(val.1.assume_init() * 2))
    }
}
--emit=mir -O
fn change_value1(val: Result<i32, ()>) -> Result<i32, ()> {
    let mut return: Result<i32, ()>;
    let mut val_discriminant: isize;
    let mut expr_temp: i32;
    let x: i32;
    goto -> 'start;

    'start: {
        val_discriminant = discriminant(val);
        switchInt(move val_discriminant) -> [0: 'if_true, otherwise: 'if_false];
    }

    'if_true: {
        x = ((val as Result::Ok).0: i32);
        expr_temp = Mul(x, const 2_i32);
        return = Result::<i32, ()>::Ok(move expr_temp);
        goto -> 'return;
    }

    'if_false: {
        return = Result::<i32, ()>::Err(const ());
        goto -> 'return;
    }
}

fn change_value2(val: Result<i32, ()>) -> Result<i32, ()> {
    let mut return: std::result::Result<i32, ()>;
    let mut val_discriminant: isize;
    let mut expr_temp: i32;
    let x: i32;
    goto -> 'start;

    'start: {
        val_discriminant = discriminant(val);
        switchInt(move val_discriminant) -> [0: 'match_ok, 1: 'match_err, otherwise: 'unreachable];
    }

    'match_err: {
        return = Result::<i32, ()>::Err(const ());
        goto -> 'return;
    }

    'unreachable: {
        unreachable;
    }

    'match_ok: {
        x = ((val as Result::Ok).0: i32);
        expr_temp = Mul(x, const 2_i32);
        return = Result::<i32, ()>::Ok(move expr_temp);
        goto -> 'return;
    }
}

fn change_value3(_1: Result<i32, ()>) -> Result<i32, ()> {
    let mut return: std::result::Result<i32, ()>;
    let mut val_discriminant: isize;
    let mut expr_temp: i32;
    let x: i32;
    goto -> 'start;

    'start: {
        val_discriminant = discriminant(val);
        switchInt(move val_discriminant) -> [0: 'match_ok, otherwise: 'match_default];
    }

    'match_default: {
        return = Result::<i32, ()>::Err(const ());
        goto -> 'return;
    }

    'match_ok: {
        x = ((val as Result::Ok).0: i32);
        expr_temp = Mul(x, const 2_i32);
        return = Result::<i32, ()>::Ok(move expr_temp);
        goto -> 'return;
    }
}

fn change_value4(val: (bool, MaybeUninit<i32>)) -> (bool, MaybeUninit<i32>) {
    let mut return: (bool, MaybeUninit<i32>);
    let mut val_discriminant: bool;
    let mut uninit: MaybeUninit<i32>;
    let mut return.1: MaybeUninit<i32>;
    let mut expr_temp: i32;
    let mut x: i32;
    let mut val_payload: MaybeUninit<i32>;
	let mut val_payload_init: ManuallyDrop<i32>;
    goto -> 'start;

    'start: {
        val_discriminant = (val.0: bool);
        switchInt(move val_discriminant) -> [0: 'if_true, otherwise: 'if_false];
    }

    'if_false: {
        uninit = MaybeUninit::<i32> { uninit: const () };
        return = (const true, move uninit);
        goto -> 'return;
    }

    'if_true: {
        val_payload = (val.1: MaybeUninit<i32>);
        val_payload_init = move (val_payload.1: ManuallyDrop<i32>);
        x = move (val_payload_init.0: i32);
        expr_temp = Mul(move x, const 2_i32);
        val_payload_init = ManuallyDrop::<i32> { value: move expr_temp };
        return.1 = MaybeUninit::<i32> { uninit: move val_payload_init };
        return = (const false, move return.1);
        goto -> 'return;
    }
}
--emit=llvm-ir

I can't be bothered to clean unoptimized LLVM IR up, sorry

define { i32, i32 } @change_value1(i32 %0, i32 %1) unnamed_addr #0 !dbg !6 {
start:
  %2 = alloca { i32, i32 }, align 4
  %val = alloca { i32, i32 }, align 4
  %3 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 0
  store i32 %0, ptr %3, align 4
  %4 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 1
  store i32 %1, ptr %4, align 4
  %5 = load i32, ptr %val, align 4, !dbg !11, !range !13, !noundef !10
  %_2 = zext i32 %5 to i64, !dbg !11
  %6 = icmp eq i64 %_2, 0, !dbg !11
  br i1 %6, label %bb1, label %bb3, !dbg !11

bb1:                                              ; preds = %start
  %7 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 1, !dbg !14
  %x = load i32, ptr %7, align 4, !dbg !14, !noundef !10
  %8 = call { i32, i1 } @llvm.smul.with.overflow.i32(i32 %x, i32 2), !dbg !15
  %_5.0 = extractvalue { i32, i1 } %8, 0, !dbg !15
  %_5.1 = extractvalue { i32, i1 } %8, 1, !dbg !15
  %9 = call i1 @llvm.expect.i1(i1 %_5.1, i1 false), !dbg !15
  br i1 %9, label %panic, label %bb2, !dbg !15

bb3:                                              ; preds = %start
  store i32 1, ptr %2, align 4, !dbg !16
  br label %bb4, !dbg !17

bb4:                                              ; preds = %bb2, %bb3
  %10 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 0, !dbg !18
  %11 = load i32, ptr %10, align 4, !dbg !18, !range !13, !noundef !10
  %12 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 1, !dbg !18
  %13 = load i32, ptr %12, align 4, !dbg !18
  %14 = insertvalue { i32, i32 } poison, i32 %11, 0, !dbg !18
  %15 = insertvalue { i32, i32 } %14, i32 %13, 1, !dbg !18
  ret { i32, i32 } %15, !dbg !18

bb2:                                              ; preds = %bb1
  %16 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 1, !dbg !19
  store i32 %_5.0, ptr %16, align 4, !dbg !19
  store i32 0, ptr %2, align 4, !dbg !19
  br label %bb4, !dbg !17

panic:                                            ; preds = %bb1
  call void @_ZN4core9panicking5panic17h9fe598656394a2d2E(ptr align 1 @str.0, i64 33, ptr align 8 @alloc_be64bd1efd00f35dae1bd1422706659e) #4, !dbg !15
  unreachable, !dbg !15
}

define { i32, i32 } @change_value2(i32 %0, i32 %1) unnamed_addr #0 !dbg !20 {
start:
  %2 = alloca { i32, i32 }, align 4
  %val = alloca { i32, i32 }, align 4
  %3 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 0
  store i32 %0, ptr %3, align 4
  %4 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 1
  store i32 %1, ptr %4, align 4
  %5 = load i32, ptr %val, align 4, !dbg !21, !range !13, !noundef !10
  %_2 = zext i32 %5 to i64, !dbg !21
  %6 = icmp eq i64 %_2, 0, !dbg !22
  br i1 %6, label %bb3, label %bb1, !dbg !22

bb3:                                              ; preds = %start
  %7 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 1, !dbg !23
  %x = load i32, ptr %7, align 4, !dbg !23, !noundef !10
  %8 = call { i32, i1 } @llvm.smul.with.overflow.i32(i32 %x, i32 2), !dbg !24
  %_5.0 = extractvalue { i32, i1 } %8, 0, !dbg !24
  %_5.1 = extractvalue { i32, i1 } %8, 1, !dbg !24
  %9 = call i1 @llvm.expect.i1(i1 %_5.1, i1 false), !dbg !24
  br i1 %9, label %panic, label %bb4, !dbg !24

bb1:                                              ; preds = %start
  store i32 1, ptr %2, align 4, !dbg !26
  br label %bb5, !dbg !27

bb2:                                              ; No predecessors!
  unreachable, !dbg !21

bb5:                                              ; preds = %bb4, %bb1
  %10 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 0, !dbg !28
  %11 = load i32, ptr %10, align 4, !dbg !28, !range !13, !noundef !10
  %12 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 1, !dbg !28
  %13 = load i32, ptr %12, align 4, !dbg !28
  %14 = insertvalue { i32, i32 } poison, i32 %11, 0, !dbg !28
  %15 = insertvalue { i32, i32 } %14, i32 %13, 1, !dbg !28
  ret { i32, i32 } %15, !dbg !28

bb4:                                              ; preds = %bb3
  %16 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 1, !dbg !29
  store i32 %_5.0, ptr %16, align 4, !dbg !29
  store i32 0, ptr %2, align 4, !dbg !29
  br label %bb5, !dbg !30

panic:                                            ; preds = %bb3
  call void @_ZN4core9panicking5panic17h9fe598656394a2d2E(ptr align 1 @str.0, i64 33, ptr align 8 @alloc_a2f1ab4ac39f1326c956f8b5fbb4e422) #4, !dbg !24
  unreachable, !dbg !24
}

define { i32, i32 } @change_value3(i32 %0, i32 %1) unnamed_addr #0 !dbg !31 {
start:
  %2 = alloca { i32, i32 }, align 4
  %val = alloca { i32, i32 }, align 4
  %3 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 0
  store i32 %0, ptr %3, align 4
  %4 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 1
  store i32 %1, ptr %4, align 4
  %5 = load i32, ptr %val, align 4, !dbg !32, !range !13, !noundef !10
  %_2 = zext i32 %5 to i64, !dbg !32
  %6 = icmp eq i64 %_2, 0, !dbg !33
  br i1 %6, label %bb2, label %bb1, !dbg !33

bb2:                                              ; preds = %start
  %7 = getelementptr inbounds { i32, i32 }, ptr %val, i32 0, i32 1, !dbg !34
  %x = load i32, ptr %7, align 4, !dbg !34, !noundef !10
  %8 = call { i32, i1 } @llvm.smul.with.overflow.i32(i32 %x, i32 2), !dbg !35
  %_5.0 = extractvalue { i32, i1 } %8, 0, !dbg !35
  %_5.1 = extractvalue { i32, i1 } %8, 1, !dbg !35
  %9 = call i1 @llvm.expect.i1(i1 %_5.1, i1 false), !dbg !35
  br i1 %9, label %panic, label %bb3, !dbg !35

bb1:                                              ; preds = %start
  store i32 1, ptr %2, align 4, !dbg !37
  br label %bb4, !dbg !38

bb4:                                              ; preds = %bb3, %bb1
  %10 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 0, !dbg !39
  %11 = load i32, ptr %10, align 4, !dbg !39, !range !13, !noundef !10
  %12 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 1, !dbg !39
  %13 = load i32, ptr %12, align 4, !dbg !39
  %14 = insertvalue { i32, i32 } poison, i32 %11, 0, !dbg !39
  %15 = insertvalue { i32, i32 } %14, i32 %13, 1, !dbg !39
  ret { i32, i32 } %15, !dbg !39

bb3:                                              ; preds = %bb2
  %16 = getelementptr inbounds { i32, i32 }, ptr %2, i32 0, i32 1, !dbg !40
  store i32 %_5.0, ptr %16, align 4, !dbg !40
  store i32 0, ptr %2, align 4, !dbg !40
  br label %bb4, !dbg !41

panic:                                            ; preds = %bb2
  call void @_ZN4core9panicking5panic17h9fe598656394a2d2E(ptr align 1 @str.0, i64 33, ptr align 8 @alloc_0e586426b6f91c04bafde5cd1b168f27) #4, !dbg !35
  unreachable, !dbg !35
}

define { i8, i32 } @change_value4(i1 zeroext %val.0, i32 %val.1) unnamed_addr #0 !dbg !42 {
start:
  %0 = alloca i32, align 4
  %_2.i = alloca i32, align 4
  %1 = alloca i32, align 4
  %2 = alloca { i8, i32 }, align 4
  br i1 %val.0, label %bb1, label %bb3, !dbg !43

bb3:                                              ; preds = %start
  %3 = call { i32, i1 } @llvm.smul.with.overflow.i32(i32 %val.1, i32 2), !dbg !44
  %_8.0 = extractvalue { i32, i1 } %3, 0, !dbg !44
  %_8.1 = extractvalue { i32, i1 } %3, 1, !dbg !44
  %4 = call i1 @llvm.expect.i1(i1 %_8.1, i1 false), !dbg !44
  br i1 %4, label %panic, label %bb5, !dbg !44

bb1:                                              ; preds = %start
  %5 = load i32, ptr %0, align 4, !dbg !45
  store i8 1, ptr %2, align 4, !dbg !53
  %6 = getelementptr inbounds { i8, i32 }, ptr %2, i32 0, i32 1, !dbg !53
  store i32 %5, ptr %6, align 4, !dbg !53
  br label %bb7, !dbg !54

bb7:                                              ; preds = %bb5, %bb1
  %7 = getelementptr inbounds { i8, i32 }, ptr %2, i32 0, i32 0, !dbg !55
  %8 = load i8, ptr %7, align 4, !dbg !55, !range !56, !noundef !10
  %9 = trunc i8 %8 to i1, !dbg !55
  %10 = getelementptr inbounds { i8, i32 }, ptr %2, i32 0, i32 1, !dbg !55
  %11 = load i32, ptr %10, align 4, !dbg !55
  %12 = zext i1 %9 to i8, !dbg !55
  %13 = insertvalue { i8, i32 } poison, i8 %12, 0, !dbg !55
  %14 = insertvalue { i8, i32 } %13, i32 %11, 1, !dbg !55
  ret { i8, i32 } %14, !dbg !55

bb5:                                              ; preds = %bb3
  store i32 %_8.0, ptr %_2.i, align 4, !dbg !57
  %15 = load i32, ptr %_2.i, align 4, !dbg !65, !noundef !10
  store i32 %15, ptr %1, align 4, !dbg !65
  %16 = load i32, ptr %1, align 4, !dbg !66
  store i8 0, ptr %2, align 4, !dbg !67
  %17 = getelementptr inbounds { i8, i32 }, ptr %2, i32 0, i32 1, !dbg !67
  store i32 %16, ptr %17, align 4, !dbg !67
  br label %bb7, !dbg !54

panic:                                            ; preds = %bb3
  call void @_ZN4core9panicking5panic17h9fe598656394a2d2E(ptr align 1 @str.0, i64 33, ptr align 8 @alloc_73446bd2db1a380533fa7ccd1689a225) #4, !dbg !44
  unreachable, !dbg !44
}
--emit=llvm-ir -O
define { i32, i32 } @change_value1(i32 %val_discriminant, i32 %val_payload) {
  %if_true = icmp ne i32 %val_discriminant, 0
  %expr_temp = shl i32 %val_payload, 1
  %ret_discriminant = zext i1 %if_true to i32
  %ret_payload = select i1 %if_true, i32 undef, i32 %expr_temp
  %3 = insertvalue { i32, i32 } poison, i32 %ret_discriminant, 0
  %4 = insertvalue { i32, i32 } %3, i32 %ret_payload, 1
  ret { i32, i32 } %4
}

define { i32, i32 } @change_value2(i32 %val_discriminant, i32 %val_payload) {
  %switch = icmp eq i32 %val_discriminant, 0
  %expr_temp = shl i32 %val_payload, 1
  %ret_payload = select i1 %switch, i32 %expr_temp, i32 undef
  %2 = insertvalue { i32, i32 } poison, i32 %val_discriminant, 0
  %3 = insertvalue { i32, i32 } %2, i32 %ret_payload, 1
  ret { i32, i32 } %3
}

@change_value3 = alias { i32, i32 } (i32, i32), ptr @change_value1

define { i8, i32 } @change_value4(i1 zeroext %val_discriminant, i32 %val_payload) {
  %expr_temp = shl i32 %val_payload, 1
  %ret_payload = select i1 %val_discriminant, i32 undef, i32 %expr_temp
  %ret_discriminant = zext i1 %val_discriminant to i8
  %1 = insertvalue { i8, i32 } poison, i8 %ret_discriminant, 0
  %2 = insertvalue { i8, i32 } %1, i32 %ret_payload, 1
  ret { i8, i32 } %2
}
--emit=asm -O
change_value1:
        xor     eax, eax         ; $eax   <- 0               ; zero all 4 bytes of %ret.0
        test    edi, edi         ; $ZF    <- %val.0 == 0     ;
        setne   al               ; %ret.0 <- !$ZF            ; only sets the low byte
        lea     edx, [rsi + rsi] ; %ret.1 <- %val.1 + %val.1 ;
        ret                      ;                           ;

change_value2:
        mov     eax, edi         ; %ret.0 <- %val.0          ;
        lea     edx, [rsi + rsi] ; %ret.1 <- %val.1 + %val.1 ;
        ret                      ;                           ;

.set change_value3, change_value1

; seems to not get merged despite being identical because the ABI
; differs; namely, the first argument is `i1 zeroext`, not `i32`
change_value4:
        mov     eax, edi         ; %ret.0 <- %val.0          ;
        lea     edx, [rsi + rsi] ; %ret.1 <- %val.1 + %val.1 ;
        ret                      ;                           ;

That patch is likely exactly what enabled this new optimization, since it extended some branch (two-arm switch) optimizations to (more-arm) switches. We want LLVM to know that the int switched over must be one of the taken arms (exactly what that patch was enabling).

The only MIR difference between change_value2 and change_value3 is the presence of the extra unreachable basic block in change_value2. change_value3 only differs from change_value1 in scope information.

That essentially confirms that changing -> [0: bb1, otherwise: bb2] to -> [0: bb1, 1: bb2, otherwise: unreachable] results in better codegen for LLVM 15.

An MIR optimization shape

Turn

bb0: {
    _1 = discriminant(_0);
    switchInt(move _1) -> [0: bb1, otherwise: bb2];
}

into

bb0: {
    _1 = discriminant(_0);
    switchInt(move _1) -> [0: bb1, 1: bb2, otherwise: bb3];
}

bb3: {
    unreachable;
}

iff discriminant(_0) is known to only have one valid value in the otherwise: branch.


Alternatively, treating Result's discriminant more like bool throughout lowering would also help; defining the equivalent function over (bool, MaybeUninit<i32>) also ends up generating the shorter assembly version, "despite" using -> [0: bb1, otherwise: bb2]. I also tried inserting an intrinsics::assume(intrinsics::discriminant_value(&val) <= 1), and that allowed all four cases to result in the more optimized assembly.

8 Likes

Might be worth trying that more broadly again.

Apparently at some point in the past it wasn't worth it:

3 Likes

Is there an issue for this? If not, it would be a good idea to create one.

I created issue.

5 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.